From fe327546490a7c8efb2382084be26330caa311e2 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Thu, 7 May 2026 14:28:43 -0400 Subject: [PATCH 01/39] MER-190 adding focused event dashboard --- .../src/components/Dashboard/Dashboard.jsx | 6 +- .../src/components/Dashboard/Dashboard.scss | 12 + frontend/src/hooks/useDashboardOverlay.js | 104 ++- .../CommunicationsTab/CommunicationsTab.scss | 2 - .../EventDashboard/EventAnalyticsDetail.jsx | 2 +- .../EventDashboard/EventDashboardFocused.jsx | 762 ++++++++++++++++++ .../EventDashboard/EventDashboardFocused.scss | 537 ++++++++++++ .../EventDashboardFocusedHeader.jsx | 452 +++++++++++ .../EventDashboardFocusedHeader.scss | 591 ++++++++++++++ .../EventJobsManager/JobsManager.scss | 1 - frontend/src/utils/overlayRegistry.js | 99 ++- 11 files changed, 2538 insertions(+), 30 deletions(-) create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss diff --git a/frontend/src/components/Dashboard/Dashboard.jsx b/frontend/src/components/Dashboard/Dashboard.jsx index f8301610..6b59f94e 100644 --- a/frontend/src/components/Dashboard/Dashboard.jsx +++ b/frontend/src/components/Dashboard/Dashboard.jsx @@ -50,6 +50,8 @@ function Dashboard({ const isRestoringOverlayRef = useRef(false); const urlHadOverlayParamsRef = useRef(false); const overlayContentRef = useRef(overlayContent); + const overlayClassName = overlayContent?.props?.className || ''; + const isViewportOverlay = overlayClassName.includes('full-width-event-dashboard-focused'); // Wrapper so closing the overlay also clears persist overlay params from the URL const handleSetOverlayContent = useCallback((content) => { @@ -758,7 +760,7 @@ function Dashboard({ } -
+
+
{overlayContent}
)} diff --git a/frontend/src/components/Dashboard/Dashboard.scss b/frontend/src/components/Dashboard/Dashboard.scss index bb23e9fe..74c48231 100644 --- a/frontend/src/components/Dashboard/Dashboard.scss +++ b/frontend/src/components/Dashboard/Dashboard.scss @@ -411,6 +411,10 @@ overflow: hidden; z-index: 1; + &.dash-right--overlay-fullscreen { + z-index: 1002; + } + &.maximized{ width:100%; max-width: 100%; @@ -441,6 +445,14 @@ overflow-x: hidden; -webkit-overflow-scrolling: touch; + &.dashboard-overlay--viewport { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + z-index: 1003; + } + .full-width-event-viewer { height: 100%; width: 100%; diff --git a/frontend/src/hooks/useDashboardOverlay.js b/frontend/src/hooks/useDashboardOverlay.js index a2641cdf..9ba47de2 100644 --- a/frontend/src/hooks/useDashboardOverlay.js +++ b/frontend/src/hooks/useDashboardOverlay.js @@ -3,6 +3,16 @@ import { useDashboardOptional } from '../contexts/DashboardContext'; import { buildOverlaySearchParams } from '../utils/overlayRegistry'; const EVENT_DASHBOARD_OVERLAY_KEY = 'event-dashboard'; +/** + * Debug/testing switch for which dashboard opens when callers use the default API. + * Flip to 'classic' to open the legacy dashboard by default. + */ +const DEFAULT_EVENT_DASHBOARD_VARIANT = 'focused'; +const EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS = { + default: EVENT_DASHBOARD_OVERLAY_KEY, + focused: 'event-dashboard-focused', + classic: 'event-dashboard-classic' +}; /** * Custom hook for easy overlay management in Dashboard components. @@ -80,7 +90,9 @@ export const useDashboardOverlay = () => { const showEventDashboard = (event, orgId, options = {}) => { if (!hasOverlay || !showOverlay) return; const { - className = 'full-width-event-dashboard', + className = DEFAULT_EVENT_DASHBOARD_VARIANT === 'classic' + ? 'full-width-event-dashboard' + : 'full-width-event-dashboard-focused', persistInUrl = false } = options; @@ -92,9 +104,13 @@ export const useDashboardOverlay = () => { setSearchParams(next, { replace: false }); } - import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard').then(({ default: EventDashboard }) => { + const loadDashboard = DEFAULT_EVENT_DASHBOARD_VARIANT === 'classic' + ? import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard') + : import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused'); + + loadDashboard.then(({ default: EventDashboardComponent }) => { showOverlay( - { }); }; + /** + * Show an EventDashboard overlay variant. + * @param {Object} event + * @param {string} orgId + * @param {Object} options + * @param {'default'|'focused'|'classic'} options.variant + * @param {boolean} options.persistInUrl + * @param {string} options.className + */ + const showEventDashboardVariant = (event, orgId, options = {}) => { + if (!hasOverlay || !showOverlay) return; + const { variant = 'default', persistInUrl = false } = options; + if (variant === 'default') { + showEventDashboard(event, orgId, options); + return; + } + + if (variant === 'focused') { + const className = options.className || 'full-width-event-dashboard-focused'; + if (persistInUrl && event?._id && orgId) { + const next = buildOverlaySearchParams( + searchParams, + EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS.focused, + { eventId: event._id, orgId } + ); + setSearchParams(next, { replace: false }); + } + import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused').then(({ default: EventDashboardFocused }) => { + showOverlay( + + ); + }); + return; + } + + const overlayKey = EVENT_DASHBOARD_VARIANT_OVERLAY_KEYS[variant] || EVENT_DASHBOARD_OVERLAY_KEY; + const className = options.className || ( + variant === 'classic' ? 'full-width-event-dashboard' : `full-width-event-dashboard-${variant}` + ); + + if (persistInUrl && event?._id && orgId) { + const next = buildOverlaySearchParams(searchParams, overlayKey, { + eventId: event._id, + orgId, + }); + setSearchParams(next, { replace: false }); + } + + if (variant === 'classic') { + import('../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard').then(({ default: EventDashboard }) => { + showOverlay( + + ); + }); + return; + } + + showEventDashboard(event, orgId, options); + }; + + /** + * Convenience wrapper for the focused dashboard variant. + * @param {Object} event + * @param {string} orgId + * @param {Object} options + */ + const showEventDashboardFocused = (event, orgId, options = {}) => { + showEventDashboardVariant(event, orgId, { ...options, variant: 'focused' }); + }; + /** * Show an EventPostMortem overlay (for past events) * @param {Object} event - The event object @@ -150,6 +246,8 @@ export const useDashboardOverlay = () => { showEventViewer, showEventWorkspace, showEventDashboard, + showEventDashboardVariant, + showEventDashboardFocused, showEventPostMortem, showAdminEventOperator, }; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss index f8093c91..5ee3f84a 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/CommunicationsTab/CommunicationsTab.scss @@ -2,8 +2,6 @@ display: flex; flex-direction: column; gap: 1.25rem; - padding-bottom: 2rem; - padding-top: 2rem; } // Hero: content only (HeaderContainer provides the box) diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx index ab99f8ad..59bf395a 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAnalyticsDetail.jsx @@ -26,7 +26,7 @@ function EventAnalyticsDetail({ event, stats, orgId, onRefresh }) { const actualRegistrations = stats?.registrationCount ?? analytics?.platform?.registrations ?? analytics?.platform?.uniqueRegistrations ?? analytics?.registrations ?? analytics?.uniqueRegistrations ?? 0; const actualCheckIns = stats?.checkIn?.totalCheckedIn ?? analytics?.platform?.checkins ?? analytics?.platform?.uniqueCheckins ?? 0; const [exporting, setExporting] = useState(false); - const [timeRange, setTimeRange] = useState('30d'); + const [timeRange, setTimeRange] = useState('90d'); // Fetch detailed analytics using the correct route const { data: analyticsData, refetch } = useFetch( diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx new file mode 100644 index 00000000..5effcf0c --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx @@ -0,0 +1,762 @@ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import { analytics } from '../../../../../services/analytics/analytics'; +import { useNotification } from '../../../../../NotificationContext'; +import useAuth from '../../../../../hooks/useAuth'; +import apiRequest from '../../../../../utils/postRequest'; +import { useDashboardOverlay } from '../../../../../hooks/useDashboardOverlay'; +import EventAnnouncementCompose from './EventAnnouncementCompose'; +import EventDashboardOnboarding from './EventDashboardOnboarding/EventDashboardOnboarding'; +import EventOverview from './EventOverview'; +import EventEditorTab from './EventEditorTab/EventEditorTab'; +import AgendaBuilder from './EventAgendaBuilder/AgendaBuilder'; +import JobsManager from './EventJobsManager/JobsManager'; +import EventAnalyticsDetail from './EventAnalyticsDetail'; +import EventCheckInTab from './EventCheckInTab/EventCheckInTab'; +import EventQRTab from './EventQRTab/EventQRTab'; +import RegistrationsTab from './RegistrationsTab/RegistrationsTab'; +import CommunicationsTab from './CommunicationsTab/CommunicationsTab'; +import ComingSoon from './ComingSoon'; +import EventTasksTab from './EventTasksTab'; +import Popup from '../../../../../components/Popup/Popup'; +import EventDashboardFocusedHeader from './EventDashboardFocusedHeader'; +import './EventDashboardFocused.scss'; + +const FORCE_EVENT_DASHBOARD_ONBOARDING = false; +const COLLAB_ACCEPT_BANNER_KEY = 'meridian_event_dash_collab_accept_v1'; +/** Scroll past this (px) in the main content area to animate the header into condensed mode */ +const HEADER_CONDENSE_SCROLL_THRESHOLD = 56; + +function readCollabAcceptDismissStore() { + try { + const raw = localStorage.getItem(COLLAB_ACCEPT_BANNER_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +function getDismissedCollabOrgIdsForEvent(eventId) { + const store = readCollabAcceptDismissStore(); + return store[eventId]?.dismissedCollabOrgIds || []; +} + +function dismissCollabAcceptsForEvent(eventId, collabOrgIds) { + const store = readCollabAcceptDismissStore(); + const prev = new Set(store[eventId]?.dismissedCollabOrgIds || []); + collabOrgIds.forEach((id) => prev.add(String(id))); + store[eventId] = { dismissedCollabOrgIds: [...prev] }; + localStorage.setItem(COLLAB_ACCEPT_BANNER_KEY, JSON.stringify(store)); +} + +function formatCollaboratorNames(names) { + if (!names || names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; +} + +function formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function formatTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); +} + +function getEventStatus(event) { + if (!event?.start_time) return null; + const now = new Date(); + const start = new Date(event.start_time); + const end = new Date(event.end_time || event.start_time); + if (start > now) return 'upcoming'; + if (end < now) return 'passed'; + return 'live'; +} + +function EventDashboardFocused({ event, orgId, onClose, className = '' }) { + const { addNotification } = useNotification(); + const { user } = useAuth(); + const { showEventPostMortem } = useDashboardOverlay(); + const [dashboardData, setDashboardData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const hasNotifiedErrorRef = useRef(false); + const [activeTab, setActiveTab] = useState('overview'); + const [showOnboarding, setShowOnboarding] = useState(false); + const [showAnnouncementSpotlight, setShowAnnouncementSpotlight] = useState(false); + const [openRegistrationSettingsFromAnnouncement, setOpenRegistrationSettingsFromAnnouncement] = useState(false); + const [collabAcceptBannerTick, setCollabAcceptBannerTick] = useState(0); + const [showCancelEventConfirm, setShowCancelEventConfirm] = useState(false); + const [cancelEventConfirmText, setCancelEventConfirmText] = useState(''); + const [cancelingEvent, setCancelingEvent] = useState(false); + const [showMobileMenu, setShowMobileMenu] = useState(false); + const [isMobileView, setIsMobileView] = useState(window.innerWidth <= 768); + const [isClosing, setIsClosing] = useState(false); + const [headerCondensed, setHeaderCondensed] = useState(false); + const closeTimerRef = useRef(null); + + const { data, loading: dataLoading, error, refetch } = useFetch( + event?._id && orgId ? `/org-event-management/${orgId}/events/${event._id}/dashboard` : null + ); + + useEffect(() => { + if (data?.success) { + setDashboardData(data.data); + setLoading(false); + } else if (error || (data && !data.success)) { + if (!hasNotifiedErrorRef.current) { + hasNotifiedErrorRef.current = true; + addNotification({ + title: 'Error', + message: error || data?.message || 'Failed to load event dashboard', + type: 'error' + }); + } + setLoading(false); + } else if (dataLoading) { + setLoading(true); + } + }, [data, error, dataLoading, addNotification]); + + useEffect(() => { + if (refreshTrigger > 0) refetch(); + }, [refreshTrigger, refetch]); + + useEffect(() => { + const handleResize = () => { + const mobile = window.innerWidth <= 768; + setIsMobileView(mobile); + if (!mobile) setShowMobileMenu(false); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + }, []); + + const handleDashboardClose = useCallback(() => { + if (isClosing) return; + setIsClosing(true); + closeTimerRef.current = setTimeout(() => { + onClose?.(); + }, 220); + }, [isClosing, onClose]); + + const handleMainContentScroll = useCallback((e) => { + if (activeTab !== 'overview') return; + const y = e.currentTarget.scrollTop; + const condensed = y > HEADER_CONDENSE_SCROLL_THRESHOLD; + setHeaderCondensed((prev) => (prev === condensed ? prev : condensed)); + }, [activeTab]); + + const handleRefresh = () => setRefreshTrigger((prev) => prev + 1); + + const handleTabChange = useCallback((tabId) => { + setActiveTab(tabId); + if (isMobileView) setShowMobileMenu(false); + if (event?._id && orgId) { + analytics.track('event_workspace_tab_view', { + event_id: event._id, + org_id: orgId, + tab: tabId + }); + } + }, [event?._id, isMobileView, orgId]); + + useEffect(() => { + if (event?._id && orgId && dashboardData && !loading) { + analytics.screen('Event Workspace Focused', { event_id: event._id, org_id: orgId }); + analytics.track('event_workspace_view', { event_id: event._id, org_id: orgId, variant: 'focused' }); + analytics.track('event_workspace_tab_view', { + event_id: event._id, + org_id: orgId, + tab: activeTab + }); + } + }, [event?._id, orgId, dashboardData, loading, activeTab]); + + useEffect(() => { + if (loading || !dashboardData) return; + const urlParams = new URLSearchParams(window.location.search); + const isTestMode = urlParams.get('test-event-onboarding') === 'true'; + const hasSeen = localStorage.getItem('eventDashboardOnboardingSeen'); + if (FORCE_EVENT_DASHBOARD_ONBOARDING || isTestMode || !hasSeen) { + setShowOnboarding(true); + } + }, [loading, dashboardData]); + + const handleOnboardingClose = useCallback(() => { + const urlParams = new URLSearchParams(window.location.search); + const isTestMode = urlParams.get('test-event-onboarding') === 'true'; + if (!FORCE_EVENT_DASHBOARD_ONBOARDING && !isTestMode) { + localStorage.setItem('eventDashboardOnboardingSeen', 'true'); + } + setShowOnboarding(false); + }, []); + + const handleOpenRegistrationSettings = useCallback(() => { + setActiveTab('registrations'); + setOpenRegistrationSettingsFromAnnouncement(true); + }, []); + + const handleAnnouncementSent = useCallback(() => { + addNotification({ + title: 'Announcement sent', + message: 'Event attendees have been notified.', + type: 'success' + }); + handleRefresh(); + }, [addNotification]); + + const isEventCompleted = dashboardData?.stats?.operationalStatus === 'completed'; + const approvalStatus = dashboardData?.event?.status || ''; + const eventStatus = getEventStatus(dashboardData?.event); + + const approvalStatusConfig = useMemo(() => { + if (approvalStatus === 'pending') { + return { tone: 'pending', title: 'Pending review', message: 'This event is currently in an approval or acknowledgement workflow.' }; + } + if (approvalStatus === 'rejected') { + return { tone: 'rejected', title: 'Needs changes', message: 'This event was rejected. Update details and re-submit for review.' }; + } + if (approvalStatus === 'approved') { + return { tone: 'approved', title: 'Approved for publishing', message: 'This event is clear to publish and appear in public experiences.' }; + } + return null; + }, [approvalStatus]); + + const collaborationAcceptBanner = useMemo(() => { + if (!dashboardData?.event || !orgId) return null; + const ev = dashboardData.event; + if (ev.hostingType !== 'Org') return null; + const hostId = String(ev.hostingId?._id || ev.hostingId); + if (String(orgId) !== hostId) return null; + + const eventKey = String(ev._id); + const dismissed = getDismissedCollabOrgIdsForEvent(eventKey); + const unseen = (ev.collaboratorOrgs || []).filter((entry) => { + const cid = String(entry.orgId?._id || entry.orgId); + return entry.status === 'active' && entry.acceptedAt && !dismissed.includes(cid); + }); + if (unseen.length === 0) return null; + return { + names: unseen.map((entry) => entry.orgId?.org_name || 'An organization'), + collabIds: unseen.map((entry) => String(entry.orgId?._id || entry.orgId)), + eventKey + }; + }, [dashboardData, orgId, collabAcceptBannerTick]); + + const handlePostMortem = useCallback(() => { + const eventToShow = dashboardData?.event || event; + if (eventToShow?._id && orgId) { + showEventPostMortem(eventToShow, orgId, { returnToEventDashboard: true }); + } + }, [dashboardData, event, orgId, showEventPostMortem]); + + const handleCancelEvent = useCallback(async () => { + if (!dashboardData?.event?._id || !orgId || cancelingEvent) return; + if (cancelEventConfirmText.trim().toLowerCase() !== 'cancel event') return; + + setCancelingEvent(true); + try { + const response = await apiRequest( + `/org-event-management/${orgId}/events/${dashboardData.event._id}`, + {}, + { method: 'DELETE' } + ); + if (response?.success) { + addNotification({ + title: 'Event cancelled', + message: 'The event has been permanently deleted.', + type: 'success' + }); + setShowCancelEventConfirm(false); + setCancelEventConfirmText(''); + onClose?.(); + return; + } + addNotification({ + title: 'Cancel failed', + message: response?.message || response?.error || 'Unable to cancel this event.', + type: 'error' + }); + } catch (err) { + addNotification({ + title: 'Cancel failed', + message: err?.message || 'Unable to cancel this event.', + type: 'error' + }); + } finally { + setCancelingEvent(false); + } + }, [addNotification, cancelEventConfirmText, cancelingEvent, dashboardData?.event?._id, onClose, orgId, dashboardData?.event?._id]); + + if (loading) { + return ( +
+
+ +

Loading focused dashboard...

+
+
+ ); + } + + if (!dashboardData) { + return ( +
+
+ +

Failed to load focused dashboard

+ +
+
+ ); + } + + const tabs = [ + { + id: 'overview', + label: 'Overview', + icon: 'mingcute:chart-bar-fill', + content: ( +
+ +
+ ) + }, + { + id: 'agenda', + label: 'Agenda', + icon: 'mdi:calendar-clock', + content: ( + + ) + }, + { + id: 'jobs', + label: 'Jobs', + icon: 'mdi:briefcase', + content: + }, + { + id: 'tasks', + label: 'Tasks', + icon: 'mdi:check-circle-outline', + content: + }, + { + id: 'analytics', + label: 'Analytics', + icon: 'mingcute:chart-line-fill', + content: ( + + ) + }, + { + id: 'edit', + label: 'Details', + icon: 'mdi:pencil', + content: ( +
+ +
+
+ +
+ Cancel event + Permanently deletes this event and associated workspace data. This cannot be undone. +
+
+ +
+
+ ) + }, + { + id: 'registrations', + label: 'Registrations', + icon: 'mdi:clipboard-list-outline', + content: ( + setOpenRegistrationSettingsFromAnnouncement(false)} + /> + ) + }, + { + id: 'communications', + label: 'Communications', + icon: 'mdi:message-text', + content: ( + setShowAnnouncementSpotlight(true)} + onOpenRegistrationSettings={handleOpenRegistrationSettings} + onNavigateToAnalytics={() => handleTabChange('analytics')} + /> + ) + }, + { + id: 'checkin', + label: 'Check-In', + icon: 'uil:qrcode-scan', + content: ( + + ) + }, + { + id: 'qr', + label: 'QR Codes', + icon: 'mdi:qrcode', + content: + }, + { + id: 'equipment', + label: 'Equipment', + icon: 'mdi:package-variant', + content: + } + ]; + + const sidebarSections = [ + { + id: 'planning', + label: 'Planning', + tabIds: ['overview', 'agenda', 'jobs', 'tasks', 'edit'] + }, + { + id: 'audience', + label: 'Audience', + tabIds: ['registrations', 'communications', 'checkin', 'qr'] + }, + { + id: 'insights', + label: 'Insights', + tabIds: ['analytics'] + }, + { + id: 'resources', + label: 'Resources', + tabIds: ['equipment'] + } + ]; + + const tabsById = {}; + tabs.forEach((tab) => { + tabsById[tab.id] = tab; + }); + + const resolvedSidebarSections = sidebarSections + .map((section) => ({ + ...section, + tabs: section.tabIds.map((tabId) => tabsById[tabId]).filter(Boolean) + })) + .filter((section) => section.tabs.length > 0); + + const activeTabConfig = tabs.find((tab) => tab.id === activeTab) || tabs[0]; + + return ( + <> +
+ setShowAnnouncementSpotlight(true)} + onPostMortem={handlePostMortem} + showPostMortem={isEventCompleted} + /> + +
+ + +
+
+ {isMobileView && ( +
+ +
+ )} + + {collaborationAcceptBanner && ( +
+

+ {formatCollaboratorNames(collaborationAcceptBanner.names)} accepted your collaboration invite. +

+
+ + +
+
+ )} + + {approvalStatusConfig && ( +
+ {approvalStatusConfig.title} + {approvalStatusConfig.message} +
+ )} + +
+ {tabs.map((tab) => ( +
+ {tab.content} +
+ ))} +
+
+
+
+ + {isMobileView && showMobileMenu && ( +
setShowMobileMenu(false)} + role="presentation" + > +
e.stopPropagation()} + > + {resolvedSidebarSections.map((section) => ( +
+

{section.label}

+
+ {section.tabs.map((tab) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ + + + + + setShowAnnouncementSpotlight(false)} + orgId={orgId} + eventId={event?._id} + eventName={dashboardData?.event?.name} + eventStartTime={dashboardData?.event?.start_time} + orgName={dashboardData?.event?.hostingId?.org_name} + orgProfileImage={dashboardData?.event?.hostingId?.org_profile_image} + organizerName={user?.name || user?.username} + organizerPicture={user?.picture} + onSent={handleAnnouncementSent} + onOpenRegistrationSettings={handleOpenRegistrationSettings} + /> + + { + if (cancelingEvent) return; + setShowCancelEventConfirm(false); + setCancelEventConfirmText(''); + }} + customClassName="event-cancel-confirm-popup" + > +
+
+ +

Cancel Event

+
+
+

Warning: this action is destructive and cannot be undone.

+

This will permanently delete {dashboardData?.event?.name || 'this event'}.

+
+
+ + setCancelEventConfirmText(e.target.value)} + placeholder="cancel event" + autoFocus + /> +
+
+ + +
+
+
+ + ); +} + +export default EventDashboardFocused; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss new file mode 100644 index 00000000..de8da080 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.scss @@ -0,0 +1,537 @@ +.event-dashboard-focused { + --dash-bg: var(--background); + + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + background: var(--dash-bg); + color: var(--text); + animation: eventDashFocusedIn 260ms cubic-bezier(0.22, 1, 0.36, 1); + + + &.full-width-event-dashboard-focused { + box-sizing: border-box; + position: fixed; + inset: 0; + z-index: 1100; + width: 100vw; + min-height: 100vh; + height: 100vh; + max-width: none; + overflow: hidden; + } + + &__body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 250px 1fr; + align-items: stretch; + } + + &.event-dashboard-focused--closing { + animation: eventDashFocusedOut 220ms cubic-bezier(0.4, 0, 1, 1) forwards; + pointer-events: none; + } + + .loading-container, + .error-container { + min-height: 50vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + color: var(--text); + } + + &__sidebar { + padding: 18px 14px 18px 6vw; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 14px; + position: relative; + min-height: 0; + height: 100%; + overflow-y: auto; + background: transparent; + transition: width 0.2s ease, padding 0.2s ease; + animation: eventDashSidebarIn 280ms cubic-bezier(0.22, 1, 0.36, 1); + } + + &__sidebar-header { + display: none; + justify-content: flex-end; + } + + &__sidebar-toggle { + border: 1px solid var(--lighterborder); + background: var(--lightbackground); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + justify-content: center; + cursor: pointer; + + &:hover { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.35); + } + } + + &__event-card { + border-radius: 14px; + padding: 12px; + background: var(--background); + } + + &__event-name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + + h1 { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text); + } + } + + &__event-meta { + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--light-text); + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__nav-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__nav-section-title { + margin: 0; + padding: 0 4px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--light-text); + } + + &__nav-section-items { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__nav-item { + border-radius: 10px; + background: var(--lightbackground); + color: var(--text); + text-align: left; + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + + &.is-active { + background: var(--secondary-color); + color: var(--primary-color); + } + + &:hover { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.35); + color: var(--primary-color); + } + } + + &__main { + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + animation: eventDashMainIn 300ms cubic-bezier(0.22, 1, 0.36, 1); + box-sizing: border-box; + background-color: var(--background); + border-left: 1px solid var(--lighterborder); + box-shadow: var(--shadow); + } + &__main-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background-color: var(--background); + overflow: hidden; + } + + &__mobile-menu-overlay { + display: none; + } + + &__mobile-menu-trigger { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + cursor: pointer; + } + + &__mobile-nav-bar { + display: none; + flex-shrink: 0; + padding: 10px 12px 0; + } + + &__banner, + &__approval-banner { + border-radius: 10px; + border: 1px solid var(--lighterborder); + background: var(--lightbackground); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + &__banner { + p { + margin: 0; + font-size: 0.9rem; + color: var(--text); + } + } + + &__banner-actions { + display: flex; + gap: 8px; + + button { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + padding: 5px 8px; + cursor: pointer; + + &:hover { + background: #4DAA57; + color: white; + border-color: #4DAA57; + } + } + } + + &__approval-banner { + strong { + color: var(--text); + } + + span { + color: var(--light-text); + font-size: 0.88rem; + } + } + + &__content { + min-width: 0; + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 18px 22px; + background-color: var(--background); + + } + + &__tab-panel { + min-width: 0; + + &.is-active { + display: block; + } + } + + .event-status-bubble { + border-radius: 999px; + padding: 3px 8px; + font-size: 0.72rem; + border: 1px solid var(--lighterborder); + + &.draft { + background: rgba(245, 158, 11, 0.12); + color: #b45309; + } + &.upcoming { + background: rgba(77, 170, 87, 0.1); + color: #4DAA57; + border-color: rgba(77, 170, 87, 0.25); + } + &.live { + background: rgba(76, 175, 80, 0.2); + color: #2e7d32; + border-color: rgba(76, 175, 80, 0.4); + } + &.passed { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border-color: rgba(108, 117, 125, 0.2); + } + } + + @media (max-width: 800px) { + &__body { + grid-template-columns: 1fr; + } + + &__sidebar { + position: relative; + top: auto; + height: auto; + max-height: 42vh; + border-right: 0; + border-bottom: 1px solid var(--lighterborder); + } + + &__main { + height: auto; + min-height: 280px; + overflow: visible; + margin: 8px; + padding: 0; + } + + &__nav { + gap: 10px; + } + + &__nav-section-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + } + + @media (max-width: 768px) { + height: 100vh; + min-height: 100vh; + overflow: hidden; + + &__body { + grid-template-columns: 1fr; + flex: 1; + min-height: 0; + } + + &__sidebar { + display: none; + } + + &__mobile-nav-bar { + display: flex; + } + + &__mobile-menu-trigger { + width: 100%; + justify-content: space-between; + } + + &__event-name-row h1 { + font-size: 0.95rem; + } + + &__event-meta p { + font-size: 0.8rem; + } + + &__main { + margin: 8px; + flex: 1; + min-height: 0; + height: auto; + padding: 0; + box-sizing: border-box; + overflow: hidden; + } + + &__main-content { + overflow: hidden; + } + + &__content { + -webkit-overflow-scrolling: touch; + } + + &__banner, + &__approval-banner { + flex-direction: column; + align-items: flex-start; + } + + &__mobile-menu-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.38); + z-index: 1205; + animation: eventDashMobileOverlayIn 170ms ease-out; + } + + &__mobile-menu-sheet { + position: absolute; + left: 10px; + right: 10px; + top: clamp(120px, 22vh, 200px); + max-height: min(68vh, 520px); + overflow-y: auto; + background: var(--background); + border: 1px solid var(--lighterborder); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + animation: eventDashMobileSheetIn 220ms cubic-bezier(0.22, 1, 0.36, 1); + } + + &__mobile-menu-section { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__mobile-menu-section-title { + margin: 0; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--light-text); + } + + &__mobile-menu-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + + &__mobile-menu-item { + border: 1px solid var(--lighterborder); + border-radius: 10px; + background: var(--lightbackground); + color: var(--text); + text-align: left; + padding: 9px 10px; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &.is-active { + background: rgba(77, 170, 87, 0.12); + border-color: rgba(77, 170, 87, 0.4); + } + } + } +} + +@keyframes eventDashFocusedIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes eventDashFocusedOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(10px) scale(0.992); + } +} + +@keyframes eventDashSidebarIn { + from { + opacity: 0; + transform: translateX(-14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes eventDashMainIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes eventDashMobileOverlayIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes eventDashMobileSheetIn { + from { + opacity: 0; + transform: translateY(-10px) scale(0.99); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx new file mode 100644 index 00000000..7c49334f --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.jsx @@ -0,0 +1,452 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useGradient } from '../../../../../hooks/useGradient'; +import { useNotification } from '../../../../../NotificationContext'; +import apiRequest from '../../../../../utils/postRequest'; +import defaultAvatar from '../../../../../assets/defaultAvatar.svg'; +import './EventDashboardFocusedHeader.scss'; + +/** + * Header used only by EventDashboardFocused — keeps styles isolated so the legacy + * EventDashboardHeader + EventDashboard.scss stack can revert unchanged. + */ +function EventDashboardFocusedHeader({ + condensed = false, + event, + stats, + onClose, + onRefresh, + orgId, + onSendAnnouncement, + onPostMortem, + showPostMortem +}) { + const [publishing, setPublishing] = useState(false); + const [eventImageMainColor, setEventImageMainColor] = useState(null); + const { AtlasMain } = useGradient(); + const { addNotification } = useNotification(); + + const getEventStatus = () => { + if (!event?.start_time) return null; + const now = new Date(); + const start = new Date(event.start_time); + const end = new Date(event.end_time || event.start_time); + if (start > now) return 'upcoming'; + if (end < now) return 'passed'; + return 'live'; + }; + + const formatDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + const formatTime = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + }; + + const getTimeUntilEvent = () => { + if (!event?.start_time) return ''; + const now = new Date(); + const start = new Date(event.start_time); + const end = new Date(event.end_time || event.start_time); + const diff = start - now; + + if (diff < 0) { + if (now <= end) return 'Happening now'; + return 'Event has ended'; + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + return `${days} day${days !== 1 ? 's' : ''} until event`; + } else if (hours > 0) { + return `${hours} hour${hours !== 1 ? 's' : ''} until event`; + } else { + return `${minutes} minute${minutes !== 1 ? 's' : ''} until event`; + } + }; + + const handlePreview = () => { + if (!event?._id) return; + const eventUrl = `${window.location.origin}/event/${event._id}`; + window.open(eventUrl, '_blank', 'noopener,noreferrer'); + }; + + const handlePublish = async () => { + if (!event?._id) return; + setPublishing(true); + try { + const res = await apiRequest(`/publish-event/${event._id}`, {}, { method: 'POST' }); + if (res.success) { + addNotification({ + title: 'Event Published', + message: res.status === 'pending' ? 'Event submitted for approval.' : 'Event published successfully.', + type: 'success' + }); + onRefresh?.(); + } else { + throw new Error(res.message || res.error); + } + } catch (err) { + addNotification({ + title: 'Publish Failed', + message: err.message || 'Failed to publish event.', + type: 'error' + }); + } finally { + setPublishing(false); + } + }; + + const handleShare = async () => { + if (!event?._id) return; + const eventUrl = `${window.location.origin}/event/${event._id}`; + try { + await navigator.clipboard.writeText(eventUrl); + addNotification({ + title: 'Success', + message: 'Event link copied to clipboard', + type: 'success' + }); + } catch { + const textArea = document.createElement('textarea'); + textArea.value = eventUrl; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + addNotification({ + title: 'Success', + message: 'Event link copied to clipboard', + type: 'success' + }); + } catch (copyErr) { + addNotification({ + title: 'Error', + message: 'Failed to copy link to clipboard', + type: 'error' + }); + } + document.body.removeChild(textArea); + } + }; + + const eventStatus = getEventStatus(); + const eventImageUrl = event?.image || event?.previewImage; + + useEffect(() => { + let isActive = true; + if (!eventImageUrl) { + setEventImageMainColor(null); + return () => { + isActive = false; + }; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + + const onReady = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + if (isActive) setEventImageMainColor(null); + return; + } + + const sampleSize = 24; + canvas.width = sampleSize; + canvas.height = sampleSize; + ctx.drawImage(img, 0, 0, sampleSize, sampleSize); + + const pixels = ctx.getImageData(0, 0, sampleSize, sampleSize).data; + let r = 0; + let g = 0; + let b = 0; + let count = 0; + + for (let i = 0; i < pixels.length; i += 4) { + const alpha = pixels[i + 3]; + if (alpha < 80) continue; + r += pixels[i]; + g += pixels[i + 1]; + b += pixels[i + 2]; + count += 1; + } + + if (!count) { + if (isActive) setEventImageMainColor(null); + return; + } + + const avgR = Math.round(r / count); + const avgG = Math.round(g / count); + const avgB = Math.round(b / count); + if (isActive) setEventImageMainColor(`${avgR}, ${avgG}, ${avgB}`); + } catch { + if (isActive) setEventImageMainColor(null); + } + }; + + const onError = () => { + if (isActive) setEventImageMainColor(null); + }; + + img.onload = onReady; + img.onerror = onError; + img.src = eventImageUrl; + + return () => { + isActive = false; + img.onload = null; + img.onerror = null; + }; + }, [eventImageUrl]); + + const collaborationOrgs = useMemo(() => { + if (event?.hostingType !== 'Org') return []; + + const currentOrgId = orgId ? String(orgId) : ''; + const map = new Map(); + const hostIdRaw = event.hostingId?._id || event.hostingId; + const hostId = hostIdRaw ? String(hostIdRaw) : ''; + + if (hostId && hostId !== currentOrgId) { + map.set(hostId, { + id: hostId, + name: event.hostingId?.org_name || 'Host organization', + image: event.hostingId?.org_profile_image || defaultAvatar, + role: 'host', + status: 'active' + }); + } + + (event.collaboratorOrgs || []).forEach((entry) => { + const collaboratorIdRaw = entry?.orgId?._id || entry?.orgId; + const collaboratorId = collaboratorIdRaw ? String(collaboratorIdRaw) : ''; + if (!collaboratorId || collaboratorId === currentOrgId || map.has(collaboratorId)) return; + map.set(collaboratorId, { + id: collaboratorId, + name: entry?.orgId?.org_name || 'Organization', + image: entry?.orgId?.org_profile_image || defaultAvatar, + role: 'collaborator', + status: entry?.status === 'active' ? 'active' : 'pending' + }); + }); + + return Array.from(map.values()); + }, [event, orgId]); + + const imageDerivedGradient = eventImageMainColor + ? `radial-gradient(ellipse 56% 72% at 0% 0%, rgba(${eventImageMainColor}, 0.9) 0%, rgba(${eventImageMainColor}, 0.72) 16%, rgba(${eventImageMainColor}, 0.46) 32%, rgba(${eventImageMainColor}, 0.22) 48%, rgba(${eventImageMainColor}, 0.1) 58%, transparent 68%)` + : null; + + return ( +
+
+ {!imageDerivedGradient && } +
+
+
+ +
+ {event?.status === 'draft' && ( + + )} + + + {onSendAnnouncement && event?._id && orgId && ( + + )} + + {showPostMortem && ( + + )} +
+
+
+
+
+ {eventImageUrl ? ( + + ) : null} +
+

{event?.name || 'Event'}

+
+ {event?.status === 'draft' && ( + + Draft + + )} + {event?.status === 'pending' && ( + + Pending Review + + )} + {event?.status === 'rejected' && ( + + Rejected + + )} + {eventStatus && !['draft', 'pending', 'rejected'].includes(event?.status) && ( + + {eventStatus === 'upcoming' && 'Upcoming'} + {eventStatus === 'live' && 'Live'} + {eventStatus === 'passed' && 'Passed'} + + )} +
+
+
+ {event?.hostingType === 'Org' && ( +
+ {collaborationOrgs.length === 0 ? null : ( + <> + with +
    + {collaborationOrgs.map((org, index) => { + const lastIndex = collaborationOrgs.length - 1; + const isLast = index === lastIndex; + const needsAnd = collaborationOrgs.length > 1 && isLast; + const needsComma = index > 0 && !isLast; + return ( +
  • + {needsComma && ( + , + )} + {needsAnd && ( + and + )} + {org.name + {org.name} +
  • + ); + })} +
+ + )} +
+ )} +
+
+
+ +
+ {stats?.registrationCount ?? 0} + Registrations +
+
+
+ +
+ {getTimeUntilEvent() || 'N/A'} + Time Until +
+
+
+
+
+
+ + {formatDate(event?.start_time)} +
+
+ + {formatTime(event?.start_time)} - {formatTime(event?.end_time)} +
+
+ + {event?.location || 'TBD'} +
+
+
+
+ ); +} + +export default EventDashboardFocusedHeader; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss new file mode 100644 index 00000000..9647477c --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedHeader.scss @@ -0,0 +1,591 @@ +/** + * Styles for EventDashboardFocusedHeader only — does not affect legacy EventDashboard / + * EventDashboardHeader. + */ + +.event-dashboard-focused-header { + position: relative; + width: 100%; + flex-shrink: 0; + background: var(--background); + border-bottom: 1px solid var(--lighterborder); + /* Full-bleed gradient; horizontal padding handled on __content */ + padding-top: 2rem; + padding-bottom: 2rem; + padding-left: 0; + padding-right: 0; + box-sizing: border-box; + transition: + padding-top 0.38s cubic-bezier(0.22, 1, 0.36, 1), + padding-bottom 0.38s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.35s ease; + + &__background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.1; + transition: opacity 0.35s ease; + z-index: 0; + overflow: hidden; + pointer-events: none; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__content { + position: relative; + z-index: 1; + padding-left: 6vw; + padding-right: 6vw; + box-sizing: border-box; + } + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + transition: margin-bottom 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__close { + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + padding: 0.5rem; + border-radius: 8px; + transition: all 0.2s ease, font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + font-size: 1.5rem; + + &:hover { + background: var(--lightbackground); + color: #dc3545; + } + } + + &__actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + transition: gap 0.3s ease; + } + + &__btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--lightbackground); + border: 1px solid var(--lighterborder); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease, padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + font-size: 0.9rem; + color: var(--text); + + &:hover { + background: #4daa57; + color: white; + border-color: #4daa57; + transform: translateY(-2px); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + iconify-icon { + font-size: 1.2rem; + + &.spinner { + animation: eventDashboardFocusedHeaderSpin 1s linear infinite; + } + } + } + + &__btn--icon-only { + padding: 0.5rem; + } + + &__btn--publish, + &__btn--announcement, + &__btn--post-mortem { + padding: 0.5rem 1rem; + } + + &__main { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 2rem; + transition: + margin-bottom 0.35s cubic-bezier(0.22, 1, 0.36, 1), + gap 0.35s cubic-bezier(0.22, 1, 0.36, 1), + align-items 0.25s ease; + } + + &__title-section { + flex: 1; + min-width: 300px; + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: gap 0.3s ease, min-width 0.3s ease; + } + + &__title-row { + display: flex; + align-items: flex-start; + flex-wrap: nowrap; + gap: 0.85rem; + min-width: 0; + + @media (max-width: 520px) { + flex-wrap: wrap; + } + } + + &__title-thumb { + width: 4.5rem; + height: 4.5rem; + object-fit: cover; + border-radius: 10px; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); + background: var(--lightbackground); + transition: + width 0.38s cubic-bezier(0.22, 1, 0.36, 1), + height 0.38s cubic-bezier(0.22, 1, 0.36, 1), + border-radius 0.32s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.35s ease; + } + + &__title-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem; + min-width: 0; + flex: 1; + + h1 { + font-size: 2.5rem; + font-weight: 700; + color: var(--text); + margin: 0; + transition: font-size 0.38s cubic-bezier(0.22, 1, 0.36, 1), line-height 0.38s cubic-bezier(0.22, 1, 0.36, 1); + } + } + + &__meta { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + } + + &__status { + padding: 0.4rem 0.9rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + transition: padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + text-transform: capitalize; + display: inline-flex; + align-items: center; + gap: 0.4rem; + + &--draft { + background: rgba(245, 158, 11, 0.12); + color: #b45309; + border: 1px solid rgba(245, 158, 11, 0.35); + } + + &--pending { + background: rgba(77, 170, 87, 0.1); + color: #4daa57; + border: 1px solid rgba(77, 170, 87, 0.2); + } + + &--rejected { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border: 1px solid rgba(108, 117, 125, 0.2); + } + + &--upcoming { + background: rgba(77, 170, 87, 0.1); + color: #4daa57; + border: 1px solid rgba(77, 170, 87, 0.2); + } + + &--live { + background: rgba(76, 175, 80, 0.2); + color: #2e7d32; + border: 1px solid rgba(76, 175, 80, 0.4); + } + + &--passed { + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + border: 1px solid rgba(108, 117, 125, 0.2); + } + } + + &__collab { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + min-height: 22px; + max-height: 8rem; + opacity: 1; + overflow: hidden; + transition: + max-height 0.44s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.28s ease, + min-height 0.3s ease; + } + + &__collab-label { + font-size: 0.85rem; + color: var(--light-text); + white-space: nowrap; + } + + &__collab-list { + margin: 0; + padding: 0; + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + } + + &__collab-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0; + line-height: 1.2; + } + + &__collab-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + } + + &__collab-name { + font-size: 0.82rem; + color: var(--text); + } + + &__collab-sep { + font-size: 0.82rem; + color: var(--light-text); + margin: 0 0.15rem 0 0.05rem; + } + + &__stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; + transition: gap 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.25rem; + background: var(--lightbackground); + border-radius: 10px; + border: 1px solid var(--lighterborder); + min-width: 140px; + transition: all 0.2s ease, gap 0.32s cubic-bezier(0.22, 1, 0.36, 1), padding 0.32s cubic-bezier(0.22, 1, 0.36, 1), min-width 0.32s cubic-bezier(0.22, 1, 0.36, 1); + + &:hover { + border-color: rgba(77, 170, 87, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } + } + + &__stat-icon { + font-size: 1.75rem; + color: #4daa57; + flex-shrink: 0; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + &__stat-value { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + line-height: 1.2; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__stat-label { + font-size: 0.8rem; + color: var(--light-text); + font-weight: 500; + transition: font-size 0.32s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__details { + display: flex; + gap: 2rem; + flex-wrap: wrap; + max-height: 32rem; + opacity: 1; + overflow: hidden; + transition: + max-height 0.46s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.3s ease, + gap 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + &__detail { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text); + font-size: 0.95rem; + + iconify-icon { + font-size: 1.2rem; + color: #4daa57; + } + } + + &--condensed { + padding-top: 0.875rem; + padding-bottom: 0.875rem; + // box-shadow: 0 8px 28px rgba(0, 0, 0, 0.07); + + .event-dashboard-focused-header__background { + opacity: 0.05; + } + + .event-dashboard-focused-header__top { + margin-bottom: 0.5rem; + } + + .event-dashboard-focused-header__close { + font-size: 1.2rem; + } + + .event-dashboard-focused-header__actions { + gap: 0.45rem; + } + + .event-dashboard-focused-header__btn { + padding: 0.375rem 0.72rem; + font-size: 0.82rem; + + iconify-icon { + font-size: 1.05rem; + } + } + + .event-dashboard-focused-header__btn--icon-only { + padding: 0.35rem; + } + + .event-dashboard-focused-header__btn--publish, + .event-dashboard-focused-header__btn--announcement, + .event-dashboard-focused-header__btn--post-mortem { + padding: 0.375rem 0.72rem; + } + + .event-dashboard-focused-header__main { + align-items: center; + margin-bottom: 0; + gap: 1.15rem; + } + + .event-dashboard-focused-header__title-section { + gap: 0.3rem; + min-width: 0; + flex: 1 1 220px; + } + + .event-dashboard-focused-header__title-row { + align-items: center; + } + + .event-dashboard-focused-header__title-thumb { + width: 2.45rem; + height: 2.45rem; + border-radius: 7px; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05); + } + + .event-dashboard-focused-header__title-heading h1 { + font-size: 1.35rem; + line-height: 1.2; + } + + .event-dashboard-focused-header__status { + padding: 0.25rem 0.65rem; + font-size: 0.74rem; + } + + .event-dashboard-focused-header__collab { + max-height: 0; + min-height: 0; + opacity: 0; + margin-bottom: 0; + } + + .event-dashboard-focused-header__stats { + gap: 0.55rem; + } + + .event-dashboard-focused-header__stat { + gap: 0.45rem; + padding: 0.45rem 0.72rem; + min-width: 96px; + } + + .event-dashboard-focused-header__stat-icon { + font-size: 1.2rem; + } + + .event-dashboard-focused-header__stat-value { + font-size: 0.92rem; + } + + .event-dashboard-focused-header__stat-label { + font-size: 0.68rem; + } + + .event-dashboard-focused-header__details { + max-height: 0; + opacity: 0; + gap: 0; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused-header { + transition-duration: 0.01ms !important; + + &__top, + &__main, + &__title-thumb, + &__title-heading h1, + &__collab, + &__details, + &__stats, + &__stat, + &__stat-icon, + &__stat-value, + &__stat-label, + &__status, + &__close, + &__btn, + &__actions, + &__background { + transition-duration: 0.01ms !important; + } + } +} + +@media (max-width: 768px) { + .event-dashboard-focused-header { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + + &__content { + padding-left: max(5vw, 12px); + padding-right: max(5vw, 12px); + } + + &__top { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + &__actions { + width: 100%; + justify-content: flex-start; + } + + &__main { + flex-direction: column; + } + + &__title-heading h1 { + font-size: 2rem; + } + + &--condensed { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + + .event-dashboard-focused-header__title-heading h1 { + font-size: 1.15rem; + line-height: 1.2; + } + } + + &__stats { + width: 100%; + + .event-dashboard-focused-header__stat { + flex: 1; + min-width: auto; + } + } + + &__details { + flex-direction: column; + gap: 1rem; + } + + &__collab-list { + gap: 0.35rem; + } + + &__collab-item { + max-width: 100%; + } + } +} + +@keyframes eventDashboardFocusedHeaderSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss index 7b925974..b7f4935d 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventJobsManager/JobsManager.scss @@ -1,5 +1,4 @@ .roles-manager { - padding: 1.5rem 0; &.loading { display: flex; diff --git a/frontend/src/utils/overlayRegistry.js b/frontend/src/utils/overlayRegistry.js index 1fd4d15c..a9572fe5 100644 --- a/frontend/src/utils/overlayRegistry.js +++ b/frontend/src/utils/overlayRegistry.js @@ -9,6 +9,11 @@ const OVERLAY_URL_PARAM = 'overlay'; * Add keys here when you register a new persistable overlay. */ export const OVERLAY_PARAM_KEYS = [OVERLAY_URL_PARAM]; +/** + * Debug/testing switch for which dashboard 'event-dashboard' restores to. + * Flip to 'classic' to make URL restore open the legacy dashboard. + */ +const DEFAULT_EVENT_DASHBOARD_VARIANT = 'focused'; /** * Register a persistable overlay type so it can be restored from the URL. @@ -98,29 +103,81 @@ export function clearOverlaySearchParams(currentParams) { return next; } -// --- Built-in registration: event-dashboard --- -registerPersistableOverlay('event-dashboard', { - paramKeys: ['eventId', 'orgId'], - async restore(params, { onClose, setOverlayContent }) { - const { eventId, orgId } = params; - if (!eventId || !orgId) return; - try { - const response = await axios.get(`/get-event/${eventId}`, { withCredentials: true }); - const event = response?.data?.event ?? response?.data; - if (!event) return; +const EVENT_DASHBOARD_OVERLAY_VARIANTS = { + default: 'event-dashboard', + focused: 'event-dashboard-focused', + classic: 'event-dashboard-classic' +}; + +function registerEventDashboardOverlayVariant(variantKey, loadComponent, className) { + registerPersistableOverlay(variantKey, { + paramKeys: ['eventId', 'orgId'], + async restore(params, { onClose, setOverlayContent }) { + const { eventId, orgId } = params; + if (!eventId || !orgId) return; + try { + const response = await axios.get(`/get-event/${eventId}`, { withCredentials: true }); + const event = response?.data?.event ?? response?.data; + if (!event) return; + const EventDashboardComponent = await loadComponent(); + setOverlayContent( + React.createElement(EventDashboardComponent, { + event, + orgId, + onClose, + className, + }) + ); + } catch (err) { + console.error(`Failed to restore ${variantKey} overlay:`, err); + } + }, + }); +} + +// --- Built-in registration: event-dashboard variants --- +if (DEFAULT_EVENT_DASHBOARD_VARIANT === 'classic') { + registerEventDashboardOverlayVariant( + EVENT_DASHBOARD_OVERLAY_VARIANTS.default, + async () => { const { default: EventDashboard } = await import( '../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard' ); - setOverlayContent( - React.createElement(EventDashboard, { - event, - orgId, - onClose, - className: 'full-width-event-dashboard', - }) + return EventDashboard; + }, + 'full-width-event-dashboard' + ); +} else { + registerEventDashboardOverlayVariant( + EVENT_DASHBOARD_OVERLAY_VARIANTS.default, + async () => { + const { default: EventDashboardFocused } = await import( + '../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused' ); - } catch (err) { - console.error('Failed to restore event dashboard overlay:', err); - } + return EventDashboardFocused; + }, + 'full-width-event-dashboard-focused' + ); +} + +registerEventDashboardOverlayVariant( + EVENT_DASHBOARD_OVERLAY_VARIANTS.focused, + async () => { + const { default: EventDashboardFocused } = await import( + '../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused' + ); + return EventDashboardFocused; + }, + 'full-width-event-dashboard-focused' +); + +registerEventDashboardOverlayVariant( + EVENT_DASHBOARD_OVERLAY_VARIANTS.classic, + async () => { + const { default: EventDashboard } = await import( + '../pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard' + ); + return EventDashboard; }, -}); \ No newline at end of file + 'full-width-event-dashboard' +); \ No newline at end of file From b65b9c9a8b52158cb316aa0ec5bec6de927491d8 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 25 May 2026 00:01:46 -0400 Subject: [PATCH 02/39] MER-190: implement phase-aware focused event workspace Introduce implicit workflow-phase behavior in the focused event dashboard and add dedicated post-mortem and planning snapshot experiences so organizers see context-specific UI as events progress. --- .../EventsManagement/EventsManagement.jsx | 6 +- .../EventDashboard/EventDashboardFocused.jsx | 551 ++++++++----- .../EventDashboard/EventDashboardFocused.scss | 121 ++- .../EventDashboardFocusedHeader.jsx | 85 +- .../EventDashboardFocusedHeader.scss | 10 +- .../EventDashboardFocusedPostMortem.jsx | 390 +++++++++ .../EventDashboardFocusedPostMortem.scss | 761 ++++++++++++++++++ ...ventDashboardFocusedPostMortemOutcomes.jsx | 326 ++++++++ ...entDashboardFocusedPostMortemOutcomes.scss | 381 +++++++++ .../EventPlanningOverviewSnapshot.jsx | 356 ++++++++ .../EventPlanningOverviewSnapshot.scss | 230 ++++++ .../EventPostMortem/usePostMortemInsights.js | 23 +- 12 files changed, 2943 insertions(+), 297 deletions(-) create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss diff --git a/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx b/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx index 2bcb58da..dd7ed071 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/EventsManagement.jsx @@ -37,7 +37,7 @@ function truncatePreviewDescription(text, max = 450) { function EventsManagement({ orgId, expandedClass, orgData: orgDataProp }) { const { addNotification } = useNotification(); - const { showEventDashboard, hideOverlay } = useDashboardOverlay(); + const { showEventDashboardFocused } = useDashboardOverlay(); const [refreshTrigger, setRefreshTrigger] = useState(0); const [collaborationInvites, setCollaborationInvites] = useState([]); const [loadingInvites, setLoadingInvites] = useState(false); @@ -99,8 +99,8 @@ function EventsManagement({ orgId, expandedClass, orgData: orgDataProp }) { const handleViewEvent = (event) => { const orgIdForDashboard = orgData?.org?.overview?._id; if (orgIdForDashboard) { - showEventDashboard(event, orgIdForDashboard, { - className: 'full-width-event-dashboard', + showEventDashboardFocused(event, orgIdForDashboard, { + className: 'full-width-event-dashboard-focused', persistInUrl: true, }); } diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx index 5effcf0c..4a57e967 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocused.jsx @@ -5,10 +5,10 @@ import { analytics } from '../../../../../services/analytics/analytics'; import { useNotification } from '../../../../../NotificationContext'; import useAuth from '../../../../../hooks/useAuth'; import apiRequest from '../../../../../utils/postRequest'; -import { useDashboardOverlay } from '../../../../../hooks/useDashboardOverlay'; import EventAnnouncementCompose from './EventAnnouncementCompose'; import EventDashboardOnboarding from './EventDashboardOnboarding/EventDashboardOnboarding'; import EventOverview from './EventOverview'; +import EventPlanningOverviewSnapshot from './EventPlanningOverviewSnapshot'; import EventEditorTab from './EventEditorTab/EventEditorTab'; import AgendaBuilder from './EventAgendaBuilder/AgendaBuilder'; import JobsManager from './EventJobsManager/JobsManager'; @@ -21,12 +21,26 @@ import ComingSoon from './ComingSoon'; import EventTasksTab from './EventTasksTab'; import Popup from '../../../../../components/Popup/Popup'; import EventDashboardFocusedHeader from './EventDashboardFocusedHeader'; +import EventDashboardFocusedPostMortem from './EventDashboardFocusedPostMortem'; import './EventDashboardFocused.scss'; const FORCE_EVENT_DASHBOARD_ONBOARDING = false; const COLLAB_ACCEPT_BANNER_KEY = 'meridian_event_dash_collab_accept_v1'; /** Scroll past this (px) in the main content area to animate the header into condensed mode */ const HEADER_CONDENSE_SCROLL_THRESHOLD = 56; +const DEBUG_PHASE_OVERRIDE_STORAGE_KEY = 'eventDashFocused:debugPhaseOverrideByEvent:v1'; +const EVENT_WORKFLOW_PHASES = { + DRAFTING: 'drafting', + PLANNING: 'planning', + RUN_OF_SHOW: 'runOfShow', + POST_MORTEM: 'postMortem' +}; +const EVENT_WORKFLOW_PHASE_LABELS = { + [EVENT_WORKFLOW_PHASES.DRAFTING]: 'Drafting', + [EVENT_WORKFLOW_PHASES.PLANNING]: 'Planning', + [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW]: 'Run of Show', + [EVENT_WORKFLOW_PHASES.POST_MORTEM]: 'Post Mortem' +}; function readCollabAcceptDismissStore() { try { @@ -58,40 +72,43 @@ function formatCollaboratorNames(names) { return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; } -function formatDate(dateString) { - if (!dateString) return ''; - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric' - }); +function inferWorkflowPhase(eventData, stats) { + const status = eventData?.status; + const operationalStatus = stats?.operationalStatus; + if (operationalStatus === 'completed') return EVENT_WORKFLOW_PHASES.POST_MORTEM; + if (status === 'draft' || status === 'pending' || status === 'rejected') return EVENT_WORKFLOW_PHASES.DRAFTING; + + const now = new Date(); + const start = eventData?.start_time ? new Date(eventData.start_time) : null; + const end = eventData?.end_time ? new Date(eventData.end_time) : start; + if (start && end && now >= start && now <= end) return EVENT_WORKFLOW_PHASES.RUN_OF_SHOW; + if (end && now > end) return EVENT_WORKFLOW_PHASES.POST_MORTEM; + + return EVENT_WORKFLOW_PHASES.PLANNING; } -function formatTime(dateString) { - if (!dateString) return ''; - const date = new Date(dateString); - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); +function readDebugPhaseOverrideStore() { + try { + const raw = localStorage.getItem(DEBUG_PHASE_OVERRIDE_STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } } -function getEventStatus(event) { - if (!event?.start_time) return null; - const now = new Date(); - const start = new Date(event.start_time); - const end = new Date(event.end_time || event.start_time); - if (start > now) return 'upcoming'; - if (end < now) return 'passed'; - return 'live'; +function writeDebugPhaseOverrideStore(nextStore) { + try { + localStorage.setItem(DEBUG_PHASE_OVERRIDE_STORAGE_KEY, JSON.stringify(nextStore)); + } catch { + // Ignore debug override persistence failures. + } } function EventDashboardFocused({ event, orgId, onClose, className = '' }) { + const isDevEnv = process.env.NODE_ENV === 'development'; const { addNotification } = useNotification(); const { user } = useAuth(); - const { showEventPostMortem } = useDashboardOverlay(); const [dashboardData, setDashboardData] = useState(null); const [loading, setLoading] = useState(true); const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -108,6 +125,9 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { const [isMobileView, setIsMobileView] = useState(window.innerWidth <= 768); const [isClosing, setIsClosing] = useState(false); const [headerCondensed, setHeaderCondensed] = useState(false); + const [forcePostMortemView, setForcePostMortemView] = useState(false); + const [debugPanelOpen, setDebugPanelOpen] = useState(false); + const [debugPhaseOverride, setDebugPhaseOverride] = useState(''); const closeTimerRef = useRef(null); const { data, loading: dataLoading, error, refetch } = useFetch( @@ -153,6 +173,18 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { if (closeTimerRef.current) clearTimeout(closeTimerRef.current); }, []); + useEffect(() => { + if (!isDevEnv || !event?._id) return; + const eventId = String(event._id); + const store = readDebugPhaseOverrideStore(); + const storedOverride = store[eventId]; + setDebugPhaseOverride( + Object.values(EVENT_WORKFLOW_PHASES).includes(storedOverride) + ? storedOverride + : '' + ); + }, [event?._id, isDevEnv]); + const handleDashboardClose = useCallback(() => { if (isClosing) return; setIsClosing(true); @@ -227,9 +259,15 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { handleRefresh(); }, [addNotification]); + const eventForPhase = dashboardData?.event || event; + const workspaceEvent = dashboardData?.event || event; + const workspaceStats = dashboardData?.stats || {}; + const workspaceAgenda = dashboardData?.agenda || []; const isEventCompleted = dashboardData?.stats?.operationalStatus === 'completed'; const approvalStatus = dashboardData?.event?.status || ''; - const eventStatus = getEventStatus(dashboardData?.event); + const inferredWorkflowPhase = inferWorkflowPhase(eventForPhase, dashboardData?.stats); + const activeWorkflowPhase = debugPhaseOverride || inferredWorkflowPhase; + const isPostMortemMode = forcePostMortemView || activeWorkflowPhase === EVENT_WORKFLOW_PHASES.POST_MORTEM; const approvalStatusConfig = useMemo(() => { if (approvalStatus === 'pending') { @@ -266,11 +304,27 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { }, [dashboardData, orgId, collabAcceptBannerTick]); const handlePostMortem = useCallback(() => { - const eventToShow = dashboardData?.event || event; - if (eventToShow?._id && orgId) { - showEventPostMortem(eventToShow, orgId, { returnToEventDashboard: true }); + setForcePostMortemView(true); + }, []); + + const handleDebugPhaseChange = useCallback((nextPhase) => { + if (!isDevEnv || !event?._id) return; + const eventId = String(event._id); + const normalized = + nextPhase && Object.values(EVENT_WORKFLOW_PHASES).includes(nextPhase) + ? nextPhase + : ''; + setDebugPhaseOverride(normalized); + setForcePostMortemView(false); + + const store = readDebugPhaseOverrideStore(); + if (normalized) { + store[eventId] = normalized; + } else { + delete store[eventId]; } - }, [dashboardData, event, orgId, showEventPostMortem]); + writeDebugPhaseOverrideStore(store); + }, [event?._id, isDevEnv]); const handleCancelEvent = useCallback(async () => { if (!dashboardData?.event?._id || !orgId || cancelingEvent) return; @@ -310,7 +364,7 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { } }, [addNotification, cancelEventConfirmText, cancelingEvent, dashboardData?.event?._id, onClose, orgId, dashboardData?.event?._id]); - if (loading) { + if (loading && !isPostMortemMode) { return (
@@ -321,7 +375,7 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { ); } - if (!dashboardData) { + if (!dashboardData && !isPostMortemMode) { return (
@@ -338,12 +392,25 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { id: 'overview', label: 'Overview', icon: 'mingcute:chart-bar-fill', + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], content: (
+ {activeWorkflowPhase === EVENT_WORKFLOW_PHASES.PLANNING && ( + handleTabChange('tasks')} + /> + )} + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: }, { id: 'tasks', label: 'Tasks', icon: 'mdi:check-circle-outline', - content: + phases: [ + EVENT_WORKFLOW_PHASES.DRAFTING, + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW + ], + content: }, { id: 'analytics', label: 'Analytics', icon: 'mingcute:chart-line-fill', + phases: [ + EVENT_WORKFLOW_PHASES.PLANNING, + EVENT_WORKFLOW_PHASES.RUN_OF_SHOW, + EVENT_WORKFLOW_PHASES.POST_MORTEM + ], content: ( @@ -393,11 +480,12 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { id: 'edit', label: 'Details', icon: 'mdi:pencil', + phases: [EVENT_WORKFLOW_PHASES.DRAFTING, EVENT_WORKFLOW_PHASES.PLANNING], content: (
@@ -427,9 +515,10 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { id: 'registrations', label: 'Registrations', icon: 'mdi:clipboard-list-outline', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], content: ( setShowAnnouncementSpotlight(true)} @@ -457,9 +547,10 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { id: 'checkin', label: 'Check-In', icon: 'uil:qrcode-scan', + phases: [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], content: ( + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], + content: }, { id: 'equipment', label: 'Equipment', icon: 'mdi:package-variant', + phases: [EVENT_WORKFLOW_PHASES.PLANNING, EVENT_WORKFLOW_PHASES.RUN_OF_SHOW], content: } ]; - const sidebarSections = [ - { - id: 'planning', - label: 'Planning', - tabIds: ['overview', 'agenda', 'jobs', 'tasks', 'edit'] - }, - { - id: 'audience', - label: 'Audience', - tabIds: ['registrations', 'communications', 'checkin', 'qr'] - }, - { - id: 'insights', - label: 'Insights', - tabIds: ['analytics'] - }, - { - id: 'resources', - label: 'Resources', - tabIds: ['equipment'] - } - ]; + const sidebarSectionsByPhase = { + [EVENT_WORKFLOW_PHASES.DRAFTING]: [ + { + id: 'drafting-core', + label: 'Drafting', + tabIds: ['overview', 'edit', 'agenda'] + }, + { + id: 'drafting-alignment', + label: 'Alignment', + tabIds: ['jobs', 'tasks'] + } + ], + [EVENT_WORKFLOW_PHASES.PLANNING]: [ + { + id: 'planning', + label: 'Planning', + tabIds: ['overview', 'agenda', 'jobs', 'tasks', 'edit'] + }, + { + id: 'audience', + label: 'Audience', + tabIds: ['registrations', 'communications', 'qr'] + }, + { + id: 'insights', + label: 'Insights', + tabIds: ['analytics'] + }, + { + id: 'resources', + label: 'Resources', + tabIds: ['equipment'] + } + ], + [EVENT_WORKFLOW_PHASES.RUN_OF_SHOW]: [ + { + id: 'live-operations', + label: 'Live Operations', + tabIds: ['overview', 'checkin', 'communications', 'tasks', 'jobs'] + }, + { + id: 'attendees', + label: 'Attendees', + tabIds: ['registrations', 'qr'] + }, + { + id: 'monitoring', + label: 'Monitoring', + tabIds: ['analytics', 'agenda'] + } + ], + [EVENT_WORKFLOW_PHASES.POST_MORTEM]: [ + { + id: 'retrospective', + label: 'Retrospective', + tabIds: ['analytics', 'overview'] + }, + { + id: 'records', + label: 'Records', + tabIds: ['communications', 'registrations'] + } + ] + }; const tabsById = {}; tabs.forEach((tab) => { tabsById[tab.id] = tab; }); - const resolvedSidebarSections = sidebarSections + const visibleTabs = tabs.filter((tab) => !tab.phases || tab.phases.includes(activeWorkflowPhase)); + const visibleTabIds = new Set(visibleTabs.map((tab) => tab.id)); + const phaseSidebarSections = sidebarSectionsByPhase[activeWorkflowPhase] || []; + + const resolvedSidebarSections = phaseSidebarSections .map((section) => ({ ...section, - tabs: section.tabIds.map((tabId) => tabsById[tabId]).filter(Boolean) + tabs: section.tabIds.map((tabId) => tabsById[tabId]).filter((tab) => tab && visibleTabIds.has(tab.id)) })) .filter((section) => section.tabs.length > 0); - const activeTabConfig = tabs.find((tab) => tab.id === activeTab) || tabs[0]; + const activeTabConfig = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0] || tabs[0]; + const effectiveActiveTab = activeTabConfig?.id || activeTab; return ( <>
- setShowAnnouncementSpotlight(true)} - onPostMortem={handlePostMortem} - showPostMortem={isEventCompleted} - /> + {isPostMortemMode ? ( + + ) : ( + <> + setShowAnnouncementSpotlight(true)} + onPostMortem={handlePostMortem} + showPostMortem={isEventCompleted} + /> -
- - -
-
- {isMobileView && ( -
- -
- )} + + - {collaborationAcceptBanner && ( -
-

- {formatCollaboratorNames(collaborationAcceptBanner.names)} accepted your collaboration invite. -

-
- - +
+
+ {isMobileView && ( +
+ +
+ )} + + {collaborationAcceptBanner && ( +
+

+ {formatCollaboratorNames(collaborationAcceptBanner.names)} accepted your collaboration invite. +

+
+ + +
+
+ )} + + {approvalStatusConfig && ( +
+ {approvalStatusConfig.title} + {approvalStatusConfig.message} +
+ )} + +
+ {visibleTabs.map((tab) => ( +
+ {tab.content} +
+ ))} +
-
- )} - - {approvalStatusConfig && ( -
- {approvalStatusConfig.title} - {approvalStatusConfig.message} -
- )} +
+
+ + )} -
- {tabs.map((tab) => ( -
+ + {debugPanelOpen && ( +
+

Dashboard State Debugger

+ + +
+ Active: {EVENT_WORKFLOW_PHASE_LABELS[activeWorkflowPhase]} + Inferred: {EVENT_WORKFLOW_PHASE_LABELS[inferredWorkflowPhase]}
- ))} -
+
+ )}
- -
+ )} - {isMobileView && showMobileMenu && ( + {!isPostMortemMode && isMobileView && showMobileMenu && (
setShowMobileMenu(false)} @@ -667,7 +836,7 @@ function EventDashboardFocused({ event, orgId, onClose, className = '' }) { +
+
+

Retrospective

+

{(postMortemSummary.eventData?.name || 'Event').toUpperCase()}

+

+ {canonicalMetrics.hasCheckInTracking + ? `${formatNumber(canonicalMetrics.checkIns)} attendees showed up.` + : `${formatNumber(canonicalMetrics.registrations)} people registered interest.`} +

+
+ +
+

+ {postMortemSummary.eventData?.name || 'This event'} ran with{' '} + {formatNumber(canonicalMetrics.registrations)} registrations + {canonicalMetrics.hasCheckInTracking && canonicalMetrics.showRate != null ? ( + <> and a {canonicalMetrics.showRate.toFixed(1)}% show rate. + ) : ( + <>. Attendance was not tracked for this event. + )} +

+
+ + + {formatDateRangeLabel(postMortemSummary.eventData?.start_time, postMortemSummary.eventData?.end_time)} + + + + {postMortemSummary.eventData?.location || 'Location TBD'} + + {canonicalMetrics.expectedVariance != null && ( + + + {canonicalMetrics.expectedVariance >= 0 ? '+' : ''} + {canonicalMetrics.expectedVariance.toFixed(0)}% vs expected + + )} + {!canonicalMetrics.hasCheckInTracking && ( + + + Attendance not captured + + )} +
+
+
+ +
+ +
+ {isDashboardLoading && ( +
+ +

Loading post-mortem workspace...

+
+ )} + {dashboardLoadError && ( +
+ +

Some dashboard data failed to load. Showing available post-mortem data.

+
+ )} + +
+
+
+ ); +} + +export default EventDashboardFocusedPostMortem; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss new file mode 100644 index 00000000..728ed65f --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortem.scss @@ -0,0 +1,761 @@ +.event-dashboard-focused__post-mortem { + position: relative; + overflow-y: auto; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; + animation: eventDashFocusedPostMortemEnter 460ms cubic-bezier(0.16, 0.84, 0.24, 1) both; +} + +.event-dashboard-focused__post-mortem-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.1; + z-index: 0; + pointer-events: none; + + img { + width: 100%; + object-fit: cover; + } +} + +.event-dashboard-focused__post-mortem-content { + --pm-page-gutter: 6vw; + + @media (min-width: 1401px) { + --pm-page-gutter: 7.5vw; + } + + position: relative; + z-index: 1; + flex: 0 0 auto; + min-height: 0; + padding: 18px var(--pm-page-gutter) 28px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0; + overflow: visible; +} + +.event-dashboard-focused__post-mortem-hero { + display: flex; + gap: 20px; + align-items: stretch; + position: sticky; + flex-wrap:wrap; + top: 0; + z-index: 12; + padding-bottom: 0; + margin-left: calc(var(--pm-page-gutter) * -1); + margin-right: calc(var(--pm-page-gutter) * -1); + padding-left: var(--pm-page-gutter); + padding-right: var(--pm-page-gutter); + background: transparent; + animation: eventDashFocusedPostMortemSectionEnter 520ms cubic-bezier(0.16, 0.84, 0.24, 1) 60ms both; +} + +.event-dashboard-focused__post-mortem-hero-main { + flex: 1; + padding: 16px 20px 18px; + display: flex; + flex-direction: column; + gap: 12px; + border-radius: 12px; +} + +.event-dashboard-focused__post-mortem-header { + display: block; +} + +.event-dashboard-focused__post-mortem-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.15rem; +} + +.event-dashboard-focused__post-mortem-close { + background: transparent; + border: none; + color: var(--text); + padding: 0.5rem; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + iconify-icon { + font-size: 1.45rem; + } + + &:hover { + background: var(--lightbackground); + color: #dc3545; + } +} + +.event-dashboard-focused__post-mortem-heading { + flex: 1; + + p { + margin: 0 0 10px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--light-text); + } + + h2 { + margin: 0 0 20px; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #2f7d46; + } + + h1 { + margin: 0; + font-size: clamp(1.55rem, 3.1vw, 2.55rem); + line-height: 0.99; + color: var(--text); + letter-spacing: -0.02em; + max-height: 200px; + overflow: hidden; + transform: translateY(0); + + em { + font-style: italic; + font-weight: 500; + color: rgba(17, 17, 17, 0.76); + } + } +} + +.event-dashboard-focused__post-mortem-intro { + max-height: 240px; + overflow: hidden; + transform: translateY(0); + + p { + margin: 0 0 10px; + color: var(--text); + line-height: 1.45; + font-size: 1.05rem; + max-width: 65ch; + } +} + +.event-dashboard-focused__post-mortem-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--lighterborder); + background: var(--background); + border-radius: 999px; + padding: 5px 10px; + color: var(--light-text); + font-size: 0.82rem; + } +} + +.event-dashboard-focused__post-mortem-poster { + border-radius: 20px; + overflow: hidden; + box-shadow: + 0 30px 40px rgba(0, 0, 0, 0.22), + 0 6px 18px rgba(0, 0, 0, 0.12); + background: white; + min-height: 300px; + width: 250px; + flex: 0 0 250px; + display: flex; + position: relative; + transform: rotate(1.5deg); + transform-origin: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +} + +.event-dashboard-focused__post-mortem-poster-caption { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 12px 12px 11px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.68) 100%); + color: #fff; + + p { + margin: 0 0 4px; + font-size: 0.9rem; + font-weight: 700; + line-height: 1.2; + } + + span { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.86); + } +} + +.event-dashboard-focused__post-mortem-poster-fallback { + width: 100%; + padding: 18px; + background: linear-gradient(140deg, #1a1a1a, #373737); + color: #f5f5f5; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 8px; + + p { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + } + + span { + color: rgba(245, 245, 245, 0.82); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } +} + +.event-dashboard-focused__post-mortem-kpi-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-radius: 16px; + overflow: hidden; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + + article { + padding: 12px 14px; + border-right: 1px solid var(--lighterborder); + display: flex; + flex-direction: column; + gap: 6px; + + &:last-child { + border-right: 0; + } + } + + span { + color: var(--light-text); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + color: var(--text); + font-size: 1.8rem; + line-height: 1; + } + + &--below-hero { + display: none; + } +} + +.event-dashboard-focused__post-mortem-hero-main > .event-dashboard-focused__post-mortem-kpi-strip { + margin-top: 4px; +} + +@media (max-width: 1000px) { + .event-dashboard-focused__post-mortem-kpi-strip--hero-main { + display: none; + } + + .event-dashboard-focused__post-mortem-kpi-strip--below-hero { + display: grid; + } +} + +.event-dashboard-focused__post-mortem-main { + flex: 0 0 auto; + min-height: 0; + margin-top: -1px; + border-radius: 16px; + border-top-left-radius: 0; + border-top-right-radius: 0; + overflow: visible; + position: relative; + z-index: 1; + animation: eventDashFocusedPostMortemSectionEnter 560ms cubic-bezier(0.16, 0.84, 0.24, 1) 120ms both; +} + +.event-dashboard-focused__post-mortem-inline-loading, +.event-dashboard-focused__post-mortem-inline-error { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--lighterborder); + background: rgba(255, 255, 255, 0.8); + color: var(--light-text); + + p { + margin: 0; + font-size: 0.9rem; + } +} + +.event-dashboard-focused__post-mortem-inline-error { + color: #8f2d2d; + background: rgba(255, 243, 243, 0.9); +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused__post-mortem, + .event-dashboard-focused__post-mortem-hero, + .event-dashboard-focused__post-mortem-main { + animation: none; + } +} + +.event-dashboard-focused__post-mortem-tabs { + height: auto; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__body { + padding: 0; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container, +.event-dashboard-focused__post-mortem-tabs .tabbed-container__main, +.event-dashboard-focused__post-mortem-tabs .tabbed-container__content { + height: auto; + min-height: 0; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__content { + overflow: visible; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper { + margin-top: 0; + margin-bottom: -1px; + border-bottom: 1px solid var(--lighterborder); + background: rgba(255, 255, 255, 0.88); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.event-dashboard-focused__pm-tab { + padding: 20px; + display: flex; + flex-direction: column; + gap: 18px; + min-height: 420px; +} + +.event-dashboard-focused__pm-outcomes-full { + min-height: 320px; +} + +.event-dashboard-focused__pm-loading, +.event-dashboard-focused__pm-error { + min-height: 220px; + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--light-text); + + .spinner { + animation: eventDashFocusedPostMortemSpin 1s linear infinite; + } +} + +.event-dashboard-focused__pm-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.event-dashboard-focused__pm-kpi-card { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; + + p { + margin: 0 0 7px; + color: var(--light-text); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + color: var(--text); + font-size: 1.4rem; + line-height: 1.1; + } +} + +.event-dashboard-focused__pm-insights h3 { + margin: 0 0 10px; + font-size: 1rem; +} + +.event-dashboard-focused__pm-insight-list { + display: grid; + gap: 8px; +} + +.event-dashboard-focused__pm-insight-list--condensed { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.event-dashboard-focused__pm-insight-item { + border: 1px solid var(--lighterborder); + border-radius: 10px; + padding: 10px; + background: var(--lightbackground); + display: flex; + align-items: flex-start; + gap: 10px; + + p { + margin: 0; + color: var(--text); + font-weight: 600; + } + + span { + color: var(--light-text); + font-size: 0.88rem; + } +} + +.event-dashboard-focused__pm-feedback { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; +} + +.event-dashboard-focused__pm-registrants { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; +} + +.event-dashboard-focused__pm-registrants-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + + h3 { + margin: 0; + font-size: 1rem; + } + + span { + color: var(--light-text); + font-size: 0.86rem; + } +} + +.event-dashboard-focused__pm-registrants-table-wrap { + overflow-x: auto; +} + +.event-dashboard-focused__pm-registrants-table { + width: 100%; + border-collapse: collapse; + min-width: 560px; + + th, + td { + padding: 9px 10px; + text-align: left; + border-bottom: 1px solid var(--lighterborder); + font-size: 0.88rem; + color: var(--text); + } + + th { + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + font-weight: 700; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.event-dashboard-focused__pm-registrants-empty { + min-height: 96px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--light-text); +} + +.event-dashboard-focused__pm-empty { + margin: 0; + color: var(--light-text); +} + +.event-dashboard-focused__pm-details-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.event-dashboard-focused__pm-detail-card { + border: 1px solid var(--lighterborder); + border-radius: 12px; + background: var(--lightbackground); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + + h3 { + margin: 0 0 4px; + font-size: 0.95rem; + } + + p { + margin: 0; + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--light-text); + font-size: 0.88rem; + } + + strong { + color: var(--text); + text-transform: capitalize; + text-align: right; + } +} + +@media (max-width: 768px) { + .event-dashboard-focused__post-mortem-content { + --pm-page-gutter: 12px; + padding: 12px; + } + + .event-dashboard-focused__post-mortem-hero { + flex-direction: column; + } + + .event-dashboard-focused__post-mortem-poster { + width: 100%; + flex-basis: auto; + min-height: 220px; + max-height: 320px; + transform: none; + } + + .event-dashboard-focused__pm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .event-dashboard-focused__post-mortem-kpi-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + + article:nth-child(2n) { + border-right: 0; + } + } + + .event-dashboard-focused__pm-details-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused__pm-insight-list--condensed { + grid-template-columns: 1fr; + } +} + +.event-dashboard-focused__post-mortem { + transition: padding-top 0.58s cubic-bezier(0.2, 0.82, 0.22, 1); +} + +.event-dashboard-focused__post-mortem-hero-main, +.event-dashboard-focused__post-mortem-poster, +.event-dashboard-focused__post-mortem-heading h1, +.event-dashboard-focused__post-mortem-heading h2, +.event-dashboard-focused__post-mortem-intro, +.event-dashboard-focused__post-mortem-kpi-strip { + transition: + transform 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + opacity 0.54s ease, + margin 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + font-size 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + padding 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + max-height 0.54s ease, + width 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + flex-basis 0.58s cubic-bezier(0.2, 0.82, 0.22, 1), + border-width 0.42s ease, + box-shadow 0.42s ease; +} + +.event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper--sticky { + top: var(--pm-tabs-sticky-top, 0px); + z-index: 11; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + background:transparent; +} + +.event-dashboard-focused__post-mortem--condensed { + .event-dashboard-focused__post-mortem-tabs .tabbed-container__tabs-wrapper--sticky { + margin-left: calc(var(--pm-page-gutter, 0px) * -1); + margin-right: calc(var(--pm-page-gutter, 0px) * -1); + padding-left: var(--pm-page-gutter, 0px); + padding-right: var(--pm-page-gutter, 0px); + background-color: var(--background); + } + + .event-dashboard-focused__post-mortem-hero { + gap: 10px; + padding-bottom: 0; + background: rgba(255, 255, 255, 0.95); + } + + .event-dashboard-focused__post-mortem-hero-main { + padding-top: 8px; + padding-bottom: 8px; + gap: 6px; + } + + .event-dashboard-focused__post-mortem-header-top { + margin-bottom: 0.15rem; + } + + .event-dashboard-focused__post-mortem-heading p { + margin-bottom: 2px; + font-size: 0.62rem; + } + + .event-dashboard-focused__post-mortem-heading h2 { + margin-bottom: 0; + font-size: 1rem; + } + + .event-dashboard-focused__post-mortem-heading h1 { + opacity: 0; + max-height: 0; + overflow: hidden; + margin: 0; + font-size: 0; + line-height: 0; + transform: translateY(-8px); + } + + .event-dashboard-focused__post-mortem-intro { + opacity: 0; + max-height: 0; + overflow: hidden; + margin: 0; + padding: 0; + transform: translateY(-8px); + } + + .event-dashboard-focused__post-mortem-kpi-strip { + transform: translateY(-2px); + grid-template-columns: repeat(4, minmax(0, 1fr)); + + article { + padding: 6px 8px; + gap: 2px; + } + + span { + font-size: 0.6rem; + } + + strong { + font-size: 0.92rem; + } + } + + .event-dashboard-focused__post-mortem-poster { + opacity: 0; + width: 0; + flex-basis: 0; + min-height: 0; + max-height: 0; + margin: 0; + border-width: 0; + box-shadow: none; + transform: translateX(12px) scale(0.96); + pointer-events: none; + } +} + +@keyframes eventDashFocusedPostMortemSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes eventDashFocusedPostMortemEnter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes eventDashFocusedPostMortemSectionEnter { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx new file mode 100644 index 00000000..af372921 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.jsx @@ -0,0 +1,326 @@ +import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { usePostMortemInsights } from '../EventPostMortem/usePostMortemInsights'; +import RSVPGrowthChart from './RSVPGrowthChart'; +import FunnelChart from './FunnelChart'; +import './EventDashboardFocusedPostMortemOutcomes.scss'; + +function EventDashboardFocusedPostMortemOutcomes({ + event, + metrics, + uniqueViewersForConversion, + registrationsForConversion, + rsvpGrowth, + funnelData, + platform, + formatNumber +}) { + const hasCheckInTracking = metrics?.hasCheckInTracking; + const sectionRefs = useRef({}); + const [visibleSections, setVisibleSections] = useState({}); + const registerSection = useCallback((id) => (node) => { + if (node) { + sectionRefs.current[id] = node; + } + }, []); + + useEffect(() => { + const entries = Object.entries(sectionRefs.current); + if (entries.length === 0) return undefined; + + const observer = new IntersectionObserver( + (observerEntries) => { + observerEntries.forEach((entry) => { + if (!entry.isIntersecting) return; + const id = entry.target.getAttribute('data-story-section'); + if (!id) return; + setVisibleSections((prev) => (prev[id] ? prev : { ...prev, [id]: true })); + observer.unobserve(entry.target); + }); + }, + { threshold: 0.2, rootMargin: '0px 0px -10% 0px' } + ); + + entries.forEach(([, node]) => observer.observe(node)); + + return () => { + observer.disconnect(); + }; + }, []); + const insights = usePostMortemInsights({ + registrations: metrics?.registrations || 0, + checkIns: metrics?.checkIns || 0, + uniqueViewers: uniqueViewersForConversion, + rsvpGrowth, + referrerSources: platform?.referrerSources, + referrerRegistrations: platform?.referrerRegistrations, + qrReferrerSources: platform?.qrReferrerSources, + formOpens: platform?.uniqueFormOpens, + hasForm: !!event?.registrationFormId, + hasCheckInTracking, + formatNumber, + expectedAttendance: event?.expectedAttendance ?? 0 + }); + + const conversionRate = uniqueViewersForConversion > 0 ? (registrationsForConversion / uniqueViewersForConversion) * 100 : null; + const formCompletionRate = event?.registrationFormId && platform?.uniqueFormOpens > 0 + ? ((metrics?.registrations || 0) / platform.uniqueFormOpens) * 100 + : null; + + const sortedTraffic = useMemo(() => { + const sources = [ + { key: 'org_page', label: 'Org Page' }, + { key: 'explore', label: 'Explore' }, + { key: 'direct', label: 'Direct' }, + { key: 'email', label: 'Email' } + ]; + + const base = sources.map((source) => { + const views = platform?.referrerSources?.[source.key] ?? 0; + const registrations = platform?.referrerRegistrations?.[source.key] ?? 0; + return { + id: source.key, + label: source.label, + views, + registrations, + conversion: views > 0 ? (registrations / views) * 100 : 0 + }; + }).filter((source) => source.views > 0 || source.registrations > 0); + + const qr = (platform?.qrReferrerSources || []).map((source) => ({ + id: `qr-${source.qr_id || source.name}`, + label: source.name || 'QR Code', + views: source.count ?? 0, + registrations: source.registrations ?? 0, + conversion: (source.count ?? 0) > 0 ? ((source.registrations ?? 0) / source.count) * 100 : 0 + })).filter((source) => source.views > 0 || source.registrations > 0); + + return [...base, ...qr].sort((a, b) => b.views - a.views); + }, [platform]); + + const topTrafficSource = sortedTraffic[0] || null; + const learningInsights = useMemo(() => { + const list = []; + const add = (item) => { + if (item) list.push(item); + }; + + add(insights.byCategory.expectedVsActual); + add(insights.byCategory.conversion); + add(insights.byCategory.formCompletion); + add(insights.byCategory.funnelBottleneck); + if (hasCheckInTracking) { + add(insights.byCategory.checkIn); + } + return list; + }, [hasCheckInTracking, insights.byCategory]); + + const actionInsights = useMemo(() => { + const strategic = Array.isArray(insights.byCategory.strategic) ? insights.byCategory.strategic : []; + const list = []; + if (insights.byCategory.trafficInvestment) { + list.push(insights.byCategory.trafficInvestment); + } + return [...list, ...strategic]; + }, [insights.byCategory]); + + return ( +
+
+

Post-Mortem

+

+ {hasCheckInTracking + ? `${formatNumber(metrics?.checkIns || 0)} attendees showed up.` + : `${formatNumber(metrics?.registrations || 0)} registrations captured.`} +

+

+ {formatNumber(metrics?.registrations || 0)} registrations + {' · '} + {conversionRate != null ? `${conversionRate.toFixed(1)}%` : 'n/a'} viewer conversion + {hasCheckInTracking && metrics?.showRate != null + ? ` · ${metrics.showRate.toFixed(1)}% show rate` + : ' · attendance not tracked'} + . +

+
+ +
+
+
+

Insights

+ {learningInsights.length > 0 ? ( +
+ {learningInsights.map((insight, index) => ( +
+ +
+

{insight.text}

+ {insight.sub ? {insight.sub} : null} +
+
+ ))} +
+ ) : ( +

Not enough data to generate insights.

+ )} +
+
+

Recommendations

+ {actionInsights.length > 0 ? ( +
+ {actionInsights.map((insight, index) => ( +
+ +
+

{insight.text}

+ {insight.sub ? {insight.sub} : null} +
+
+ ))} +
+ ) : ( +

No additional actions were generated from this dataset.

+ )} + {!hasCheckInTracking && ( +
+ +
+

Measurement gap

+ Check-ins were not used for this event. +
+
+ )} +
+
+
+ +
+
+
+

Snapshot

+
+
+ Registrations + {formatNumber(metrics?.registrations || 0)} +
+
+ Viewer Conversion + {conversionRate != null ? `${conversionRate.toFixed(1)}%` : 'n/a'} +
+ {hasCheckInTracking ? ( +
+ Check-Ins + {formatNumber(metrics?.checkIns || 0)} +
+ ) : ( +
+ Attendance Capture + Not tracked +
+ )} +
+
+
+ {hasCheckInTracking ? ( +
+ Check-In Rate + {metrics?.showRate != null ? `${metrics.showRate.toFixed(1)}%` : 'n/a'} +
+ ) : ( +
+ Attendance Status + Not captured +
+ )} + {formCompletionRate != null && ( +
+ Form Completion + {formCompletionRate.toFixed(1)}% +
+ )} + {metrics?.expectedVariance != null && ( +
+ Vs Expected Attendance + {metrics.expectedVariance >= 0 ? '+' : ''}{metrics.expectedVariance.toFixed(0)}% +
+ )} +
+
+
+ +
+
+
+
+

Registration Trends

+ {rsvpGrowth ? ( +
+ +
+ ) : ( +

No registration trend data available.

+ )} +
+
+

Audience Funnel

+
+ +
+
+
+
+
+ +
+
+ {topTrafficSource ? ( +
+

{topTrafficSource.label} led discovery.

+ + {formatNumber(topTrafficSource.views)} views, {formatNumber(topTrafficSource.registrations)} registrations, {topTrafficSource.conversion.toFixed(1)}% conversion + +
+ ) : null} + {sortedTraffic.length > 0 ? ( +
+ {sortedTraffic.map((source) => ( +
+ {source.label} + {formatNumber(source.views)} views + {formatNumber(source.registrations)} regs + {source.conversion.toFixed(1)}% +
+ ))} +
+ ) : ( +

No traffic source data available.

+ )} +
+
+
+ ); +} + +export default EventDashboardFocusedPostMortemOutcomes; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss new file mode 100644 index 00000000..33b2f299 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboardFocusedPostMortemOutcomes.scss @@ -0,0 +1,381 @@ +.event-dashboard-focused-pm-outcomes { + display: flex; + flex-direction: column; + gap: 36px; + padding-bottom: 8px; +} + +.event-dashboard-focused-pm-outcomes__lead { + padding: 8px 4px 0; +} + +.event-dashboard-focused-pm-outcomes__eyebrow { + margin: 0 0 8px; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #2f7d46; + font-weight: 700; +} + +.event-dashboard-focused-pm-outcomes__headline { + margin: 0; + font-size: clamp(1.65rem, 2.6vw, 2.45rem); + line-height: 1.04; + letter-spacing: -0.02em; + color: var(--text); + font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; +} + +.event-dashboard-focused-pm-outcomes__lede { + margin: 12px 0 0; + max-width: 74ch; + font-size: 1rem; + line-height: 1.52; + color: var(--light-text); +} + +.event-dashboard-focused-pm-outcomes__story-section { + opacity: 0; + transform: translateY(24px); + transition: + opacity 620ms cubic-bezier(0.2, 0.8, 0.2, 1), + transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.event-dashboard-focused-pm-outcomes__story-section.is-visible { + opacity: 1; + transform: translateY(0); +} + +.event-dashboard-focused-pm-outcomes__story-heading { + margin-bottom: 14px; + + p { + margin: 0 0 6px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #2f7d46; + font-weight: 700; + } + + h3 { + margin: 0; + font-size: clamp(1.1rem, 1.8vw, 1.45rem); + letter-spacing: -0.01em; + color: var(--text); + } +} + +.event-dashboard-focused-pm-outcomes__section { + border: 0; + border-radius: 22px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.52)), + radial-gradient(circle at top right, rgba(47, 125, 70, 0.1), transparent 55%); + padding: 20px; + box-shadow: + 0 22px 40px rgba(17, 17, 17, 0.08), + inset 0 0 0 1px rgba(255, 255, 255, 0.65); +} + +.event-dashboard-focused-pm-outcomes__section-title { + margin: 0 0 12px; + font-size: 1.04rem; + color: var(--text); +} + +.event-dashboard-focused-pm-outcomes__section h4 { + margin: 0 0 10px; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--light-text); +} + +.event-dashboard-focused-pm-outcomes__section--split { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; +} + +.event-dashboard-focused-pm-outcomes__feature-card { + border-radius: 16px; + background: rgba(255, 255, 255, 0.66); + padding: 16px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + + h3 { + margin: 0 0 12px; + font-size: 0.98rem; + color: var(--text); + } +} + +.event-dashboard-focused-pm-outcomes__feature-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + + div { + border-radius: 14px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + } + + span { + font-size: 0.72rem; + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + font-size: 1.2rem; + color: var(--text); + line-height: 1.1; + } +} + +.event-dashboard-focused-pm-outcomes__metrics-column { + display: grid; + gap: 10px; +} + +.event-dashboard-focused-pm-outcomes__metric-pill { + border-radius: 14px; + background: rgba(255, 255, 255, 0.68); + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); + + span { + font-size: 0.74rem; + color: var(--light-text); + text-transform: uppercase; + letter-spacing: 0.08em; + } + + strong { + font-size: 1.28rem; + color: var(--text); + line-height: 1.05; + } +} + +.event-dashboard-focused-pm-outcomes__metric-pill--neutral { + background: rgba(247, 251, 247, 0.86); +} + +.event-dashboard-focused-pm-outcomes__drivers-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.event-dashboard-focused-pm-outcomes__driver-card { + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); + padding: 12px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); +} + +.event-dashboard-focused-pm-outcomes__chart-shell { + border-radius: 12px; + background: rgba(255, 255, 255, 0.84); + padding: 8px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + +.event-dashboard-focused-pm-outcomes__funnel-shell { + border-radius: 12px; + background: rgba(255, 255, 255, 0.84); + padding: 10px; + min-height: 230px; + height: 230px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + +.event-dashboard-focused-pm-outcomes__traffic-lead { + margin: 0 0 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + padding: 12px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); + + p { + margin: 0 0 4px; + color: var(--text); + font-weight: 700; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + } +} + +.event-dashboard-focused-pm-outcomes__traffic-table { + display: grid; + gap: 8px; +} + +.event-dashboard-focused-pm-outcomes__traffic-row { + display: grid; + grid-template-columns: 1.4fr 1fr 1fr auto; + gap: 8px; + align-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.66); + padding: 10px 12px; + color: var(--light-text); + font-size: 0.85rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.68); + + strong { + color: var(--text); + font-size: 0.88rem; + } +} + +.event-dashboard-focused-pm-outcomes__insights { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + article { + border-radius: 14px; + background: rgba(255, 255, 255, 0.7); + padding: 13px; + display: flex; + align-items: flex-start; + gap: 10px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7); + + iconify-icon { + margin-top: 2px; + font-size: 1.05rem; + color: #2f7d46; + } + } + + p { + margin: 0; + color: var(--text); + font-weight: 600; + font-size: 0.92rem; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + } +} + +.event-dashboard-focused-pm-outcomes__story-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.event-dashboard-focused-pm-outcomes__story-card { + border-radius: 18px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)); + padding: 16px; + box-shadow: + 0 12px 28px rgba(17, 17, 17, 0.06), + inset 0 0 0 1px rgba(255, 255, 255, 0.75); +} + +.event-dashboard-focused-pm-outcomes__adoption-nudge { + margin-top: 12px; + border-radius: 14px; + background: rgba(244, 250, 245, 0.84); + padding: 12px; + display: flex; + align-items: flex-start; + gap: 10px; + box-shadow: inset 0 0 0 1px rgba(47, 125, 70, 0.16); + + iconify-icon { + margin-top: 2px; + font-size: 1.1rem; + color: #2f7d46; + } + + p { + margin: 0 0 4px; + color: var(--text); + font-size: 0.9rem; + font-weight: 700; + } + + span { + color: var(--light-text); + font-size: 0.84rem; + line-height: 1.45; + } +} + +.event-dashboard-focused-pm-outcomes__empty { + margin: 0; + color: var(--light-text); +} + +@media (max-width: 1024px) { + .event-dashboard-focused-pm-outcomes__section--split { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__story-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__drivers-grid { + grid-template-columns: 1fr; + } + + .event-dashboard-focused-pm-outcomes__feature-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 768px) { + .event-dashboard-focused-pm-outcomes__headline { + font-size: 1.65rem; + } + + .event-dashboard-focused-pm-outcomes__section { + padding: 14px; + } + + .event-dashboard-focused-pm-outcomes__feature-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .event-dashboard-focused-pm-outcomes__traffic-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .event-dashboard-focused-pm-outcomes__funnel-shell { + min-height: 190px; + height: 190px; + } +} + +@media (prefers-reduced-motion: reduce) { + .event-dashboard-focused-pm-outcomes__story-section { + opacity: 1; + transform: none; + transition: none; + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx new file mode 100644 index 00000000..c9bc62d0 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.jsx @@ -0,0 +1,356 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useFetch } from '../../../../../hooks/useFetch'; +import apiRequest from '../../../../../utils/postRequest'; +import TaskDetailPanel from '../../../../../components/TaskWorkspace/TaskDetailPanel'; +import TaskDetailSheet from '../../../../../components/TaskWorkspace/TaskDetailSheet'; +import TaskDetailFull from '../../../../../components/TaskWorkspace/TaskDetailFull'; +import { buildTaskDraft } from '../../../../../components/TaskWorkspace/taskWorkspaceUtils'; +import { DEFAULT_TASK_BOARD_STATUSES } from '../../../../../constants/taskBoardDefaults'; +import './EventPlanningOverviewSnapshot.scss'; + +function formatMonthDay(value) { + if (!value) return 'TBD'; + return new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); +} + +function formatDueDay(value) { + if (!value) return 'TBD'; + return new Date(value).toLocaleDateString('en-US', { weekday: 'short' }); +} + +function startOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function isSameOrAfterDay(a, b) { + return a.getTime() >= b.getTime(); +} + +function isSameOrBeforeDay(a, b) { + return a.getTime() <= b.getTime(); +} + +function resolveEventCreatedAt(event) { + return ( + event?.createdAt + || event?.created_at + || event?.createdOn + || event?.created_on + || event?.timestamps?.createdAt + || null + ); +} + +function toDisplayTitle(task) { + return task?.title || task?.name || 'Untitled task'; +} + +function toTaskStatus(task) { + return task?.effectiveStatus || task?.status || ''; +} + +function isDoneStatus(task) { + return /(done|completed|cancelled|canceled)/i.test(toTaskStatus(task)); +} + +function getTaskOwnerId(task) { + const ownerRaw = task?.ownerUserId || task?.assigneeUserId || task?.assignedToUserId || null; + if (!ownerRaw) return ''; + if (typeof ownerRaw === 'object') { + return String(ownerRaw?._id || ownerRaw?.id || ''); + } + return String(ownerRaw); +} + +function EventPlanningOverviewSnapshot({ event, orgId, userId, onOpenTasks }) { + const [detailMode, setDetailMode] = useState('closed'); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [taskDraft, setTaskDraft] = useState(() => buildTaskDraft({ title: '', status: 'todo' }, () => 'todo')); + const [detailSaving, setDetailSaving] = useState(false); + const [detailError, setDetailError] = useState(''); + const tasksUrl = event?._id && orgId + ? `/org-event-management/${orgId}/events/${event._id}/tasks?status=all&priority=all` + : null; + + const { data: tasksData, refetch: refetchTasks } = useFetch(tasksUrl); + const tasks = tasksData?.data?.tasks || []; + const membersEndpoint = orgId ? `/org-roles/${orgId}/members` : null; + const { data: membersData } = useFetch(membersEndpoint); + const members = membersData?.members || []; + const boardStatusesEndpoint = orgId ? `/org-event-management/${orgId}/task-board-statuses` : null; + const { data: boardStatusesData } = useFetch(boardStatusesEndpoint); + const boardStatuses = useMemo(() => { + const list = boardStatusesData?.data?.statuses; + return Array.isArray(list) && list.length ? list : DEFAULT_TASK_BOARD_STATUSES; + }, [boardStatusesData]); + + const now = new Date(); + const eventStart = event?.start_time ? new Date(event.start_time) : null; + const eventEndRaw = event?.end_time ? new Date(event.end_time) : null; + const eventEnd = eventEndRaw || eventStart; + const createdAt = resolveEventCreatedAt(event); + const timelineStart = createdAt ? startOfDay(createdAt) : null; + const timelineEnd = eventEnd ? startOfDay(eventEnd) : null; + const liveStartDay = eventStart ? startOfDay(eventStart) : null; + const liveEndDay = eventEnd ? startOfDay(eventEnd) : liveStartDay; + const todayStart = startOfDay(now); + const currentUserId = userId ? String(userId) : ''; + const countdownMs = eventStart ? Math.max(eventStart.getTime() - now.getTime(), 0) : 0; + const days = Math.floor(countdownMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((countdownMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((countdownMs % (1000 * 60 * 60)) / (1000 * 60)); + const totalHours = Math.floor(countdownMs / (1000 * 60 * 60)); + + const countdownDisplay = useMemo(() => { + if (!eventStart) { + return { + primaryValue: '--', + primaryLabel: 'days', + secondaryText: 'Time TBD' + }; + } + if (countdownMs >= 1000 * 60 * 60 * 24) { + return { + primaryValue: days, + primaryLabel: 'days', + secondaryText: `${hours} hrs - ${minutes} min` + }; + } + return { + primaryValue: totalHours, + primaryLabel: 'hrs', + secondaryText: `${minutes} min` + }; + }, [countdownMs, days, eventStart, hours, minutes, totalHours]); + + const upcomingTasks = useMemo(() => { + const activeTasks = tasks.filter((task) => { + if (isDoneStatus(task)) return false; + if (!currentUserId) return false; + return getTaskOwnerId(task) === currentUserId; + }); + activeTasks.sort((a, b) => { + const aDue = a?.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY; + const bDue = b?.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY; + if (aDue !== bDue) return aDue - bDue; + const aCreated = a?.createdAt ? new Date(a.createdAt).getTime() : 0; + const bCreated = b?.createdAt ? new Date(b.createdAt).getTime() : 0; + return aCreated - bCreated; + }); + return activeTasks.slice(0, 4); + }, [tasks, currentUserId]); + + const timelineDots = useMemo(() => { + if (!timelineStart || !timelineEnd || timelineEnd < timelineStart) return []; + + const dots = []; + const current = new Date(timelineStart); + while (current <= timelineEnd) { + const dotDay = new Date(current); + dotDay.setHours(0, 0, 0, 0); + dots.push({ + key: dotDay.toISOString(), + isPast: dotDay < todayStart, + isToday: dotDay.getTime() === todayStart.getTime(), + isLive: + liveStartDay + && liveEndDay + && isSameOrAfterDay(dotDay, liveStartDay) + && isSameOrBeforeDay(dotDay, liveEndDay) + }); + current.setDate(current.getDate() + 1); + } + return dots; + }, [timelineStart, timelineEnd, todayStart, liveStartDay, liveEndDay]); + + const detailTaskUrl = + event?._id && orgId && selectedTaskId && detailMode !== 'closed' + ? `/org-event-management/${orgId}/events/${event._id}/tasks/${selectedTaskId}` + : null; + const { data: detailTaskData, refetch: refetchDetailTask } = useFetch(detailTaskUrl); + + const taskForDetailPanel = useMemo(() => { + if (!selectedTaskId) return null; + const id = String(selectedTaskId); + const fromFetch = detailTaskData?.data?.task; + if (fromFetch && String(fromFetch._id) === id) return fromFetch; + return tasks.find((task) => String(task?._id) === id) || null; + }, [detailTaskData, selectedTaskId, tasks]); + + const openTaskDetail = useCallback( + (task) => { + if (!task?._id) return; + setSelectedTaskId(String(task._id)); + setDetailMode('full'); + setDetailError(''); + setTaskDraft(buildTaskDraft(task, toTaskStatus)); + }, + [] + ); + + const closeTaskDetail = useCallback(() => { + setDetailMode('closed'); + setSelectedTaskId(null); + setDetailError(''); + }, []); + + const handleDetailSave = useCallback(async () => { + if (!orgId || !event?._id || !selectedTaskId || !taskDraft.title.trim()) return; + setDetailSaving(true); + setDetailError(''); + try { + const payload = { + title: taskDraft.title.trim(), + description: taskDraft.description.trim(), + status: taskDraft.status, + priority: taskDraft.priority, + isCritical: Boolean(taskDraft.isCritical), + ownerUserId: taskDraft.ownerUserId || null, + dueAt: taskDraft.dueAt ? new Date(taskDraft.dueAt).toISOString() : null + }; + const response = await apiRequest( + `/org-event-management/${orgId}/events/${event._id}/tasks/${selectedTaskId}`, + payload, + { method: 'PUT' } + ); + if (!response?.success) { + throw new Error(response?.message || response?.error || 'Failed to save task'); + } + await refetchTasks?.({ silent: true }); + await refetchDetailTask?.({ silent: true }); + } catch (saveErr) { + setDetailError(saveErr.message || 'Unable to save.'); + } finally { + setDetailSaving(false); + } + }, [event?._id, orgId, refetchDetailTask, refetchTasks, selectedTaskId, taskDraft]); + + return ( +
+
+

Event starts in

+
+ {countdownDisplay.primaryValue} +
+ {countdownDisplay.primaryLabel} + {countdownDisplay.secondaryText} +
+
+
+
+ {timelineDots.map((dot) => ( + + ))} +
+
+ {formatMonthDay(createdAt)} - created + today + + {formatMonthDay(eventStart)} - live + +
+
+
+ +
+
+

This week - {upcomingTasks.length} item{upcomingTasks.length === 1 ? '' : 's'} need you

+ {onOpenTasks ? ( + + ) : null} +
+
    + {upcomingTasks.length > 0 ? ( + upcomingTasks.map((task) => ( +
  • + +
  • + )) + ) : ( +
  • +

    No active tasks assigned to you yet.

    +
  • + )} +
+
+ + + {taskForDetailPanel && ( + setDetailMode('full')} + onSave={handleDetailSave} + saving={detailSaving} + saveError={detailError} + /> + )} + + + + {taskForDetailPanel && ( + setDetailMode('sheet')} + onSave={handleDetailSave} + saving={detailSaving} + saveError={detailError} + /> + )} + +
+ ); +} + +export default EventPlanningOverviewSnapshot; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss new file mode 100644 index 00000000..050f3878 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventPlanningOverviewSnapshot.scss @@ -0,0 +1,230 @@ +.event-planning-overview-snapshot { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + border: 1px solid var(--lighterborder); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + overflow: hidden; + margin: 0 0 14px; +} + +.event-planning-overview-snapshot__countdown{ + padding: 18px 20px; +} +.event-planning-overview-snapshot__tasks { + padding: 14px 20px; +} + +.event-planning-overview-snapshot__tasks { + border-left: 1px solid var(--lighterborder); +} + +.event-planning-overview-snapshot__eyebrow { + margin: 0 0 10px; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--light-text); + font-weight: 700; +} + +.event-planning-overview-snapshot__clock { + display: flex; + align-items: flex-end; + gap: 10px; + margin-bottom: 14px; + + strong { + font-size: clamp(3rem, 5vw, 4.5rem); + line-height: 0.85; + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + font-weight: 600; + color: var(--text); + } + + span { + display: block; + font-size: 2rem; + line-height: 1; + color: var(--text); + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + } + + small { + display: block; + margin-top: 6px; + color: var(--light-text); + font-size: 0.95rem; + } +} + +.event-planning-overview-snapshot__timeline-dots { + display: grid; + align-items: center; + margin: 0 0 8px; + min-height: 10px; + width: 100%; + gap: 2px; +} + +.event-planning-overview-snapshot__day-segment { + width: 100%; + height: 4px; + border-radius: 999px; + background: rgba(77, 170, 87, 0.16); + + &.is-past { + background: rgba(77, 170, 87, 0.46); + } + + &.is-today { + background: #4DAA57; + height: 6px; + } + + &.is-live { + background: rgba(45, 126, 214, 0.55); + } + + &.is-live.is-past { + background: rgba(45, 126, 214, 0.35); + } + + &.is-live.is-today { + background: #2d7ed6; + } +} + +.event-planning-overview-snapshot__timeline-labels { + display: flex; + justify-content: space-between; + gap: 10px; + + span { + color: var(--light-text); + font-size: 0.78rem; + text-transform: lowercase; + } +} + +.event-planning-overview-snapshot__timeline-label-live { + color: #2d7ed6; + font-weight: 600; +} + +.event-planning-overview-snapshot__tasks-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + align-items: center; + + p { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--light-text); + font-weight: 700; + } + + button { + border: 1px solid var(--lighterborder); + background: var(--background); + color: var(--text); + border-radius: 8px; + font-size: 0.76rem; + font-weight: 600; + padding: 4px 8px; + cursor: pointer; + } +} + +.event-planning-overview-snapshot__tasks ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; +} + +.event-planning-overview-snapshot__tasks li { + border-bottom: 1px solid var(--lighterborder); + padding: 4px 0; + + &:last-child { + border-bottom: 0; + padding-bottom: 0; + } + + p { + margin: 0; + color: var(--text); + font-weight: 600; + font-size: 1rem; + } + + small { + color: #9a7a3a; + font-size: 0.82rem; + font-weight: 600; + } +} + +.event-planning-overview-snapshot__task-button { + width: 100%; + display: grid; + grid-template-columns: 24px 1fr auto; + align-items: center; + gap: 10px; + border: 0; + background: transparent; + padding: 8px 4px; + margin: 0; + text-align: left; + cursor: pointer; + border-radius: 8px; + + &:hover { + background:var(--lightest) + } +} + +.event-planning-overview-snapshot__task-chip { + width: 22px; + height: 22px; + border-radius: 999px; + // background: rgba(154, 122, 58, 0.1); + // color: #9a7a3a; + background: var(--background); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 700; + overflow:hidden; + + img{ + width:100%; + height:100%; + } +} + +.event-planning-overview-snapshot__empty { + grid-template-columns: 1fr !important; + + p { + color: var(--light-text); + font-weight: 500; + } +} + +@media (max-width: 960px) { + .event-planning-overview-snapshot { + grid-template-columns: 1fr; + } + + .event-planning-overview-snapshot__tasks { + border-left: 0; + border-top: 1px solid var(--lighterborder); + } +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js b/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js index f2011a9d..df0109c1 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventPostMortem/usePostMortemInsights.js @@ -11,6 +11,7 @@ export const INSIGHT_CATEGORIES = { funnelBottleneck: 'funnelBottleneck', trafficInvestment: 'trafficInvestment', strategic: 'strategic', + measurement: 'measurement', }; /** @@ -28,6 +29,7 @@ export function usePostMortemInsights({ qrReferrerSources, formOpens, hasForm, + hasCheckInTracking = true, formatNumber, expectedAttendance, }) { @@ -43,6 +45,7 @@ export function usePostMortemInsights({ [INSIGHT_CATEGORIES.funnelBottleneck]: null, [INSIGHT_CATEGORIES.trafficInvestment]: null, [INSIGHT_CATEGORIES.strategic]: [], + [INSIGHT_CATEGORIES.measurement]: null, }; if (expectedAttendance > 0 && registrations > 0) { @@ -64,7 +67,7 @@ export function usePostMortemInsights({ byCategory[INSIGHT_CATEGORIES.expectedVsActual] = item; } - if (registrations > 0 && checkIns > 0) { + if (hasCheckInTracking && registrations > 0 && checkIns > 0) { const rate = ((checkIns / registrations) * 100).toFixed(0); const item = { icon: 'mdi:check-circle', @@ -75,6 +78,16 @@ export function usePostMortemInsights({ byCategory[INSIGHT_CATEGORIES.checkIn] = item; } + if (!hasCheckInTracking && registrations > 0) { + const measurementItem = { + icon: 'mdi:clipboard-check-outline', + text: 'Attendance was not captured for this event', + sub: 'Enable check-ins next time to measure true attendance and improve reminder strategy.', + }; + all.push(measurementItem); + byCategory[INSIGHT_CATEGORIES.measurement] = measurementItem; + } + if (uniqueViewers > 0 && registrations > 0) { const rate = ((registrations / uniqueViewers) * 100).toFixed(0); const item = { @@ -182,12 +195,14 @@ export function usePostMortemInsights({ if (uniqueViewers > 0 && (hasForm ? formOpens > 0 : true)) { const viewToForm = hasForm && formOpens > 0 ? (formOpens / uniqueViewers) * 100 : 0; const formToReg = hasForm && formOpens > 0 && registrations > 0 ? (registrations / formOpens) * 100 : 0; - const regToCheckIn = registrations > 0 ? (checkIns / registrations) * 100 : 0; + const regToCheckIn = hasCheckInTracking && registrations > 0 ? (checkIns / registrations) * 100 : 0; const drops = []; if (hasForm && formOpens > 0) drops.push({ stage: 'viewer→form', rate: viewToForm, label: 'Viewers opening form' }); if (hasForm && formOpens > 0 && registrations > 0) drops.push({ stage: 'form→reg', rate: formToReg, label: 'Form opens → registrations' }); - if (registrations > 0) drops.push({ stage: 'reg→checkin', rate: regToCheckIn, label: 'Registrations → check-ins' }); + if (hasCheckInTracking && registrations > 0) { + drops.push({ stage: 'reg→checkin', rate: regToCheckIn, label: 'Registrations → check-ins' }); + } const bottleneck = drops.length > 0 ? drops.reduce((min, d) => (d.rate < min.rate ? d : min)) : null; if (bottleneck && bottleneck.rate < 70) { @@ -364,5 +379,5 @@ export function usePostMortemInsights({ } return { all, byCategory }; - }, [registrations, checkIns, uniqueViewers, rsvpGrowth, referrerSources, referrerRegistrations, qrReferrerSources, formOpens, hasForm, formatNumber, expectedAttendance]); + }, [registrations, checkIns, uniqueViewers, rsvpGrowth, referrerSources, referrerRegistrations, qrReferrerSources, formOpens, hasForm, hasCheckInTracking, formatNumber, expectedAttendance]); } From ddb10cc349d2d0ec206fdd95ac4de2f0390b2554 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 25 May 2026 00:01:50 -0400 Subject: [PATCH 03/39] MER-190: redesign registration pace visualization for planning snapshot Restyle RSVP growth into an editorial registration pace card with smoother line behavior, live goal guidance, and extracted chart-specific styling to keep dashboard styles modular. --- .../EventDashboard/EventDashboard.scss | 307 +-------------- .../EventDashboard/EventOverview.jsx | 7 +- .../EventDashboard/RSVPGrowthChart.jsx | 157 +++++++- .../EventDashboard/RSVPGrowthChart.scss | 366 ++++++++++++++++++ 4 files changed, 527 insertions(+), 310 deletions(-) create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss index 08ff8259..7d0bb8f0 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventDashboard.scss @@ -2,7 +2,6 @@ width: 100%; max-width: 100%; min-height: 100%; - background: var(--background); display: flex; flex-direction: column; @@ -583,7 +582,6 @@ } .event-overview { - padding: 1.5rem 0; width: 100%; max-width: 100%; overflow-x: hidden; // Prevent horizontal scrolling @@ -681,7 +679,6 @@ background: var(--lightbackground); border: 1px solid var(--lighterborder); border-radius: 18px; - padding: 1rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); } @@ -692,6 +689,8 @@ .overview-chart-box { flex: 1 1 auto; min-width: 0; + background-color: var(--background); + overflow: hidden; } .overview-preview-box-header, @@ -746,11 +745,10 @@ } .overview-chart-box-body { - padding: 0.3rem; .rsvp-growth-chart { padding: 0; - background: transparent; + background: var(--background); border: none; border-radius: 0; box-shadow: none; @@ -876,6 +874,7 @@ gap: 1.5rem; align-items: flex-start; width: 100%; + .rsvp-stats-card { flex: 0 0 auto; @@ -1353,304 +1352,6 @@ cursor: pointer; } -// RSVP Growth Chart Styles -.rsvp-growth-chart { - width: 100%; - max-width: 100%; - padding: 1.5rem; - background: var(--lightbackground); - border: 1px solid var(--lighterborder); - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - box-sizing: border-box; - overflow: hidden; // Prevent content from expanding beyond bounds - - .chart-loading, - .chart-error, - .chart-empty { - display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - color: var(--light-text); - font-size: 0.95rem; - } - - .chart-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - - .chart-stats-inline { - display: flex; - gap: 2rem; - align-items: baseline; - - .chart-stat-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .chart-stat-value { - font-size: 1.5rem; - font-weight: 600; - color: var(--text); - } - - .chart-stat-label { - font-size: 0.85rem; - color: var(--light-text); - font-weight: 500; - display: flex; - align-items: center; - gap: 0.35rem; - } - - .chart-stat-change { - display: inline-flex; - align-items: center; - gap: 0.2rem; - font-size: 0.75rem; - font-weight: 600; - - &.up { - color: #22c55e; - } - - &.down { - color: var(--red, #fa756d); - } - } - } - } - - .chart-header-right { - display: flex; - align-items: center; - gap: 1rem; - } - - .chart-title-section { - display: flex; - align-items: center; - gap: 1rem; - - h3 { - display: flex; - align-items: center; - gap: 0.75rem; - font-size: 1.25rem; - font-weight: 600; - color: var(--text); - margin: 0; - - iconify-icon { - color: #4DAA57; - font-size: 1.5rem; - } - } - - .frozen-badge { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.8rem; - background: #fff3cd; - color: #856404; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 500; - - iconify-icon { - font-size: 1rem; - color: #856404; - } - } - } - - .chart-controls { - display: flex; - gap: 0.5rem; - background: var(--lighterborder); - border-radius: 8px; - padding: 0.25rem; - - .toggle-btn { - padding: 0.5rem 1rem; - background: transparent; - border: none; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 500; - color: var(--light-text); - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background: var(--lightbackground); - } - - &.active { - background: var(--background); - color: var(--text); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } - } - } - } - - .chart-stats { - display: flex; - gap: 2rem; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--lighterborder); - - .stat-item { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .stat-label { - font-size: 0.85rem; - color: var(--light-text); - font-weight: 500; - } - - .stat-value { - font-size: 1.5rem; - font-weight: 600; - color: var(--text); - } - } - } - - .chart-container { - height: 350px; - width: 100%; - max-width: 100%; - position: relative; - padding: 0.5rem 0 1rem 0; - margin-bottom: 0.5rem; - box-sizing: border-box; - overflow: hidden; // Prevent chart from expanding beyond container - } - - &.rsvp-growth-chart-visx .chart-container-visx { - height: 320px; - width: 100%; - min-height: 280px; - - > div { - width: 100% !important; - } - } - - &.rsvp-growth-chart-visx .chart-controls .toggle-btn { - font-size: 0.8rem; - padding: 0.4rem 0.75rem; - } - - .chart-legend { - display: flex; - gap: 1.25rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--lighterborder); - font-size: 0.8rem; - color: var(--light-text); - } - - .chart-legend-item { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .chart-legend-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - } - - .chart-legend-dot-actual { - background: #22c55e; - } - - .chart-legend-dot-required { - background: #94a3b8; - } -} - -// Reset visx tooltip wrapper so only our custom content provides styling -.rsvp-chart-tooltip-wrapper.visx-tooltip { - padding: 0; - background: transparent; - border: none; - box-shadow: none; - z-index: 9999 !important; - pointer-events: none; -} - -/* Visx renders vertical crosshair in a portal (sibling to chart), not inside .chart-container-visx */ -.visx-crosshair.visx-crosshair-vertical { - z-index: 10001 !important; - pointer-events: none; -} - -.visx-crosshair.visx-crosshair-vertical line { - stroke: #94a3b8 !important; - stroke-width: 1px !important; - stroke-opacity: 1 !important; - stroke-dasharray: 1 4 !important; - stroke-linecap: round !important; -} - -.visx-tooltip-glyph { - z-index: 10003 !important; - pointer-events: none; -} - -.rsvp-chart-tooltip { - padding: 0.5rem 0.75rem; - background: var(--background); - border: 1px solid var(--lighterborder); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); - font-size: 0.8rem; - min-width: 120px; -} - -.rsvp-chart-tooltip-date { - font-weight: 600; - color: var(--text); - margin-bottom: 0.35rem; -} - -.rsvp-chart-tooltip-row { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--light-text); -} - -.rsvp-chart-tooltip-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.rsvp-chart-tooltip-dot-actual { - background: #22c55e; -} - -.rsvp-chart-tooltip-dot-required { - background: #94a3b8; -} - .event-analytics-detail { padding: 1.5rem 0; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx index a2efab23..fabf553e 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventOverview.jsx @@ -149,12 +149,6 @@ function EventOverview({ )} {event && (
-
-

- - Registration Chart -

-
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx index 6e90c345..888c81c3 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.jsx @@ -10,9 +10,10 @@ import { GlyphSeries, buildChartTheme, Tooltip, + DataContext, } from '@visx/xychart'; import { curveMonotoneX } from '@visx/curve'; -import './EventDashboard.scss'; +import './RSVPGrowthChart.scss'; const accessors = { xAccessor: (d) => d.x, @@ -107,6 +108,44 @@ function getSparseTickValues(values, maxTicks = MAX_X_TICKS) { return result; } +function SnapshotGoalGuide({ goalValue, label }) { + const { xScale, yScale } = React.useContext(DataContext) || {}; + if (!xScale || !yScale || !Number.isFinite(goalValue) || goalValue <= 0) return null; + const xRange = xScale.range?.() || []; + const yRange = yScale.range?.() || []; + if (xRange.length < 2 || yRange.length < 2) return null; + const xLeft = Math.min(...xRange); + const xRight = Math.max(...xRange); + const y = Number(yScale(goalValue)); + const yTop = Math.min(...yRange); + const yBottom = Math.max(...yRange); + if (!Number.isFinite(y) || y < yTop || y > yBottom) return null; + return ( + + + + {label} + + + ); +} + function RSVPGrowthChart({ eventId, orgId, @@ -114,6 +153,7 @@ function RSVPGrowthChart({ registrationCount, rsvpGrowth: rsvpGrowthProp, report = false, + variant = 'default', /** Full path to RSVP growth API (e.g. tenant-operator route); timezone query appended when absent */ rsvpGrowthUrlOverride, }) { @@ -265,6 +305,7 @@ function RSVPGrowthChart({ ? 100 : 0; const hasChartData = dailyData && dailyData.length > 0; + const isSnapshotVariant = variant === 'snapshot'; if (!hasChartData) { return ( @@ -322,6 +363,120 @@ function RSVPGrowthChart({ const cap = displayExpected > 0 ? displayExpected : Math.max(actualMax, requiredMax); return Math.ceil(Math.max(actualMax, requiredMax, cap) * 1.1) || 10; })(); + const registrationPct = displayExpected > 0 ? Math.round((currentRSVPs / displayExpected) * 100) : 0; + const last7DaysGain = dailyData.slice(-7).reduce((sum, day) => sum + (day?.dailyRSVPs || 0), 0); + const isOnTrack = displayExpected > 0 ? currentRSVPs >= displayExpected * 0.6 : currentRSVPs > 0; + const snapshotSeries = (() => { + const base = actualData.length > 0 ? actualData : [{ x: todayStr, y: 0 }]; + const withIndex = base.map((point, idx) => ({ ...point, ix: idx })); + if (withIndex.length === 1) { + return [ + withIndex[0], + { + ...withIndex[0], + ix: 1 + } + ]; + } + return withIndex; + })(); + + if (isSnapshotVariant) { + const chartRangeDays = dailyData.length; + return ( +
+
+
+

Registration pace

+ {chartRangeDays} days +
+ +
+ {currentRSVPs} +

+ of {displayExpected || 0} expected · {registrationPct}% +

+
+ +

+ {last7DaysGain >= 0 ? `+${last7DaysGain}` : last7DaysGain} this week ·{' '} + {isOnTrack ? 'on track to hit goal' : 'behind pace'} +

+
+ +
+ + + + + + + + d.ix} + yAccessor={accessors.yAccessor} + curve={curveMonotoneX} + fillOpacity={0.9} + fill="url(#actual-gradient-snapshot)" + /> + d.ix} + yAccessor={accessors.yAccessor} + curve={curveMonotoneX} + stroke="#2f8f4a" + strokeWidth={3} + strokeLinecap="round" + strokeLinejoin="round" + /> + + { + if (!tooltipData?.datumByKey) return null; + const actualDatum = + tooltipData.datumByKey?.['actual-snapshot-line']?.datum + || tooltipData.datumByKey?.['actual-snapshot-area']?.datum + || tooltipData?.nearestDatum?.datum; + if (!actualDatum) return null; + const dateLabel = actualDatum?.x ? formatSemanticDate(actualDatum.x) : 'Current'; + return ( +
+
{dateLabel}
+
+ + Registrations: {Math.round(actualDatum?.y || 0)} +
+
+ + Goal: {displayExpected || 0} +
+
+ ); + }} + /> +
+
+
+ ); + } return (
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss new file mode 100644 index 00000000..ebad8e29 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/RSVPGrowthChart.scss @@ -0,0 +1,366 @@ +.rsvp-growth-chart { + width: 100%; + max-width: 100%; + padding: 1.5rem; + background: var(--lightbackground); + border: 1px solid var(--lighterborder); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + overflow: hidden; + + .chart-loading, + .chart-error, + .chart-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--light-text); + font-size: 0.95rem; + } + + .chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + .chart-stats-inline { + display: flex; + gap: 2rem; + align-items: baseline; + + .chart-stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .chart-stat-value { + font-size: 1.5rem; + font-weight: 600; + color: var(--text); + } + + .chart-stat-label { + font-size: 0.85rem; + color: var(--light-text); + font-weight: 500; + display: flex; + align-items: center; + gap: 0.35rem; + } + + .chart-stat-change { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.75rem; + font-weight: 600; + + &.up { + color: #22c55e; + } + + &.down { + color: var(--red, #fa756d); + } + } + } + } + + .chart-header-right { + display: flex; + align-items: center; + gap: 1rem; + } + + .chart-title-section { + display: flex; + align-items: center; + gap: 1rem; + + h3 { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); + margin: 0; + + iconify-icon { + color: #4DAA57; + font-size: 1.5rem; + } + } + + .frozen-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.8rem; + background: #fff3cd; + color: #856404; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + + iconify-icon { + font-size: 1rem; + color: #856404; + } + } + } + + .chart-controls { + display: flex; + gap: 0.5rem; + background: var(--lighterborder); + border-radius: 8px; + padding: 0.25rem; + + .toggle-btn { + padding: 0.5rem 1rem; + background: transparent; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + color: var(--light-text); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: var(--lightbackground); + } + + &.active { + background: var(--background); + color: var(--text); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + } + } + } + + .chart-stats { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--lighterborder); + + .stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .stat-label { + font-size: 0.85rem; + color: var(--light-text); + font-weight: 500; + } + + .stat-value { + font-size: 1.5rem; + font-weight: 600; + color: var(--text); + } + } + } + + .chart-container { + height: 350px; + width: 100%; + max-width: 100%; + position: relative; + padding: 0.5rem 0 1rem 0; + margin-bottom: 0.5rem; + box-sizing: border-box; + overflow: hidden; + } + + &.rsvp-growth-chart-visx .chart-container-visx { + height: 320px; + width: 100%; + min-height: 280px; + + > div { + width: 100% !important; + } + } + + &.rsvp-growth-chart-visx .chart-controls .toggle-btn { + font-size: 0.8rem; + padding: 0.4rem 0.75rem; + } + + .chart-legend { + display: flex; + gap: 1.25rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--lighterborder); + font-size: 0.8rem; + color: var(--light-text); + } + + .chart-legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .chart-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .chart-legend-dot-actual { + background: #22c55e; + } + + .chart-legend-dot-required { + background: #94a3b8; + } +} + +.rsvp-growth-chart.rsvp-growth-chart--snapshot { + padding: 0.15rem 0.1rem 0.1rem; + border: 0; + box-shadow: none; + background: transparent; + + .rsvp-growth-chart__snapshot-header { + padding: 18px 20px; + } + + .rsvp-growth-chart__snapshot-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 0.6rem; + + h4 { + margin: 0; + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #1f2b24; + } + + span { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.68rem; + font-weight: 700; + color: rgba(33, 46, 38, 0.42); + } + } + + .rsvp-growth-chart__snapshot-kpi { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.1rem; + + strong { + font-size: clamp(2.6rem, 4.1vw, 3.7rem); + line-height: 0.88; + font-weight: 600; + color: #1a2a22; + // font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, 'Times New Roman', serif; + } + + p { + margin: 0; + font-size: 0.95rem; + line-height: 1.25; + color: rgba(23, 31, 27, 0.6); + font-weight: 600; + } + } + + .rsvp-growth-chart__snapshot-delta { + margin: 0 0 0.45rem; + font-size: 0.95rem; + color: #3f9f5b; + font-weight: 600; + } + + .chart-container.chart-container-visx { + height: 152px; + min-height: 152px; + width: calc(100% + 0.2rem); + margin: 0 -0.1rem; + padding: 0; + } +} + +.rsvp-chart-tooltip-wrapper.visx-tooltip { + padding: 0; + background: transparent; + border: none; + box-shadow: none; + z-index: 9999 !important; + pointer-events: none; +} + +.visx-crosshair.visx-crosshair-vertical { + z-index: 10001 !important; + pointer-events: none; +} + +.visx-crosshair.visx-crosshair-vertical line { + stroke: #94a3b8 !important; + stroke-width: 1px !important; + stroke-opacity: 1 !important; + stroke-dasharray: 1 4 !important; + stroke-linecap: round !important; +} + +.visx-tooltip-glyph { + z-index: 10003 !important; + pointer-events: none; +} + +.rsvp-chart-tooltip { + padding: 0.5rem 0.75rem; + background: var(--background); + border: 1px solid var(--lighterborder); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font-size: 0.8rem; + min-width: 120px; +} + +.rsvp-chart-tooltip-date { + font-weight: 600; + color: var(--text); + margin-bottom: 0.35rem; +} + +.rsvp-chart-tooltip-row { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--light-text); +} + +.rsvp-chart-tooltip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.rsvp-chart-tooltip-dot-actual { + background: #22c55e; +} + +.rsvp-chart-tooltip-dot-required { + background: #94a3b8; +} From 35523475ef010f0aa29b5a35093a5aee7944775a Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 25 May 2026 00:01:55 -0400 Subject: [PATCH 04/39] MER-190: polish agenda calendar readability and event task board visuals Improve agenda day-view rendering and helper utilities while refining related task board card styling so live operations scheduling is clearer and easier to scan. --- .../components/TaskBoard/SharedTaskBoard.scss | 2 +- .../cards/EventTasksTaskKanbanCard.scss | 2 +- .../EventAgendaBuilder/AgendaBuilder.scss | 3 +- .../AgendaDailyCalendar.jsx | 373 +++++++----------- .../AgendaDailyCalendar.scss | 175 ++++++-- .../agendaCalendarUtils.js | 204 ++++++++++ .../AgendaItemCalendarEvent.jsx | 5 +- .../AgendaItemCalendarEvent.scss | 9 + 8 files changed, 517 insertions(+), 256 deletions(-) create mode 100644 frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js diff --git a/frontend/src/components/TaskBoard/SharedTaskBoard.scss b/frontend/src/components/TaskBoard/SharedTaskBoard.scss index 98e02bd1..0a3b1e3f 100644 --- a/frontend/src/components/TaskBoard/SharedTaskBoard.scss +++ b/frontend/src/components/TaskBoard/SharedTaskBoard.scss @@ -6,7 +6,7 @@ --task-board-card-gap: 0.42rem; --task-board-transition: 220ms ease; --task-board-column-border: var(--lighterborder); - --task-board-column-bg: var(--lightbackground); + --task-board-column-bg: var(--background); --task-board-muted: var(--light-text); --task-board-token-bg: var(--background); --task-board-drop-border: rgba(77, 170, 87, 0.5); diff --git a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss index b7949b50..0b9e7b5c 100644 --- a/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss +++ b/frontend/src/components/TaskBoard/cards/EventTasksTaskKanbanCard.scss @@ -18,7 +18,7 @@ background: var(--background); padding: 0.52rem 0.58rem 0.48rem; cursor: grab; - box-shadow: 0 1px 2px rgba(15, 18, 22, 0.04); + box-shadow: 0px 1px 1px #35353640, 0px 0px 1px #1E1F214F; transition: border-color 220ms ease, opacity 220ms ease, background-color 220ms ease, box-shadow 220ms ease; &:hover { diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss index c60b64c9..84c8c0fb 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaBuilder.scss @@ -77,7 +77,7 @@ } .agenda-header { - background: var(--background); + // background: var(--background); display: flex; justify-content: space-between; align-items: center; @@ -347,6 +347,7 @@ .agenda-calendar-wrapper { margin-top: 1rem; + overflow-x: auto; .calendar-zoom-controls { display: flex; diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx index ac190347..49071148 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.jsx @@ -1,11 +1,18 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import AgendaItemCalendarEvent from '../AgendaItemCalendarEvent/AgendaItemCalendarEvent'; import '../../../../../../OIEDash/EventsCalendar/Week/WeeklyCalendar/WeeklyCalendar.scss'; +import { + computeDayRange, + computeColumns, + getSegmentLayout, + groupIntoClusters, + MINUTES_PER_DAY, + splitItemIntoDaySegments, + toCalendarEvent +} from './agendaCalendarUtils'; import './AgendaDailyCalendar.scss'; const DEFAULT_MINUTE_HEIGHT = 4; -const MINUTES_PER_DAY = 24 * 60; -const RANGE_PADDING_MINUTES = 30; function AgendaDailyCalendar({ agendaItems = [], @@ -15,265 +22,183 @@ function AgendaDailyCalendar({ minuteHeight = DEFAULT_MINUTE_HEIGHT, onEditItem }) { - const [width, setWidth] = useState(0); - const ref = React.useRef(null); - - const { days, totalHeight, rangeStartMinutes, rangeEndMinutes, minutesInRange } = useMemo(() => { - const eventStart = event?.start_time ? new Date(event.start_time) : new Date(); - const eventEnd = event?.end_time ? new Date(event.end_time) : new Date(eventStart); - eventEnd.setDate(eventEnd.getDate() + 1); - - let first = dayStart ? new Date(dayStart) : eventStart; - let last = dayEnd ? new Date(dayEnd) : eventEnd; - - if (agendaItems.length > 0) { - const firstStart = agendaItems.reduce((earliest, item) => { - const itemStart = item.startTime ? new Date(item.startTime) : null; - if (!itemStart) return earliest; - return !earliest || itemStart < earliest ? itemStart : earliest; - }, null); - const lastEnd = agendaItems.reduce((latest, item) => { - const itemEnd = item.endTime ? new Date(item.endTime) : null; - if (!itemEnd) return latest; - return !latest || itemEnd > latest ? itemEnd : latest; - }, null); - if (firstStart) first = firstStart < first ? firstStart : first; - if (lastEnd) last = lastEnd > last ? lastEnd : last; - } - - const firstDay = new Date(first); - firstDay.setHours(0, 0, 0, 0); - const lastDay = new Date(last); - lastDay.setHours(0, 0, 0, 0); - - const days = []; - for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) { - days.push(new Date(d)); - } - - // Only show event hours, not full 24h - use min start and max end across all items - const allStarts = [first]; - const allEnds = [last]; - agendaItems.forEach((item) => { - if (item.startTime) allStarts.push(new Date(item.startTime)); - if (item.endTime) allEnds.push(new Date(item.endTime)); - }); - let rangeStartMinutes = Math.min(...allStarts.map((d) => d.getHours() * 60 + d.getMinutes())); - let rangeEndMinutes = Math.max(...allEnds.map((d) => d.getHours() * 60 + d.getMinutes())); - - if (rangeEndMinutes <= rangeStartMinutes) { - rangeEndMinutes = rangeStartMinutes + 60; // fallback: 1 hour - } - - // Add padding and round to 30-min boundaries - rangeStartMinutes = Math.floor((rangeStartMinutes - RANGE_PADDING_MINUTES) / 30) * 30; - rangeEndMinutes = Math.ceil((rangeEndMinutes + RANGE_PADDING_MINUTES) / 30) * 30; - rangeStartMinutes = Math.max(0, rangeStartMinutes); - rangeEndMinutes = Math.min(MINUTES_PER_DAY, rangeEndMinutes); - - const minutesInRange = rangeEndMinutes - rangeStartMinutes; - const totalHeight = days.length * minutesInRange * minuteHeight; + const { days, minutesInRange, perDayBounds } = useMemo( + () => computeDayRange(agendaItems, event, dayStart, dayEnd), + [agendaItems, event, dayStart, dayEnd] + ); - return { - days, - totalHeight: Math.max(totalHeight, minutesInRange * minuteHeight), - rangeStartMinutes, - rangeEndMinutes, - minutesInRange - }; - }, [agendaItems, event, dayStart, dayEnd, minuteHeight]); + const columnHeight = minutesInRange * minuteHeight; + const isMultiDay = days.length > 1; - const calendarEvents = useMemo(() => { - return agendaItems + const segmentsByDay = useMemo(() => { + const calendarEvents = agendaItems .filter((item) => item.startTime && item.endTime) - .map((item) => ({ - ...item, - start_time: typeof item.startTime === 'string' ? item.startTime : item.startTime?.toISOString?.() ?? new Date(item.startTime).toISOString(), - end_time: typeof item.endTime === 'string' ? item.endTime : item.endTime?.toISOString?.() ?? new Date(item.endTime).toISOString() - })); - }, [agendaItems]); + .map(toCalendarEvent); + + const byDay = days.map(() => []); + calendarEvents.forEach((item) => { + const segments = splitItemIntoDaySegments(item, days); + segments.forEach((seg) => { + byDay[seg.dayIndex].push(seg); + }); + }); + return byDay; + }, [agendaItems, days]); - useEffect(() => { - if (ref.current) { - setWidth(ref.current.clientWidth); + const renderTimeGrid = () => { + const lines = []; + for (let m = 0; m < MINUTES_PER_DAY; m += 30) { + const isHour = m % 60 === 0; + lines.push( +
+ ); } - }, [ref, agendaItems]); - - const getMinutesFromStart = (date) => { - const base = new Date(days[0]); - base.setHours(0, 0, 0, 0); - const d = new Date(date); - const dayOffset = Math.floor((d - base) / (1000 * 60 * 60 * 24)); - const minutesInDay = d.getHours() * 60 + d.getMinutes(); - const minutesFromRangeStart = minutesInDay - rangeStartMinutes; - return dayOffset * minutesInRange + minutesFromRangeStart; + return lines; }; - const groupIntoClusters = (events) => { - if (events.length === 0) return []; - const sortedEvents = [...events].sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - const clusters = []; - let currentCluster = [sortedEvents[0]]; - let maxEnd = new Date(sortedEvents[0].end_time); - - for (let i = 1; i < sortedEvents.length; i++) { - const ev = sortedEvents[i]; - const evStart = new Date(ev.start_time); - if (evStart < maxEnd) { - currentCluster.push(ev); - const evEnd = new Date(ev.end_time); - maxEnd = evEnd > maxEnd ? evEnd : maxEnd; - } else { - clusters.push(currentCluster); - currentCluster = [ev]; - maxEnd = new Date(ev.end_time); - } + const renderTimeLabels = () => { + const labels = []; + for (let hour = 0; hour < 24; hour++) { + const hourMinutes = hour * 60; + labels.push( +
+ {new Date(0, 0, 0, hour).toLocaleTimeString([], { hour: '2-digit' })} +
+ ); } - clusters.push(currentCluster); - return clusters; + return labels; }; - const computeColumns = (cluster) => { - const sortedCluster = [...cluster].sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); - const columns = []; - const eventsWithColumns = []; - - for (const ev of sortedCluster) { - const evStart = new Date(ev.start_time); - const evEnd = new Date(ev.end_time); - let columnIndex = -1; - - for (let i = 0; i < columns.length; i++) { - if (evStart >= columns[i]) { - columnIndex = i; - break; - } - } + const renderInactiveRegions = (dayIndex) => { + const { activeStartMinutes, activeEndMinutes } = + perDayBounds[dayIndex] ?? { activeStartMinutes: 0, activeEndMinutes: MINUTES_PER_DAY }; - if (columnIndex === -1) { - columnIndex = columns.length; - columns.push(evEnd); - } else { - columns[columnIndex] = evEnd; - } - - eventsWithColumns.push({ ...ev, column: columnIndex }); + const regions = []; + if (activeStartMinutes > 0) { + regions.push( +
+ ); } - - const columnsInCluster = columns.length; - eventsWithColumns.forEach((ev) => { - ev.columnsInCluster = columnsInCluster; - }); - - return eventsWithColumns; + if (activeEndMinutes < MINUTES_PER_DAY) { + regions.push( +
+ ); + } + return regions; }; - const renderEvents = () => { - const clusters = groupIntoClusters(calendarEvents); + const renderDayEvents = (dayIndex) => { + const daySegments = segmentsByDay[dayIndex] || []; + const clusters = groupIntoClusters(daySegments); const processedEvents = []; for (const cluster of clusters) { - const eventsWithColumns = computeColumns(cluster); - processedEvents.push(...eventsWithColumns); + processedEvents.push(...computeColumns(cluster)); } - return processedEvents.map((ev, index) => { - const start = new Date(ev.start_time); - const end = new Date(ev.end_time); - const topMinutes = getMinutesFromStart(start); - const durationMinutes = (end - start) / (1000 * 60); - const eventHeight = Math.max(durationMinutes * minuteHeight, 24); + return processedEvents.map((seg) => { + const layout = getSegmentLayout(seg.segmentStart, seg.segmentEnd, minuteHeight); + if (!layout) return null; + + const spanClass = [ + seg.continuesFromPrev ? 'continues-from-prev' : '', + seg.continuesToNext ? 'continues-to-next' : '', + isMultiDay && (seg.continuesFromPrev || seg.continuesToNext) ? 'multi-day-span' : '' + ] + .filter(Boolean) + .join(' '); return (
); }); }; - const renderTimeGrid = () => { - const lines = []; - days.forEach((day, dayIndex) => { - for (let m = rangeStartMinutes; m < rangeEndMinutes; m += 30) { - const isHour = m % 60 === 0; - const top = (dayIndex * minutesInRange + (m - rangeStartMinutes)) * minuteHeight; - lines.push( -
- ); - } - }); - return lines; - }; - - const renderDayLines = () => { - return days.map((day, index) => ( -
- - {day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} - -
- )); - }; - - const renderTimeLabels = () => { - const labels = []; - const startHour = Math.floor(rangeStartMinutes / 60); - const endHour = Math.ceil(rangeEndMinutes / 60); - days.forEach((day, dayIndex) => { - for (let hour = startHour; hour < endHour; hour++) { - const hourMinutes = hour * 60; - if (hourMinutes >= rangeEndMinutes) break; - const top = (dayIndex * minutesInRange + (hourMinutes - rangeStartMinutes)) * minuteHeight; - labels.push( -
- {new Date(0, 0, 0, hour).toLocaleTimeString([], { hour: '2-digit' })} -
- ); - } - }); - return labels; - }; - return (
-
-
- {renderTimeLabels()} +
+
+
+ {days.map((day, index) => ( +
+ + {day.toLocaleDateString('en-US', { weekday: 'short' })} + + + {day.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + +
+ ))}
+
-
- {renderDayLines()} - {renderTimeGrid()} - {renderEvents()} +
+
{renderTimeLabels()}
+ +
+ {days.map((day, index) => ( +
+ {renderInactiveRegions(index)} + {renderTimeGrid()} + {renderDayEvents(index)} +
+ ))}
diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss index 0d016e09..68dfa8d2 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/AgendaDailyCalendar.scss @@ -1,51 +1,170 @@ .agenda-daily-calendar { height: auto; border-top: 1px solid var(--lighterborder); + width: 100%; - &.multi-day { - .calendar-header { - display: none; + &.multi-day-columns { + overflow-x: auto; + + .days-header, + .days-container { + min-width: max(100%, calc(var(--day-count, 2) * 140px)); + } + + .day-header, + .day-column { + min-width: 140px; + } + } + + .calendar-header { + display: flex; + position: sticky; + top: 0; + z-index: 20; + background-color: var(--background); + } + + .time-header { + width: 60px; + flex-shrink: 0; + border: 1px solid var(--lighterborder); + border-radius: 10px 0 0 0; + } + + .days-header { + display: flex; + flex: 1; + } + + .day-header { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 8px 6px; + border: 1px solid var(--lighterborder); + border-left: none; + background: var(--background); + + .day-name { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--light-text); + } + + .day-date { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + } + + &:last-child { + border-radius: 0 10px 0 0; } } .calendar-body { + display: flex; position: relative; - padding-top: 12px; + padding-top: 0; overflow: visible; } - .day-column { + .time-column { + width: 60px; + flex-shrink: 0; position: relative; + border-left: 1px solid var(--lighterborder); + border-right: 1px solid var(--lighterborder); } - .day-line { - position: absolute; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, rgba(77, 170, 87, 0.4), rgba(77, 170, 87, 0.15)); - z-index: 10; - pointer-events: none; - - .day-line-label { + .days-container { + display: flex; + flex: 1; + position: relative; + min-width: 0; + } + + .day-column { + flex: 1; + min-width: 0; + position: relative; + border-right: 1px solid var(--lighterborder); + background-color: var(--bounding-color, #fbfbfb); + + &:last-child { + border-right: none; + } + + .inactive-region { position: absolute; - left: 8px; - top: -10px; - font-size: 0.8rem; - font-weight: 700; - color: var(--text); - background: var(--background); - padding: 2px 8px; - border: 1px solid rgba(77, 170, 87, 0.5); - border-radius: 4px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); - z-index: 11; + left: 0; + right: 0; + z-index: 1; + pointer-events: none; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 4px, + rgba(0, 0, 0, 0.03) 4px, + rgba(0, 0, 0, 0.03) 8px + ); + background-color: rgba(0, 0, 0, 0.04); + + &.before { + border-bottom: 1px dashed var(--lighterborder); + } + + &.after { + border-top: 1px dashed var(--lighterborder); + } } } + // Legacy vertical multi-day styles (removed stacked day lines) + .day-line { + display: none; + } + + .time-grid-line { + z-index: 2; + } + .event.agenda-event { position: absolute; - margin:0; - min-height: 32px; + margin: 0; + min-height: 24px; + z-index: 4; + + &:hover { + z-index: 5; + } + + &.multi-day-span { + border-left-width: 3px; + } + + &.continues-from-prev .agenda-item-calendar-event { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top-style: dashed; + } + + &.continues-to-next .agenda-item-calendar-event { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-style: dashed; + } + } +} + +.agenda-calendar-wrapper { + .agenda-daily-calendar.multi-day-columns { + --day-count: 2; } } diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js new file mode 100644 index 00000000..17c53901 --- /dev/null +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaDailyCalendar/agendaCalendarUtils.js @@ -0,0 +1,204 @@ +const MS_PER_DAY = 24 * 60 * 60 * 1000; +export const MINUTES_PER_DAY = 24 * 60; + +export function startOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +export function endOfDay(date) { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +export function getMinutesInDay(date) { + return date.getHours() * 60 + date.getMinutes(); +} + +export function toCalendarEvent(item) { + return { + ...item, + start_time: + typeof item.startTime === 'string' + ? item.startTime + : item.startTime?.toISOString?.() ?? new Date(item.startTime).toISOString(), + end_time: + typeof item.endTime === 'string' + ? item.endTime + : item.endTime?.toISOString?.() ?? new Date(item.endTime).toISOString() + }; +} + +/** Split an agenda item into one segment per calendar day it intersects. */ +export function splitItemIntoDaySegments(item, days) { + const start = new Date(item.start_time); + const end = new Date(item.end_time); + if (isNaN(start.getTime()) || isNaN(end.getTime()) || end <= start) { + return []; + } + + const segments = []; + days.forEach((day, dayIndex) => { + const dayStart = startOfDay(day); + const dayEnd = endOfDay(day); + const segStart = start > dayStart ? start : dayStart; + const segEnd = end < dayEnd ? end : dayEnd; + + if (segStart < segEnd) { + segments.push({ + ...item, + dayIndex, + segmentStart: segStart, + segmentEnd: segEnd, + continuesFromPrev: segStart > start, + continuesToNext: segEnd < end, + segmentKey: `${item.id}-day-${dayIndex}` + }); + } + }); + + return segments; +} + +/** Active (non-grey) minutes on a day column: event start → event end, clipped to that day. */ +export function getDayActiveBounds(day, eventStart, eventEnd) { + const dayStart = startOfDay(day); + const dayEnd = endOfDay(day); + + if (eventEnd <= dayStart || eventStart >= dayEnd) { + return { activeStartMinutes: 0, activeEndMinutes: 0 }; + } + + const activeStartMinutes = eventStart > dayStart ? getMinutesInDay(eventStart) : 0; + const activeEndMinutes = eventEnd < dayEnd ? getMinutesInDay(eventEnd) : MINUTES_PER_DAY; + + return { activeStartMinutes, activeEndMinutes }; +} + +export function computeDayRange(agendaItems, event, dayStart, dayEnd) { + const eventStart = event?.start_time ? new Date(event.start_time) : new Date(); + const eventEnd = event?.end_time ? new Date(event.end_time) : new Date(eventStart); + if (isNaN(eventEnd.getTime()) || eventEnd <= eventStart) { + eventEnd.setDate(eventEnd.getDate() + 1); + } + + let first = dayStart ? new Date(dayStart) : eventStart; + let last = dayEnd ? new Date(dayEnd) : eventEnd; + + if (agendaItems.length > 0) { + const firstStart = agendaItems.reduce((earliest, item) => { + const itemStart = item.startTime ? new Date(item.startTime) : null; + if (!itemStart) return earliest; + return !earliest || itemStart < earliest ? itemStart : earliest; + }, null); + const lastEnd = agendaItems.reduce((latest, item) => { + const itemEnd = item.endTime ? new Date(item.endTime) : null; + if (!itemEnd) return latest; + return !latest || itemEnd > latest ? itemEnd : latest; + }, null); + if (firstStart) first = firstStart < first ? firstStart : first; + if (lastEnd) last = lastEnd > last ? lastEnd : last; + } + + const firstDay = startOfDay(first); + const lastDay = startOfDay(last); + + const days = []; + for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) { + days.push(new Date(d)); + } + + const perDayBounds = days.map((day) => getDayActiveBounds(day, eventStart, eventEnd)); + + return { + days, + perDayBounds, + minutesInRange: MINUTES_PER_DAY + }; +} + +export function getSegmentLayout(segmentStart, segmentEnd, minuteHeight) { + let startMinutes = getMinutesInDay(segmentStart); + let endMinutes = getMinutesInDay(segmentEnd); + + if (endMinutes === 0 && segmentEnd > segmentStart) { + const daySpan = segmentEnd - segmentStart; + if (daySpan >= MS_PER_DAY - 60000) { + endMinutes = MINUTES_PER_DAY; + } + } + + if (endMinutes <= startMinutes) { + return null; + } + + const top = startMinutes * minuteHeight; + const height = Math.max((endMinutes - startMinutes) * minuteHeight, 24); + + return { top, height, displayStart: segmentStart, displayEnd: segmentEnd }; +} + +export function groupIntoClusters(events) { + if (events.length === 0) return []; + const sortedEvents = [...events].sort( + (a, b) => new Date(a.segmentStart) - new Date(b.segmentStart) + ); + const clusters = []; + let currentCluster = [sortedEvents[0]]; + let maxEnd = new Date(sortedEvents[0].segmentEnd); + + for (let i = 1; i < sortedEvents.length; i++) { + const ev = sortedEvents[i]; + const evStart = new Date(ev.segmentStart); + if (evStart < maxEnd) { + currentCluster.push(ev); + const evEnd = new Date(ev.segmentEnd); + maxEnd = evEnd > maxEnd ? evEnd : maxEnd; + } else { + clusters.push(currentCluster); + currentCluster = [ev]; + maxEnd = new Date(ev.segmentEnd); + } + } + clusters.push(currentCluster); + return clusters; +} + +export function computeColumns(cluster) { + const sortedCluster = [...cluster].sort( + (a, b) => new Date(a.segmentStart) - new Date(b.segmentStart) + ); + const columns = []; + const eventsWithColumns = []; + + for (const ev of sortedCluster) { + const evStart = new Date(ev.segmentStart); + const evEnd = new Date(ev.segmentEnd); + let columnIndex = -1; + + for (let i = 0; i < columns.length; i++) { + if (evStart >= columns[i]) { + columnIndex = i; + break; + } + } + + if (columnIndex === -1) { + columnIndex = columns.length; + columns.push(evEnd); + } else { + columns[columnIndex] = evEnd; + } + + eventsWithColumns.push({ ...ev, column: columnIndex }); + } + + const columnsInCluster = columns.length; + eventsWithColumns.forEach((ev) => { + ev.columnsInCluster = columnsInCluster; + }); + + return eventsWithColumns; +} diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx index ecc941b3..b5bd0437 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.jsx @@ -33,7 +33,7 @@ function getDisplayType(item) { return item?.type || 'Activity'; } -function AgendaItemCalendarEvent({ item, onEdit, event }) { +function AgendaItemCalendarEvent({ item, onEdit, event, showContinuationHint = false }) { const formatTime = (date) => { if (!date) return ''; const d = date instanceof Date ? date : new Date(date); @@ -68,6 +68,9 @@ function AgendaItemCalendarEvent({ item, onEdit, event }) {
{item.title || 'Untitled'}
+ {showContinuationHint && ( + Spans multiple days + )}
{getDisplayType(item)} {item.location && ( diff --git a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss index b7dc075f..7a6293dc 100644 --- a/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss +++ b/frontend/src/pages/ClubDash/EventsManagement/components/EventDashboard/EventAgendaBuilder/AgendaItemCalendarEvent/AgendaItemCalendarEvent.scss @@ -32,6 +32,15 @@ margin-bottom: 4px; } + .continuation-hint { + display: block; + font-size: 0.7em; + font-style: italic; + color: var(--event-accent, var(--red)); + margin-bottom: 4px; + opacity: 0.85; + } + .event-details { display: flex; flex-direction: column; From 87c3eef48cec025bdc07ac0b16bfb0401a9add96 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Mon, 25 May 2026 00:02:27 -0400 Subject: [PATCH 05/39] adding new logo --- .DS_Store | Bin 10244 -> 10244 bytes .../Meridian/EventDashboard Redesign.html | 41 + .../Meridian/EventDashboard Spec.md | 388 ++++++++ .../design/EventDashboard/Meridian/app.jsx | 33 + .../EventDashboard/Meridian/design-canvas.jsx | 936 ++++++++++++++++++ .../EventDashboard/Meridian/diagnosis.jsx | 99 ++ .../EventDashboard/Meridian/v1-focus.jsx | 169 ++++ .../EventDashboard/Meridian/v2-cockpit.jsx | 157 +++ .../Meridian/v3-retrospective.jsx | 180 ++++ .../EventDashboard/Meridian/v4-live.jsx | 153 +++ .../EventDashboard/Meridian/v5-concluded.jsx | 159 +++ frontend/public/icon.svg | 19 +- frontend/src/assets/Brand Image/BEACON.svg | 16 +- frontend/src/assets/Brand Image/BEACON2.svg | 13 + frontend/src/assets/Brand Image/Globe.svg | 14 +- .../Admin/OperatorHubMode/OperatorHubMode.jsx | 2 +- 16 files changed, 2352 insertions(+), 27 deletions(-) create mode 100644 frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html create mode 100644 frontend/design/EventDashboard/Meridian/EventDashboard Spec.md create mode 100644 frontend/design/EventDashboard/Meridian/app.jsx create mode 100644 frontend/design/EventDashboard/Meridian/design-canvas.jsx create mode 100644 frontend/design/EventDashboard/Meridian/diagnosis.jsx create mode 100644 frontend/design/EventDashboard/Meridian/v1-focus.jsx create mode 100644 frontend/design/EventDashboard/Meridian/v2-cockpit.jsx create mode 100644 frontend/design/EventDashboard/Meridian/v3-retrospective.jsx create mode 100644 frontend/design/EventDashboard/Meridian/v4-live.jsx create mode 100644 frontend/design/EventDashboard/Meridian/v5-concluded.jsx create mode 100644 frontend/src/assets/Brand Image/BEACON2.svg diff --git a/.DS_Store b/.DS_Store index 6198ba9e8c3c4d1f37215366350bf472b7fa11c1..b1e51a31a141cf667416abb7702f5448003a5357 100644 GIT binary patch delta 215 zcmZn(XbG6$&uFwUU^hRb(Pkcj&zu4|>4w3{`MCuQKv1%j|K;R|;vyos`7SO=Ir&LI zF^;IF|2sOa9e0FEq+pd0D9At<(^{#(08%~qzvSM@EmD&F$aXL-ahcp8C5vK(ve=Sb i)*hf0jZ!i|34|5M68y+kuv}5tyhG|g^JaF1zw7|RX-3)r delta 37 tcmZn(XbG6$LAU^hRb)@B}o&zzH|OYPmvBlDYiV?zMrW_E?Y>;MAr4XywH diff --git a/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html b/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html new file mode 100644 index 00000000..e4bd9d50 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/EventDashboard Redesign.html @@ -0,0 +1,41 @@ + + + + +EventDashboard — Lifecycle Redesigns + + + + + + + + + + +
+ + + + + + + + + + diff --git a/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md b/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md new file mode 100644 index 00000000..0defbe80 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/EventDashboard Spec.md @@ -0,0 +1,388 @@ +# EventDashboard — Lifecycle Redesign Spec + +**Status:** Ready for implementation +**Owner:** Design → Eng handoff +**Scope:** Replace the current monolithic `EventDashboard` (and the parallel `EventDashboardFocused` variant) with four state-aware surfaces driven by a single component that branches on event lifecycle. + +--- + +## 1. Problem statement + +The current `EventDashboard` is a single dense surface that renders the same nine-tab layout regardless of where an event is in its lifecycle. This causes: + +1. **Decision fatigue** — 9 tabs + 5 header CTAs visible at all times. +2. **No focus** — equal-weight panels for unrelated tasks (Event Preview vs. Registration Chart). +3. **Status-blind chrome** — a concluded event still pushes "Send announcement" and "Preview" as primaries. +4. **Stat noise** — 3+ stats above the chart with similar visual weight. +5. **Wasted vertical space** — sidebar + ambient header + title + meta strip + banner + tabs ≈ 480px before content. +6. **No "what now" surface** — pending follow-ups (feedback, thank-yous, returns) hide inside tabs. + +## 2. Solution overview + +The dashboard becomes **four products that share a data layer**: + +| Code | Lifecycle state | Trigger condition | Primary jobs | +|---|---|---|---| +| `created` | Just created (draft) | `event.status === 'draft'` and required fields incomplete | Complete setup checklist; reach a publishable state | +| `preparing` | Published, future-dated | `status === 'published'` && `start_time > now` | Outreach, tasks, run-of-show prep | +| `live` | Day-of, in progress | `start_time <= now <= end_time` | Check-in, agenda advance, issue triage | +| `concluded` | Past | `end_time < now` | Post-mortem, feedback collection, wrap-up tasks | + +A single `EventDashboard` component reads `dashboardData.stats.operationalStatus` (and existing `event.status`) and routes to one of four `DashboardShell` variants. The existing `useFetch('/org-event-management/${orgId}/events/${eventId}/dashboard')` call is unchanged. + +## 3. Visual reference + +Designs live in `EventDashboard Redesign.html` (this project). Each artboard is 1440×980 and is the source of truth. Take inspiration from styling but re-imagine it in Meridian's existing design language + +### 4.3 Components to build (shared across all four states) + +Build these as small presentational components in `components/EventDashboard/components/shared/`: + +- **``** — left rail of workspace areas, replaces `TabbedContainer`. Each item: `{key, label, count?, active?, disabled?, alert?, live?}`. Width 184px. Disabled items show "LOCKED" mono microlabel. +- **``** — status badge. Live tone has pulsing 0 0 0 3px tint shadow on dot. +- **``** — Fraunces big number + tinted delta chip. +- **``** — 4–6px filled bar. +- **``** — mono 10.5px uppercase eyebrow. +- **``** — used in "Needs attention" / "Wrap-up" panels. +- **``** — for stat rows in readiness panels. + +### 4.4 Header pattern (all four states) + +Top of canvas, padding `24px 40px 0`: + +``` +[← back btn] [breadcrumb mono] +[H1 Fraunces 44px] [StatusPill] +[meta strip: date · time · venue · host (state-dependent)] + [secondary btns] [primary CTA] +``` + +Primary CTA is **state-specific** (see each state below). Secondary buttons collapse into a "More" overflow on viewports < 1280px. + +## 5. State-by-state spec + +### 5.1 `created` — Just created (empty event) + +**Trigger:** `event.status === 'draft'`. + +**Header** +- Breadcrumb: `EVENTS / NEW · DRAFT` +- StatusPill: `tone="draft"`, label = `Draft · not visible to anyone` +- Meta: `Created {relative time} · Auto-saved` +- Secondary: `Discard` +- Primary: `Publish · {N} steps left` — **disabled** until checklist complete; computes `N = totalSteps - completedSteps`. + +**WorkspaceRail items** +```js +[ + { key: 'overview', label: 'Setup', count: '2/8', active: true }, + { key: 'schedule', label: 'Schedule', disabled: true }, + { key: 'tasks', label: 'Tasks', disabled: true }, + { key: 'people', label: 'People', disabled: true, count: 0 }, + { key: 'jobs', label: 'Jobs', disabled: true }, + { key: 'comms', label: 'Communications', disabled: true }, + { key: 'insights', label: 'Insights', disabled: true }, +] +``` + +**Body — two stacked panels:** + +1. **Progress hero** (white card) + - Eyebrow `SETUP · {done} OF {total}` + - H2 Fraunces 28px: `"You're ~{minutes} minutes away from a publishable event."` (computed from sum of remaining `mins`) + - 8-segment progress meter (filled = green primary, unfilled = bg-soft) + - Right-aligned: small empty poster placeholder (replaced when uploaded). + +2. **Setup checklist card** (white card, divider rows) + Each row: `[circle: checkmark / number / current dot] [title + sub] [~Xmin] [CTA]` + - Done rows: title strikethrough, ink-3. + - Current row: full row tinted `--ed-primary-tint`, primary "Continue →" CTA on right. + - Future rows: muted, "Open" ghost button. + - Final row tagged `FINAL STEP` mono microlabel. + + Steps (this order, this copy): + 1. Name your event + 2. Pick a date + 3. Add a location *(required to publish)* + 4. Write a description + 5. Upload a poster or hero image *(optional but events with a poster see 3× more registrations)* + 6. Build a registration form + 7. Set capacity & expected attendance + 8. Publish + +**Footer note:** `ⓘ Tasks, registrations, communications and insights unlock once your event is published.` + +**Backend hooks** +- Each step maps to a field on the event document. "Done" rule: + - `name`: `event.event_name?.trim().length > 0` + - `date`: `event.start_time && event.end_time` + - `location`: `event.location?.trim().length > 0` + - `description`: `event.description?.length >= 100` + - `poster`: `event.poster_image_url` + - `regForm`: `event.registrationFormId` + - `capacity`: `event.expectedAttendance > 0` + - `publish`: requires above + `event.status !== 'draft'` +- "Add location" CTA opens the existing `EventEditorTab` scrolled to the location field. + +--- + +### 5.2 `preparing` — Before the event + +**Trigger:** `event.status === 'published'` and `start_time > now`. + +**Header** +- StatusPill: `tone="prep"`, label = `"Published · {daysOut} days out"` (or hours if < 1 day). +- Meta: full date/time/venue. +- Secondary: `Preview page` +- Primary: `Send announcement` (paper-airplane icon). + +**WorkspaceRail** +```js +[ + { key: 'overview', label: 'Overview', active: true }, + { key: 'schedule', label: 'Schedule', count: agenda.items.length }, + { key: 'tasks', label: 'Tasks', count: openTaskCount, alert: hasOverdueTasks }, + { key: 'people', label: 'People', count: registrationCount }, + { key: 'jobs', label: 'Jobs', count: openJobCount }, + { key: 'comms', label: 'Communications', count: pendingAnnouncementCount }, + { key: 'insights', label: 'Insights' }, +] +``` + +**Body:** + +1. **Countdown hero** (white card, two columns split by vertical divider) + - **Left column (1.1fr):** + - Eyebrow `EVENT STARTS IN` + - Big number: days remaining (96px Fraunces) + - Inline: `days` + `{hours} hrs · {minutes} min` + - 30-segment progress bar showing position from creation date → start date. Filled segments = primary. + - Footer: `{createdDate} · created` — `today` — `{startDate} · live` + - **Right column (1fr):** "This week — {N} items need you" + - Up to 4 task rows: `[avatar circle][body][due chip]`. Avatar background = warn-soft for urgent, bg-soft for normal. + - Pull from `tasks` filtered to `dueDate <= now + 7d`, ordered by due date. + +2. **Three-panel row (1.3fr / 1fr / 1fr):** + - **Registration pace** — title, big number `registrationCount`, sub `of {expected} expected · {pct}%`, delta line (this week + on-track copy), small sparkline with goal dashed line. Reuse existing `EventDashboardChart` rendered at small height (~110–130px) with no header/legend. + - **Outreach** — list of `Announcement` rows: initial / mid-cycle / final push / day-of. Done rows show date sent + reach; current row primary-bordered; future rows muted with suggested send date. Wires to `Communications` data. + - **Run-of-show readiness** — 5-row checklist of: Schedule blocks, Speakers confirmed, Volunteer roles, Equipment booked, Venue walkthrough. Each row: label + ratio in primary (good) or warn (incomplete). + +--- + +### 5.3 `live` — During the event + +**Trigger:** `start_time <= now <= end_time`. + +**Visual identity:** dark slim live header, lighter body. This is the only state with a colored chrome. + +**Header (replaces standard header — full-width dark bar, padding `14px 32px`):** +- Background `#1c2520`, color white. +- Left: back button (transparent border) + live pulse pill (`LIVE · DAY {N}`) + event name (medium weight, opacity 0.85) + `{wallClockTime} · {elapsed} elapsed` (mono). +- Right: `Page volunteers` (ghost) + `Send announcement` (orange `--ed-live-accent` solid). + +**WorkspaceRail** (label `LIVE OPS`, white card panel) +```js +[ + { key: 'live', label: 'Live ops', active: true, live: true, count: 'NOW' }, + { key: 'checkin', label: 'Check-in', count: checkedInCount, live: true }, + { key: 'schedule', label: 'Schedule', count: `${currentBlockIdx}/${totalBlocks}` }, + { key: 'tasks', label: 'Tasks', count: liveTaskCount, alert: true }, + { key: 'people', label: 'People', count: registrationCount }, + { key: 'jobs', label: 'Jobs', count: jobCount }, + { key: 'comms', label: 'Communications', count: 0 }, +] +``` + +**Body:** + +1. **"Happening now" command bar** (dark gradient card) + - Eyebrow `HAPPENING NOW · {room}` + - H2 Fraunces 28px: current agenda block title + speaker + - Sub: `{startTime} – {endTime} · ends in {N} minutes` (red-tinted if running over) + - Right: `View slot` (ghost) + `Advance →` (mint solid, advances to next agenda item). + +2. **4-tile readiness row** (white cards, equal width) + - `Checked in`: count / total registered, meter, `{pct}%` accent. + - `Capacity`: `{checkedIn}/{venueCapacity}` ratio. + - `On schedule`: `+{minutes}` running over (warn tone if > 0). Meter shows position in day. + - `Issues`: count of open ops issues, warn tone, sub = top issue summary. + +3. **Two-column row (1.2fr / 1fr):** + - **Check-in flow · last 60 min** — sparkline (`color="#c4533a"`), three sub-stats: peak rate, current rate, no-shows. `Open scanner` button. + - **Up next** — agenda from current block onward (next 6 items). The next block has primary-tint background + `NEXT` mono badge. Each row: `[time mono][title + room]`. + +**Polling:** check-in count and "happening now" should poll every 15s while user is on this view. + +--- + +### 5.4 `concluded` — Post-mortem + +**Trigger:** `end_time < now`. + +**Header** +- Breadcrumb: `EVENTS / RETROSPECTIVE` +- StatusPill: `tone="past"`, label = `"Concluded · {daysAgo} days ago"` +- Meta: full date/time/venue. +- Secondary: `Duplicate for {nextYear}` +- Primary: `Share report` (dark `--ed-ink` solid, NOT primary green). + +**WorkspaceRail** +```js +[ + { key: 'overview', label: 'Retrospective', active: true }, + { key: 'feedback', label: 'Feedback', count: feedbackResponseCount }, + { key: 'people', label: 'Attendees', count: checkedInCount }, + { key: 'tasks', label: 'Wrap-up', count: openWrapupTasks, alert: true }, + { key: 'insights', label: 'Insights' }, + { key: 'archive', label: 'Archive' }, +] +``` + +**Body:** + +1. **Outcome statement** (white card, 1.4fr / auto) + - Eyebrow `OUTCOME` (primary green) + - H1 Fraunces 36px: one-sentence summary template: + `"{registrationCount} {audience} showed up — {deltaPct}% over your {expectedAttendance}-attendee goal, with a {showRate}% show-rate and {avgRating} / 5 average rating."` + - Where `{audience}` is auto-picked from event template/tags ("builders", "guests", "attendees", default "people"). + - Sub: list of remaining wrap-up follow-ups in plain English. + - Right: poster thumbnail tilted 2°, drop-shadow. + +2. **Scoreboard** (white card, 4 columns separated by `1px solid --ed-line`) + Each column: eyebrow / Fraunces 44px value / delta chip / "expected X" / italic 12px explainer. + Required metrics: + - Registrations (vs. expected). + - Showed up (count + show-rate %). + - Avg. rating (from feedback). + - NPS (warn tone if response rate < 30%). + +3. **Two-column row (1.4fr / 1fr):** + - **What attendees said** — top 4 feedback themes as horizontal bar rows: `{themeText} ··· {pct}%`. Bar color: primary (>50% positive), `#c4a44a` mixed, warn negative. Theme extraction = backend job (out of scope here; for v1, use response tags). + - **Wrap-up** — `` list. Top item primary-tinted ("Send feedback follow-up to {N} silent attendees"), rest are neutral ("$X prize disbursement", "Equipment return — {N} items"). + +## 6. Architecture + +### 6.1 File structure + +``` +components/EventDashboard/ + EventDashboard.jsx ← thin router + EventDashboard.scss ← tokens + base + shells/ + CreatedShell.jsx + PreparingShell.jsx + LiveShell.jsx + ConcludedShell.jsx + components/shared/ + WorkspaceRail.jsx + StatusPill.jsx + HeroNumber.jsx + MeterBar.jsx + EyebrowLabel.jsx + AttentionItem.jsx + DashboardHeader.jsx ← shared layout, slots for state-specific bits + state/ + useEventLifecycleState.js ← derives 'created' | 'preparing' | 'live' | 'concluded' + useSetupChecklist.js ← created-state checklist completeness + useThisWeekTasks.js + useLiveOpsPolling.js +``` + +### 6.2 Routing logic + +```js +function getLifecycleState(event, stats) { + if (event.status === 'draft') return 'created'; + if (stats?.operationalStatus === 'completed') return 'concluded'; + const now = Date.now(); + const start = new Date(event.start_time).getTime(); + const end = new Date(event.end_time || event.start_time).getTime(); + if (now >= start && now <= end) return 'live'; + if (start > now) return 'preparing'; + return 'concluded'; +} +``` + +`EventDashboard.jsx` keeps the data fetch, onboarding, error handling, and announcement spotlight. The router only chooses a shell: + +```jsx +const state = getLifecycleState(event, dashboardData?.stats); +const Shell = { created: CreatedShell, preparing: PreparingShell, + live: LiveShell, concluded: ConcludedShell }[state]; +return ; +``` + +### 6.3 Migration + +- `EventDashboardFocused` is deprecated and routed through the new component. Delete after one release. +- The `overlayRegistry.js` `default` and `focused` variants both resolve to the new `EventDashboard`. +- `useDashboardOverlay.showEventDashboardFocused` becomes an alias for `showEventDashboard`. +- The current `EventDashboardHeader` is retired; only `DashboardHeader` (shared) remains. +- The old tab system (`TabbedContainer` of 9 tabs) is replaced with the rail. All nine tab bodies are kept and rendered when the corresponding rail item is active. Routing inside the dashboard remains URL-driven (`?tab=` param). + +## 7. Behaviour & data + +### 7.1 Existing endpoints reused + +- `GET /org-event-management/${orgId}/events/${eventId}/dashboard` — primary fetch (unchanged). +- `GET /org-event-management/${orgId}/events/${eventId}/registrations/growth` — for sparklines. +- `GET .../checkins/recent?window=60m` — **new lightweight endpoint** for the Live state's check-in flow chart. +- `GET .../tasks?dueWithin=7d` — for "this week" panel (preparing state). + +### 7.2 New endpoints + +- `GET /org-event-management/${orgId}/events/${eventId}/setup-progress` — returns `{ completed: [...stepKeys], total: N, etaMinutes: M }`. The frontend can compute this client-side too; surface it as an endpoint so it can be cached. +- `POST .../advance-agenda` — advances the current agenda block. Used by Live state's `Advance →` button. +- `GET .../feedback-themes` — returns top 4 themes with pct + tone for the concluded state. v1 may compute this from response tags; v2 = ML. + +### 7.3 Polling + +- Live state polls `/dashboard` and `/checkins/recent` every 15s. All other states do not poll. +- Pause polling when `document.hidden`. + +### 7.4 Empty / loading / error + +- Loading: keep the existing skeleton but adapt per shell (e.g., the Created shell shows skeletons of checklist rows). +- Errors: existing `addNotification` toast + retry on dashboard fetch; preserved from current implementation. + +## 8. Accessibility + +- All state pills have `aria-label` including the readable status ("Event status: live, day 1"). +- Live pulse uses `prefers-reduced-motion: reduce` to disable the box-shadow pulse animation. +- WorkspaceRail items are `` with `aria-current="page"` on the active item. Disabled items use `aria-disabled="true"` and are non-tabbable. +- All big-number tiles have a hidden `` describing the relationship ("177 registrations, 77% over goal"). +- Color is never the sole indicator — every warn-toned item has a text label or icon. + +## 9. Analytics + +Add events: +- `event_dashboard_state_view` `{state, eventId, orgId}` — fired once per state per session. +- `event_dashboard_state_transition` `{from, to, eventId}` — for Live ↔ Concluded transitions. +- `event_dashboard_setup_step_complete` `{step, eventId}` — Created state. +- `event_dashboard_advance_agenda` `{fromBlockId, toBlockId}` — Live state. + +Existing `event_workspace_view` and `event_workspace_tab_view` are kept; rename the latter to `event_dashboard_rail_view` if convenient. + +## 10. Acceptance criteria + +- [ ] Lifecycle state is correctly derived from `event.status`, `start_time`, `end_time`, and `stats.operationalStatus`. +- [ ] Each shell matches the corresponding artboard at 1440×980 within 4px tolerance. +- [ ] No header has more than one primary CTA. +- [ ] WorkspaceRail count badges reflect live data. +- [ ] Created state's `Publish` button is disabled until all required steps complete. +- [ ] Live state polls and pauses when tab is hidden. +- [ ] Concluded state's outcome sentence renders correctly when any of `expectedAttendance`, `feedbackCount`, or `avgRating` is missing (graceful fallback copy: "showed up — feedback collection in progress"). +- [ ] Existing `EventDashboardOnboarding`, announcement spotlight, and post-mortem overlay continue to work. +- [ ] Existing analytics events continue to fire; new events fire once per state. +- [ ] `EventDashboardFocused` consumers redirect to the new `EventDashboard` with no behavioral regression. + +## 11. Out of scope (v1) + +- ML-powered feedback theme extraction (v1 = response tags). +- Multi-day live state UX (Day 2 chips, between-days "lull" view) — v1 treats every day-of as Day 1. +- Mobile layout — v1 is desktop-only; the existing mobile preview component is unchanged. +- Operator/admin variants (`AdminEventOperatorPage`) — out of scope; they will continue using the legacy components until a follow-up. + +--- + +**Reference:** see `EventDashboard Redesign.html` in this project for the four artboards, the diagnosis card, and the shared chrome components. diff --git a/frontend/design/EventDashboard/Meridian/app.jsx b/frontend/design/EventDashboard/Meridian/app.jsx new file mode 100644 index 00000000..89e8a3a1 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/app.jsx @@ -0,0 +1,33 @@ +const { DesignCanvas, DCSection, DCArtboard } = window; + +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/frontend/design/EventDashboard/Meridian/design-canvas.jsx b/frontend/design/EventDashboard/Meridian/design-canvas.jsx new file mode 100644 index 00000000..5e685a6d --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/design-canvas.jsx @@ -0,0 +1,936 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Only direct DCSection > DCArtboard children are + // walked — wrapping them in other elements opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + React.Children.forEach(children, (sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + React.Children.forEach(sec.props.children, (ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(children); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/frontend/design/EventDashboard/Meridian/diagnosis.jsx b/frontend/design/EventDashboard/Meridian/diagnosis.jsx new file mode 100644 index 00000000..f3ba90e9 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/diagnosis.jsx @@ -0,0 +1,99 @@ +/* Diagnosis card — the four lifecycle states and the design problem of each */ +function Diagnosis() { + const states = [ + { + n: '01', tag: 'JUST CREATED', title: 'Empty event', + problem: 'A blank shell. The user has a name and not much else. Today\'s dashboard hides the gaps inside tab navigation — they have to click around to discover what\'s missing.', + goal: 'Make the next setup step obvious, and the second one too. Defer everything else until it earns its place.', + tone: 'neutral', + }, + { + n: '02', tag: 'PREPARING', title: 'Before the event', + problem: 'Foundations are done. Now it\'s outreach, tasks, and last-mile prep. Today\'s dashboard treats Mar 14 the same whether it\'s 30 days out or 30 hours out — no urgency, no time-aware cues.', + goal: 'A countdown view. What needs to happen this week, what\'s blocked, what\'s been sent.', + tone: 'work', + }, + { + n: '03', tag: 'LIVE', title: 'During the event', + problem: 'The current dashboard is a planning tool. On the day-of, organizers need a different surface entirely: check-in pace, capacity, mid-event announcements, ops issues — not a Registrations chart.', + goal: 'A live operations console. Big numbers, fast actions, almost no chrome.', + tone: 'live', + }, + { + n: '04', tag: 'CONCLUDED', title: 'Post-mortem', + problem: 'The event is over but the chrome still pushes "Send announcement" and "Preview". The post-mortem is hidden behind a banner CTA.', + goal: 'Pivot to retrospective: outcome vs. expectation, what attendees said, what tasks remain to close out.', + tone: 'past', + }, + ]; + return ( +
+
+
Four dashboards, not one
+
+
+ The EventDashboard tries to be the same surface across the entire lifecycle of an event. That's the root of the decision fatigue, density, and lack of focus — a "just-created" event and a "post-mortem" event have nothing in common except the data model — they should look like different products. +
+ + {/* Lifecycle bar */} +
+ {states.map((s, i) => ( +
+
+
{s.n}
+
+
+
{s.tag}
+
{s.title}
+
{s.problem}
+
→ {s.goal}
+
+ ))} +
+ + {/* Recurring flaws — what every state inherits */} +
What every state should fix
+
+ {[ + {tag: 'Tabs → workspace', body: '9 horizontal tabs become a 6-item workspace rail with counts. Same areas, less competition.'}, + {tag: 'Header → status-aware', body: 'The big block of CTAs is a function of state. A new event needs "Publish"; a live event needs "Open check-in"; a concluded event needs "Generate post-mortem".'}, + {tag: 'One primary, always', body: 'There is exactly one primary action per state, surfaced in a single dedicated zone — not five buttons in a top-right cluster.'}, + ].map((f, i) => ( +
+
{f.tag}
+
{f.body}
+
+ ))} +
+
+ ); +} +window.Diagnosis = Diagnosis; diff --git a/frontend/design/EventDashboard/Meridian/v1-focus.jsx b/frontend/design/EventDashboard/Meridian/v1-focus.jsx new file mode 100644 index 00000000..93d49762 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v1-focus.jsx @@ -0,0 +1,169 @@ +/* Shared chrome — AppSidebar, Sparkline, PosterPlaceholder + WorkspaceRail */ + +const Sparkline = ({color = 'var(--primary)', flat = false}) => ( + + + + + + + + + + +); + +const PosterPlaceholder = ({w = 160, h = 220, empty = false}) => ( + empty ? ( +
+
NO POSTER
+
+ ) : ( +
+
180 ? 32 : 26, fontWeight: 600, lineHeight: 0.95, letterSpacing: '-0.02em'}}>CHANGE
THE
WORLD
+
SAT MAR 14 · 9:30 AM
BIO & INTERDIS
+
180 ? 38 : 32, fontStyle: 'italic', color: '#f8d4a0', fontWeight: 600}}>$5,000
+
+ ) +); + +function AppSidebar({active = 'Events'}) { + const items = [ + {label: 'Dashboard', icon: 'M3 13h8V3H3zM13 21h8V11h-8zM3 21h8v-6H3zM13 3v6h8V3z'}, + {label: 'Events', icon: 'M19 4h-1V2h-2v2H8V2H6v2H5C3.9 4 3 4.9 3 6v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14z'}, + {label: 'Tasks', icon: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'}, + {label: 'Announcements', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm1 17h-2v-6h2zm0-8h-2V7h2z'}, + {label: 'Members', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3z'}, + {label: 'Settings', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z'}, + ]; + return ( +
+
+
+
+
+
Meridian
+
ATLAS
+
+
+
+
+ +
+
+
+
+
+
James
+
@James
+
+
+
+ ); +} + +/* Workspace rail used by all four states. Items can be disabled (state 1) or alerted (state 2/3). */ +function WorkspaceRail({items, label = 'WORKSPACE'}) { + return ( + + ); +} + +/* Status pill helper */ +function StatusPill({tone, label, dot = true}) { + const tones = { + draft: {bg: 'var(--bg-soft)', fg: 'var(--ink-3)', dot: 'var(--ink-4)'}, + prep: {bg: 'var(--primary-tint)', fg: 'var(--primary)', dot: 'var(--primary)'}, + live: {bg: '#fde7e1', fg: '#a8412c', dot: '#c4533a'}, + past: {bg: 'var(--bg-soft)', fg: 'var(--ink-3)', dot: 'var(--ink-4)'}, + }; + const t = tones[tone] || tones.prep; + return ( + + {dot && } + {label} + + ); +} + +window.Sparkline = Sparkline; +window.PosterPlaceholder = PosterPlaceholder; +window.AppSidebar = AppSidebar; +window.WorkspaceRail = WorkspaceRail; +window.StatusPill = StatusPill; diff --git a/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx b/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx new file mode 100644 index 00000000..d7a564e5 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v2-cockpit.jsx @@ -0,0 +1,157 @@ +/* State 1 — JUST CREATED. Empty event. The user just clicked "Create event". + Goals: + - Make the next setup step obvious (and the second one too). + - Defer everything that hasn't earned its place: tabs are mostly locked. + - The screen reads like a checklist, not a dashboard. */ + +function StateCreated() { + const items = [ + {key: 'overview', label: 'Setup', count: '2/8', active: true}, + {key: 'schedule', label: 'Schedule', disabled: true}, + {key: 'tasks', label: 'Tasks', disabled: true}, + {key: 'people', label: 'People', count: 0, disabled: true}, + {key: 'jobs', label: 'Jobs', disabled: true}, + {key: 'comms', label: 'Communications', disabled: true}, + {key: 'insights', label: 'Insights', disabled: true}, + ]; + + const checklist = [ + {done: true, title: 'Name your event', sub: '"Change the World Innovation Weekend"', mins: 1}, + {done: true, title: 'Pick a date', sub: 'Saturday, March 14, 2026 · 9:30 AM – 6:00 PM', mins: 1}, + {done: false, current: true, title: 'Add a location', sub: 'Where is this happening? Required to publish.', mins: 2, cta: 'Add location'}, + {done: false, title: 'Write a description', sub: 'Tell people why they should come. 2–4 paragraphs is plenty.', mins: 5}, + {done: false, title: 'Upload a poster or hero image', sub: 'Optional, but events with a poster see 3× more registrations.', mins: 2}, + {done: false, title: 'Build a registration form', sub: 'Default is name + email. Add custom fields if needed.', mins: 4}, + {done: false, title: 'Set capacity & expected attendance', sub: 'Used for waitlists and analytics goals.', mins: 1}, + {done: false, title: 'Publish', sub: 'Sends to your org\'s feed. You can still edit after publishing.', mins: 1, terminal: true}, + ]; + + return ( +
+ +
+
+
+
+
+ +
EVENTS / NEW · DRAFT
+
+
+

Change the World Innovation Weekend

+ +
+
+ Created 4 minutes ago · Auto-saved +
+
+
+ + +
+
+
+ +
+ + +
+ {/* Progress hero */} +
+
+
SETUP · 2 OF 8
+
+ You are ~15 minutes away from a publishable event. +
+
+ {Array.from({length: 8}).map((_, i) => ( +
+ ))} +
+
+
+ +
+
+ + {/* Checklist — the whole content */} +
+ {checklist.map((it, i) => ( +
+
+ {it.done ? ( + + ) : ( + {i + 1} + )} +
+
+
+ {it.title} + {it.terminal && FINAL STEP} +
+
{it.sub}
+
+
+ ~{it.mins} min + {it.current && ( + + )} + {!it.current && !it.done && ( + + )} +
+
+ ))} +
+ +
+ + Tasks, registrations, communications and insights unlock once your event is published. +
+
+
+
+
+ ); +} + +window.StateCreated = StateCreated; diff --git a/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx b/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx new file mode 100644 index 00000000..c8c22e11 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v3-retrospective.jsx @@ -0,0 +1,180 @@ +/* State 2 — PREPARING. Foundation done. T-minus countdown. + Goals: + - Time-aware: a countdown front and center; "this week" framing. + - Outreach + tasks are the headline activities. + - Registration chart is here, but kept as a small read-only snapshot. */ + +function StatePreparing() { + const items = [ + {key: 'overview', label: 'Overview', active: true}, + {key: 'schedule', label: 'Schedule', count: 12}, + {key: 'tasks', label: 'Tasks', count: 4, alert: true}, + {key: 'people', label: 'People', count: 64}, + {key: 'jobs', label: 'Jobs', count: 5}, + {key: 'comms', label: 'Communications', count: 1}, + {key: 'insights', label: 'Insights'}, + ]; + + return ( +
+ +
+ {/* Header */} +
+
+
+
+ +
EVENTS / CHANGE THE WORLD INNOVATION WEEKEND
+
+
+

Change the World Innovation Weekend

+ +
+
+ Sat, Mar 14, 2026 + · + 9:30 AM – 6:00 PM + · + Biotechnology & Interdis Bldg +
+
+
+ + +
+
+
+ +
+ + +
+ {/* Countdown hero — combines T-minus + this-week framing */} +
+
+
EVENT STARTS IN
+
+
11
+
+
days
+
4 hrs · 26 min
+
+
+
+ {Array.from({length: 30}).map((_, i) => ( +
+ ))} +
+
+ Feb 13 · created + today + Mar 14 · live +
+
+
+
THIS WEEK · 4 ITEMS NEED YOU
+
+ {[ + {who: 'You', body: 'Send the second outreach email', due: 'by Tue', tone: 'urgent'}, + {who: 'You', body: 'Confirm catering count with vendor', due: 'by Wed', tone: 'urgent'}, + {who: 'Sam', body: 'Finalize judging rubric', due: 'by Thu', tone: 'normal'}, + {who: 'Devi', body: 'Book A/V equipment', due: 'by Fri', tone: 'normal'}, + ].map((t, i) => ( +
+
{t.who[0]}
+
{t.body}
+
{t.due}
+
+ ))} +
+
+
+ + {/* Three secondary panels: registration pace, outreach, ops */} +
+
+
+
Registration pace
+ 30 DAYS +
+
+
64
+
of 100 expected · 64%
+
+
+12 this week · on track to hit goal
+
+ +
+
goal · 100
+
+
+ +
+
Outreach
+ {[ + {label: 'Initial announcement', sub: 'Sent Feb 14 · 412 reached', done: true}, + {label: 'Mid-cycle reminder', sub: 'Sent Mar 1 · 38 new RSVPs', done: true}, + {label: 'Final push', sub: 'Suggested: Wed Mar 11', done: false, current: true}, + {label: 'Day-of confirmation', sub: 'Auto · Mar 14 6 AM', done: false}, + ].map((s, i) => ( +
+
+ {s.done && } +
+
+
{s.label}
+
{s.sub}
+
+
+ ))} +
+ +
+
Run-of-show readiness
+ {[ + {k: 'Schedule blocks', v: '12 / 12', good: true}, + {k: 'Speakers confirmed', v: '6 / 8', good: false}, + {k: 'Volunteer roles', v: '5 / 5', good: true}, + {k: 'Equipment booked', v: '3 / 5', good: false}, + {k: 'Venue walkthrough', v: 'Mar 12', good: true}, + ].map((r, i) => ( +
+ {r.k} + {r.v} +
+ ))} +
+
+
+
+
+
+ ); +} + +window.StatePreparing = StatePreparing; diff --git a/frontend/design/EventDashboard/Meridian/v4-live.jsx b/frontend/design/EventDashboard/Meridian/v4-live.jsx new file mode 100644 index 00000000..9251aaf0 --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v4-live.jsx @@ -0,0 +1,153 @@ +/* State 3 — LIVE. Day-of, event in progress. + Goals: + - Operations console. Big numbers. Fast actions. Almost no chrome. + - Live check-in pace, capacity meter, current agenda block. + - One-tap actions: send announcement, open scanner, page volunteers. */ + +function StateLive() { + const items = [ + {key: 'live', label: 'Live ops', active: true, live: true, count: 'NOW'}, + {key: 'checkin', label: 'Check-in', count: 96, live: true}, + {key: 'schedule', label: 'Schedule', count: '4/12'}, + {key: 'tasks', label: 'Tasks', count: 1, alert: true}, + {key: 'people', label: 'People', count: 177}, + {key: 'jobs', label: 'Jobs', count: 5}, + {key: 'comms', label: 'Communications', count: 0}, + ]; + + return ( +
+ +
+ {/* Slim live header */} +
+
+ + + + LIVE · DAY 1 + + Change the World Innovation Weekend + 11:24 AM · 4h 36m elapsed +
+
+ + +
+
+ +
+
+ +
+ +
+ {/* The single 'now' panel — current schedule block + actions */} +
+
+
HAPPENING NOW · ROOM A
+
Opening keynote — Dr. Patel
+
10:00 AM – 11:30 AM · ends in 6 minutes
+
+
+ + +
+
+ + {/* Big live numbers row */} +
+ {[ + {k: 'Checked in', v: '139', sub: 'of 177 registered', meter: 0.785, accent: 'primary'}, + {k: 'Capacity', v: '78%', sub: '139 / 200 max', meter: 0.78, accent: 'primary'}, + {k: 'On schedule', v: '+0:06', sub: 'running 6 min over', meter: 0.5, accent: 'warn'}, + {k: 'Issues', v: '1', sub: 'AV in Room B', meter: 1, accent: 'warn'}, + ].map((s, i) => ( +
+
{s.k}
+
+
{s.v}
+
+
{s.sub}
+
+
+
+
+ ))} +
+ + {/* Two columns: live check-in + agenda */} +
+
+
+
Check-in flow · last 60 min
+
+ +
+
+
+ +
+
+ {[ + {k: 'Peak rate', v: '14 / min', t: '9:42 AM'}, + {k: 'Now', v: '2 / min', t: 'live'}, + {k: 'No-shows', v: '38', t: '21.5%'}, + ].map((m, i) => ( +
+
{m.k.toUpperCase()}
+
{m.v}
+
{m.t}
+
+ ))} +
+
+ +
+
+
Up next
+ +
+
+ {[ + {time: '11:30 AM', title: 'Track kickoff — Climate', room: 'Room A', accent: true}, + {time: '11:30 AM', title: 'Track kickoff — Health', room: 'Room B'}, + {time: '12:00 PM', title: 'Lunch · catered', room: 'Atrium'}, + {time: '01:00 PM', title: 'Build sprint 1', room: 'All rooms'}, + {time: '03:00 PM', title: 'Mentor office hours', room: 'Lounge'}, + {time: '05:30 PM', title: 'Day 1 wrap', room: 'Room A'}, + ].map((s, i) => ( +
+
{s.time}
+
+
{s.title}
+
{s.room}
+
+ {s.accent && NEXT} +
+ ))} +
+
+
+
+
+
+
+ ); +} + +window.StateLive = StateLive; diff --git a/frontend/design/EventDashboard/Meridian/v5-concluded.jsx b/frontend/design/EventDashboard/Meridian/v5-concluded.jsx new file mode 100644 index 00000000..92c7f71a --- /dev/null +++ b/frontend/design/EventDashboard/Meridian/v5-concluded.jsx @@ -0,0 +1,159 @@ +/* State 4 — CONCLUDED. Post-mortem mode. + Goals: + - Pivot to retrospective: outcomes vs. expectations. + - Voices (feedback) and wrap-up tasks share the screen. + - The live-event chrome is fully retired. */ + +function StateConcluded() { + const items = [ + {key: 'overview', label: 'Retrospective', active: true}, + {key: 'feedback', label: 'Feedback', count: 42}, + {key: 'people', label: 'Attendees', count: 139}, + {key: 'tasks', label: 'Wrap-up', count: 3, alert: true}, + {key: 'insights', label: 'Insights'}, + {key: 'archive', label: 'Archive'}, + ]; + + return ( +
+ +
+
+
+
+
+ +
EVENTS / RETROSPECTIVE
+
+
+

Change the World Innovation Weekend

+ +
+
+ Sat, Mar 14, 2026 · 9:30 AM – 6:00 PM · Biotechnology & Interdis Bldg +
+
+
+ + +
+
+
+ +
+ + +
+ {/* One-line outcome statement, editorial */} +
+
+
OUTCOME
+
+ 177 builders showed up — 77% over your 100-attendee goal, with a 78.5% show-rate and 4.6 / 5 average rating. +
+
+ 3 follow-ups remain: feedback collection (24% response rate), prize disbursement, equipment return. +
+
+
+ +
+
+ + {/* Scoreboard — expected vs actual */} +
+ {[ + {k: 'Registrations', v: '177', expect: 'expected 100', delta: '+77%', tone: 'primary', explain: 'Final-week acceleration drove most of the overage.'}, + {k: 'Showed up', v: '139', expect: 'of 177 registered', delta: '78.5%', tone: 'primary', explain: 'Above the 65% campus average for free events.'}, + {k: 'Avg. rating', v: '4.6', expect: '/ 5 · 42 responses', delta: 'top quartile', tone: 'primary', explain: 'Highest praise: structure of the prize tracks.'}, + {k: 'NPS', v: '+58', expect: '24% response rate', delta: 'collect more', tone: 'warn', explain: 'Confidence interval is wide. Send a follow-up?'}, + ].map((s, i) => ( +
+
{s.k}
+
+
{s.v}
+
{s.delta}
+
+
{s.expect}
+
{s.explain}
+
+ ))} +
+ + {/* Voices + Wrap-up */} +
+
+
+

What attendees said

+ +
+
+ {[ + {pct: 71, label: 'Prize structure was motivating', tone: 'good'}, + {pct: 64, label: 'Mentor availability was excellent', tone: 'good'}, + {pct: 38, label: 'Wished for longer build time', tone: 'mixed'}, + {pct: 21, label: 'Lunch logistics were confusing', tone: 'bad'}, + ].map((th, i) => ( +
+
+ {th.label} + {th.pct}% +
+
+
+
+
+ ))} +
+
+ +
+
+

Wrap-up

+ 3 OPEN +
+ {[ + {tag: 'COLLECT', body: 'Send feedback follow-up to the 135 silent attendees.', cta: 'Send', primary: true}, + {tag: 'DISBURSE', body: '$5,000 prize awaiting confirmation for winning team.', cta: 'Confirm'}, + {tag: 'RETURN', body: 'Equipment return to facilities — 3 items outstanding.', cta: 'Mark done'}, + ].map((it, i) => ( +
+
{it.tag}
+
{it.body}
+ +
+ ))} +
+
+
+
+
+
+ ); +} + +window.StateConcluded = StateConcluded; diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index 435542fa..790b5ed1 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,17 +1,16 @@ - - - - - - + + + + + - - + + - - + + diff --git a/frontend/src/assets/Brand Image/BEACON.svg b/frontend/src/assets/Brand Image/BEACON.svg index 03870788..f645c8b6 100644 --- a/frontend/src/assets/Brand Image/BEACON.svg +++ b/frontend/src/assets/Brand Image/BEACON.svg @@ -1,13 +1,5 @@ - - - - - - - - - - - - + + + + diff --git a/frontend/src/assets/Brand Image/BEACON2.svg b/frontend/src/assets/Brand Image/BEACON2.svg new file mode 100644 index 00000000..03870788 --- /dev/null +++ b/frontend/src/assets/Brand Image/BEACON2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/Brand Image/Globe.svg b/frontend/src/assets/Brand Image/Globe.svg index aa852db1..e1684307 100644 --- a/frontend/src/assets/Brand Image/Globe.svg +++ b/frontend/src/assets/Brand Image/Globe.svg @@ -1,5 +1,11 @@ - - - - + + + + + + + + + + diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx index 2dcfac58..09ec838f 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx @@ -162,7 +162,7 @@ function OperatorHubMode() { const [confirmVariant, setConfirmVariant] = useState(null); const [typedPhrase, setTypedPhrase] = useState(''); const [phraseError, setPhraseError] = useState(''); - const { data: configData, refetch, loading } = useFetch('/org-management/config', { + const { data: configData, refetch, loading} = useFetch('/org-management/config', { cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, }); const config = configData?.data; From c77f31a2599338e9a4596878a3fb5a980989a10a Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:04 +0900 Subject: [PATCH 06/39] MER-192: Extend TenantConfig schema for dynamic pivot tenant provisioning. --- backend/schemas/tenantConfig.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/schemas/tenantConfig.js b/backend/schemas/tenantConfig.js index f85a0d44..f6e58111 100644 --- a/backend/schemas/tenantConfig.js +++ b/backend/schemas/tenantConfig.js @@ -12,6 +12,20 @@ const tenantEntrySchema = new mongoose.Schema( default: 'active', }, statusMessage: { type: String, default: '', trim: true, maxlength: 240 }, + tenantType: { + type: String, + enum: ['campus', 'pivot'], + default: 'campus', + }, + pivotPilot: { type: Boolean, default: false }, + mongoUri: { type: String, default: null, trim: true }, + mongoDatabaseName: { type: String, default: null, trim: true, lowercase: true }, + pivotCatalogOrgId: { type: String, default: null, trim: true }, + provisioningConfirmations: { + dns: { type: Boolean, default: false }, + cors: { type: Boolean, default: false }, + pickerVerified: { type: Boolean, default: false }, + }, }, { _id: false } ); From ad78418476b4b134a18e407661805185143d69ab Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:10 +0900 Subject: [PATCH 07/39] MER-192: Add shared default tenant definitions and merge helpers. --- backend/constants/defaultTenants.js | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/constants/defaultTenants.js diff --git a/backend/constants/defaultTenants.js b/backend/constants/defaultTenants.js new file mode 100644 index 00000000..f9d44c51 --- /dev/null +++ b/backend/constants/defaultTenants.js @@ -0,0 +1,75 @@ +const TENANT_STATUSES = new Set(['active', 'coming_soon', 'maintenance', 'hidden']); +const TENANT_TYPES = new Set(['campus', 'pivot']); + +const DEFAULT_TENANTS = [ + { + tenantKey: 'rpi', + name: 'Rensselaer Polytechnic Institute', + subdomain: 'rpi', + location: 'Troy, NY', + status: 'active', + statusMessage: '', + tenantType: 'campus', + pivotPilot: false, + }, + { + tenantKey: 'tvcog', + name: 'Center of Gravity', + subdomain: 'tvcog', + location: 'Troy, NY', + status: 'active', + statusMessage: '', + tenantType: 'campus', + pivotPilot: false, + }, +]; + +function normalizeTenantRow(row = {}) { + const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); + if (!tenantKey || tenantKey === 'www') return null; + + const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active'; + const tenantType = TENANT_TYPES.has(row?.tenantType) ? row.tenantType : 'campus'; + + return { + tenantKey, + name: String(row?.name || tenantKey).trim(), + subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(), + location: String(row?.location || '').trim(), + status, + statusMessage: String(row?.statusMessage || '').trim().slice(0, 240), + tenantType, + pivotPilot: row?.pivotPilot === true || tenantType === 'pivot', + mongoUri: String(row?.mongoUri || '').trim() || undefined, + mongoDatabaseName: String(row?.mongoDatabaseName || '').trim() || undefined, + pivotCatalogOrgId: String(row?.pivotCatalogOrgId || '').trim() || undefined, + provisioningConfirmations: { + dns: row?.provisioningConfirmations?.dns === true, + cors: row?.provisioningConfirmations?.cors === true, + pickerVerified: row?.provisioningConfirmations?.pickerVerified === true, + }, + }; +} + +function normalizeTenantRows(rows = []) { + return rows.map(normalizeTenantRow).filter(Boolean); +} + +function mergeTenantRows(baseRows = [], overrideRows = []) { + const merged = new Map(); + normalizeTenantRows(baseRows).forEach((row) => merged.set(row.tenantKey, row)); + normalizeTenantRows(overrideRows).forEach((row) => { + const base = merged.get(row.tenantKey) || {}; + merged.set(row.tenantKey, { ...base, ...row }); + }); + return Array.from(merged.values()); +} + +module.exports = { + TENANT_STATUSES, + TENANT_TYPES, + DEFAULT_TENANTS, + normalizeTenantRow, + normalizeTenantRows, + mergeTenantRows, +}; From b8e7e8e18b97e5db5c7e3943f9cf9e487034a780 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:10 +0900 Subject: [PATCH 08/39] MER-192: Add tenant config service and dynamic per-tenant Mongo routing. --- backend/connectionsManager.js | 140 ++++++---- backend/services/tenantConfigService.js | 343 ++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 backend/services/tenantConfigService.js diff --git a/backend/connectionsManager.js b/backend/connectionsManager.js index bc5a77c3..5ee813f3 100644 --- a/backend/connectionsManager.js +++ b/backend/connectionsManager.js @@ -1,62 +1,108 @@ const mongoose = require('mongoose'); -//load env require('dotenv').config(); -// Store active connections in a Map const connectionPool = new Map(); - -// Single global/platform DB connection (reused) let globalConnection = null; +let tenantUriCache = new Map(); + +const LEGACY_SCHOOL_DB_MAP = { + rpi: process.env.MONGO_URI_RPI, + tvcog: process.env.MONGO_URI_TVCOG, +}; + +function getPlatformDbUri() { + return ( + process.env.MONGO_URI_PLATFORM || + process.env.MONGO_URI_GLOBAL || + (process.env.MONGO_URI_RPI + ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') + : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace( + /\/([^/]+)(\?|$)/, + '/meridian_platform$2' + )) + ); +} + +function getBaseMongoUri() { + return ( + process.env.MONGODB_URI || + process.env.DEFAULT_MONGO_URI || + process.env.MONGO_URI_RPI || + null + ); +} + +function deriveMongoUriForTenant(tenantKey, tenantRow = {}) { + if (tenantKey === 'www') return getPlatformDbUri(); + + const envKey = `MONGO_URI_${String(tenantKey).toUpperCase()}`; + if (process.env[envKey]) return process.env[envKey]; + + if (LEGACY_SCHOOL_DB_MAP[tenantKey]) return LEGACY_SCHOOL_DB_MAP[tenantKey]; + + if (tenantRow?.mongoUri) return tenantRow.mongoUri; + + if (tenantUriCache.has(tenantKey)) return tenantUriCache.get(tenantKey); + + const dbName = tenantRow?.mongoDatabaseName || tenantKey; + const base = getBaseMongoUri(); + if (!base) return null; + return base.replace(/\/([^/?]+)(\?|$)/, `/${dbName}$2`); +} + +function setTenantUriCache(entries = {}) { + tenantUriCache = new Map(Object.entries(entries).filter(([, uri]) => Boolean(uri))); +} + +function getRegisteredTenantKeys() { + const keys = new Set(['rpi', 'tvcog', 'www']); + tenantUriCache.forEach((_, key) => keys.add(key)); + return Array.from(keys); +} const connectToDatabase = async (school) => { - if (!connectionPool.has(school)) { - const dbUri = getDbUriForSchool(school); // A function to get the correct URI - const connection = mongoose.createConnection(dbUri, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - - connectionPool.set(school, connection); - console.log(`Created new connection for school: ${school}`); + if (!connectionPool.has(school)) { + const dbUri = deriveMongoUriForTenant(school); + if (!dbUri) { + throw new Error(`No MongoDB URI configured for tenant "${school}"`); } - return connectionPool.get(school); + const connection = mongoose.createConnection(dbUri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + connectionPool.set(school, connection); + console.log(`Created new connection for school: ${school}`); + } + return connectionPool.get(school); }; -/** - * Connect to the platform/global DB for cross-tenant data (GlobalUser, PlatformRole, TenantMembership). - * Uses MONGO_URI_PLATFORM or falls back to same cluster with different db name (e.g. meridian_platform). - */ const connectToGlobalDatabase = async () => { - if (!globalConnection) { - const uri = process.env.MONGO_URI_PLATFORM || process.env.MONGO_URI_GLOBAL || - (process.env.MONGO_URI_RPI - ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') - : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2')); - globalConnection = mongoose.createConnection(uri, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - console.log('Created global/platform database connection'); - } - return globalConnection; + if (!globalConnection) { + const uri = getPlatformDbUri(); + globalConnection = mongoose.createConnection(uri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + console.log('Created global/platform database connection'); + } + return globalConnection; }; -/** Platform/global DB URI for www (landing) - never use a tenant DB for base URL */ -const getPlatformDbUri = () => - process.env.MONGO_URI_PLATFORM || process.env.MONGO_URI_GLOBAL || - (process.env.MONGO_URI_RPI - ? process.env.MONGO_URI_RPI.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2') - : (process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI)?.replace(/\/([^/]+)(\?|$)/, '/meridian_platform$2')); +function invalidateTenantConnection(tenantKey) { + const conn = connectionPool.get(tenantKey); + if (conn) { + conn.close().catch(() => {}); + connectionPool.delete(tenantKey); + } +} -const getDbUriForSchool = (school) => { - if (school === 'www') { - return getPlatformDbUri(); - } - const schoolDbMap = { - rpi: process.env.MONGO_URI_RPI, - tvcog: process.env.MONGO_URI_TVCOG, - }; - return schoolDbMap[school] || process.env.MONGODB_URI || process.env.DEFAULT_MONGO_URI; +module.exports = { + connectToDatabase, + connectToGlobalDatabase, + getPlatformDbUri, + deriveMongoUriForTenant, + setTenantUriCache, + getRegisteredTenantKeys, + invalidateTenantConnection, }; - -module.exports = { connectToDatabase, connectToGlobalDatabase }; diff --git a/backend/services/tenantConfigService.js b/backend/services/tenantConfigService.js new file mode 100644 index 00000000..33bb49b2 --- /dev/null +++ b/backend/services/tenantConfigService.js @@ -0,0 +1,343 @@ +const { randomBytes } = require('crypto'); +const bcrypt = require('bcrypt'); +const getGlobalModels = require('./getGlobalModelService'); +const { + DEFAULT_TENANTS, + normalizeTenantRow, + normalizeTenantRows, + mergeTenantRows, +} = require('../constants/defaultTenants'); +const { + connectToDatabase, + setTenantUriCache, + deriveMongoUriForTenant, +} = require('../connectionsManager'); + +const CONFIG_KEY = 'default'; +const BASE_DOMAIN = process.env.MERIDIAN_BASE_DOMAIN || 'meridian.study'; + +const MANUAL_STEP_IDS = { + DNS: 'dns', + CORS: 'cors', + MONGO_ENV: 'mongo_env', + PIVOT_CATALOG: 'pivot_catalog', + VERIFY_PICKER: 'verify_picker', +}; + +function buildManualSteps(tenant, context = {}) { + const subdomain = tenant.subdomain || tenant.tenantKey; + const origin = `https://${subdomain}.${BASE_DOMAIN}`; + const isPivot = tenant.pivotPilot === true || tenant.tenantType === 'pivot'; + const envVarName = `MONGO_URI_${tenant.tenantKey.toUpperCase()}`; + const confirmations = tenant.provisioningConfirmations || {}; + + return [ + { + id: MANUAL_STEP_IDS.DNS, + title: 'DNS subdomain', + description: `Point ${subdomain}.${BASE_DOMAIN} to your app load balancer / hosting (CNAME or A record).`, + automated: false, + completed: confirmations.dns === true, + command: `CNAME ${subdomain}.${BASE_DOMAIN} → `, + }, + { + id: MANUAL_STEP_IDS.CORS, + title: 'Production CORS allowlist', + description: `Add ${origin} to backend CORS origins in deployment config (app.js or env). Required for browser API calls from the new subdomain.`, + automated: false, + completed: confirmations.cors === true, + command: origin, + }, + { + id: MANUAL_STEP_IDS.MONGO_ENV, + title: 'Optional deploy env var', + description: `For deployments that prefer env-based DB routing, set ${envVarName}. Dynamic mongoUri in TenantConfig takes precedence when env is unset.`, + automated: false, + completed: Boolean(process.env[envVarName]) || Boolean(tenant.mongoUri), + command: `${envVarName}=mongodb://.../${tenant.mongoDatabaseName || tenant.tenantKey}`, + }, + { + id: MANUAL_STEP_IDS.PIVOT_CATALOG, + title: 'Pivot Catalog org', + description: isPivot + ? 'Internal org used as technical host for imported Pivot events. Hidden from org discovery.' + : 'Not required for campus tenants.', + automated: isPivot, + completed: !isPivot || Boolean(tenant.pivotCatalogOrgId), + optional: !isPivot, + orgId: tenant.pivotCatalogOrgId || null, + }, + { + id: MANUAL_STEP_IDS.VERIFY_PICKER, + title: 'Verify school picker', + description: + 'Confirm the tenant appears correctly on /select-school (status can still be coming soon while you verify). Activate the subdomain when you are ready to go live.', + automated: false, + completed: confirmations.pickerVerified === true, + requiresActiveStatus: tenant.status !== 'active', + }, + ]; +} + +function toStoredTenantRow(tenant) { + const isDefault = DEFAULT_TENANTS.some((base) => base.tenantKey === tenant.tenantKey); + const payload = { + tenantKey: tenant.tenantKey, + name: tenant.name, + subdomain: tenant.subdomain, + location: tenant.location, + status: tenant.status, + statusMessage: tenant.statusMessage, + tenantType: tenant.tenantType, + pivotPilot: tenant.pivotPilot, + mongoUri: tenant.mongoUri, + mongoDatabaseName: tenant.mongoDatabaseName, + pivotCatalogOrgId: tenant.pivotCatalogOrgId, + provisioningConfirmations: tenant.provisioningConfirmations, + }; + if (isDefault) { + const base = DEFAULT_TENANTS.find((row) => row.tenantKey === tenant.tenantKey); + const override = { tenantKey: tenant.tenantKey }; + Object.keys(payload).forEach((key) => { + if (key === 'tenantKey') return; + if (JSON.stringify(payload[key]) !== JSON.stringify(base?.[key])) { + override[key] = payload[key]; + } + }); + return Object.keys(override).length > 1 ? override : null; + } + return payload; +} + +async function getStoredTenantRows(req) { + const doc = await loadTenantConfigDoc(req); + return doc?.tenants || []; +} + +async function upsertStoredTenantRow(req, tenant, updatedBy = null) { + const storedRow = toStoredTenantRow(tenant); + const stored = await getStoredTenantRows(req); + const without = stored.filter((row) => row.tenantKey !== tenant.tenantKey); + const next = storedRow ? [...without, storedRow] : without; + await saveTenantRows(req, next, updatedBy); + return getTenantByKey(req, tenant.tenantKey); +} + +async function loadTenantConfigDoc(req) { + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + return TenantConfig.findOne({ configKey: CONFIG_KEY }).lean(); +} + +async function getMergedTenants(req) { + const doc = await loadTenantConfigDoc(req); + return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []); +} + +async function getTenantByKey(req, tenantKey) { + const tenants = await getMergedTenants(req); + return tenants.find((row) => row.tenantKey === tenantKey) || null; +} + +async function syncTenantUriCache(req) { + const tenants = await getMergedTenants(req); + const cache = {}; + tenants.forEach((tenant) => { + const uri = deriveMongoUriForTenant(tenant.tenantKey, tenant); + if (uri) cache[tenant.tenantKey] = uri; + }); + setTenantUriCache(cache); + return cache; +} + +async function saveTenantRows(req, rows, updatedBy = null) { + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const normalized = normalizeTenantRows(rows); + const doc = await TenantConfig.findOneAndUpdate( + { configKey: CONFIG_KEY }, + { $set: { tenants: normalized, updatedBy } }, + { new: true, upsert: true, setDefaultsOnInsert: true } + ).lean(); + await syncTenantUriCache(req); + return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []); +} + +function isMongoPingOk(pingResult) { + if (!pingResult || typeof pingResult !== 'object') return false; + return pingResult.ok === 1 || pingResult.ok === true; +} + +async function pingTenantDatabase(tenantKey, tenantRow = null) { + const started = performance.now(); + try { + const db = await connectToDatabase(tenantKey); + const ping = await db.db.admin().ping(); + const latencyMs = Number((performance.now() - started).toFixed(2)); + return { + ok: isMongoPingOk(ping), + latencyMs, + databaseName: db.name, + }; + } catch (error) { + return { + ok: false, + latencyMs: null, + error: error.message, + databaseName: tenantRow?.mongoDatabaseName || tenantKey, + }; + } +} + +async function ensurePivotSystemUser(reqLike) { + const getModels = require('./getModelService'); + const { User } = getModels(reqLike, 'User'); + const email = `pivot-catalog@${reqLike.school}.internal`; + let user = await User.findOne({ email }); + if (user) return user; + + const randomPassword = randomBytes(32).toString('hex'); + const hashed = await bcrypt.hash(randomPassword, 10); + user = await User.create({ + email, + name: 'Pivot Catalog System', + username: `pivot_cat_${reqLike.school}`.slice(0, 24), + password: hashed, + roles: ['admin'], + onboarded: true, + }); + return user; +} + +async function provisionPivotCatalogOrg(req, tenantKey, tenantRow) { + const getModels = require('./getModelService'); + const db = await connectToDatabase(tenantKey); + const reqLike = { db, school: tenantKey }; + const { Org, OrgMember } = getModels(reqLike, 'Org', 'OrgMember', 'User'); + const owner = await ensurePivotSystemUser(reqLike); + + const catalogName = `Pivot Catalog — ${tenantRow.location || tenantRow.name || tenantKey}`; + if (tenantRow.pivotCatalogOrgId) { + const existing = await Org.findById(tenantRow.pivotCatalogOrgId); + if (existing && existing.isDeleted !== true) { + return { orgId: String(existing._id), orgName: existing.org_name, created: false }; + } + } + + let org = await Org.findOne({ org_name: catalogName, isDeleted: { $ne: true } }); + let created = false; + if (!org) { + org = await Org.create({ + org_name: catalogName, + org_description: 'Internal technical host for Pivot catalog events. Not shown in Pivot consumer UI.', + org_profile_image: '/Logo.svg', + owner: owner._id, + unlisted: true, + positions: [ + { + name: 'owner', + displayName: 'Owner', + permissions: ['all'], + isDefault: false, + canManageMembers: true, + canManageRoles: true, + canManageEvents: true, + canViewAnalytics: true, + order: 0, + color: '#dc2626', + }, + { + name: 'member', + displayName: 'Member', + permissions: ['view_events'], + isDefault: true, + canManageMembers: false, + canManageRoles: false, + canManageEvents: false, + canViewAnalytics: false, + order: 1, + color: '#6b7280', + }, + ], + }); + created = true; + } + + const membership = await OrgMember.findOne({ org_id: org._id, user_id: owner._id }); + if (!membership) { + await OrgMember.create({ + org_id: org._id, + user_id: owner._id, + role: 'owner', + roles: ['owner'], + status: 'active', + }); + } + + return { orgId: String(org._id), orgName: org.org_name, created }; +} + +function serializeTenantForAdmin(tenant, extras = {}) { + const { health, manualStepContext, ...rest } = extras; + const manualSteps = buildManualSteps(tenant, manualStepContext || {}); + return { + ...tenant, + mongoUriConfigured: Boolean(tenant.mongoUri || process.env[`MONGO_URI_${tenant.tenantKey.toUpperCase()}`]), + subdomainUrl: `https://${tenant.subdomain || tenant.tenantKey}.${BASE_DOMAIN}`, + health: health || null, + manualSteps, + provisioningComplete: manualSteps.filter((step) => !step.optional).every((step) => step.completed), + ...rest, + }; +} + +function validateNewTenantPayload(body = {}) { + const tenantKey = String(body.tenantKey || '').trim().toLowerCase(); + if (!/^[a-z][a-z0-9_-]{1,31}$/.test(tenantKey)) { + return { error: 'tenantKey must be 2–32 chars, start with a letter, lowercase alphanumeric/underscore/hyphen.' }; + } + if (tenantKey === 'www') return { error: 'tenantKey "www" is reserved.' }; + if (!String(body.name || '').trim()) return { error: 'name is required.' }; + if (!String(body.location || '').trim()) return { error: 'location is required (city display name).' }; + + const tenantType = body.tenantType === 'pivot' ? 'pivot' : 'campus'; + const mongoDatabaseName = String(body.mongoDatabaseName || tenantKey).trim().toLowerCase(); + const mongoUri = String(body.mongoUri || '').trim() || deriveMongoUriForTenant(tenantKey, { mongoDatabaseName }); + + if (!mongoUri) { + return { error: 'Could not derive mongoUri. Provide mongoUri or set DEFAULT_MONGO_URI / MONGO_URI_RPI.' }; + } + + return { + row: normalizeTenantRow({ + tenantKey, + name: body.name, + subdomain: body.subdomain || tenantKey, + location: body.location, + status: body.status || 'coming_soon', + statusMessage: body.statusMessage || '', + tenantType, + pivotPilot: tenantType === 'pivot' || body.pivotPilot === true, + mongoUri, + mongoDatabaseName, + }), + }; +} + +module.exports = { + CONFIG_KEY, + BASE_DOMAIN, + MANUAL_STEP_IDS, + buildManualSteps, + loadTenantConfigDoc, + getMergedTenants, + getTenantByKey, + syncTenantUriCache, + saveTenantRows, + pingTenantDatabase, + provisionPivotCatalogOrg, + serializeTenantForAdmin, + validateNewTenantPayload, + upsertStoredTenantRow, + DEFAULT_TENANTS, + mergeTenantRows, + normalizeTenantRows, +}; From 112514ab8b48ea1c2bf7eea50a99c22fa0f82af1 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:10 +0900 Subject: [PATCH 09/39] MER-192: Add platform admin APIs for dynamic tenant provisioning. --- backend/middlewares/requirePlatformAdmin.js | 29 +++ backend/routes/platformTenantRoutes.js | 213 ++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 backend/middlewares/requirePlatformAdmin.js create mode 100644 backend/routes/platformTenantRoutes.js diff --git a/backend/middlewares/requirePlatformAdmin.js b/backend/middlewares/requirePlatformAdmin.js new file mode 100644 index 00000000..ffdbf7ed --- /dev/null +++ b/backend/middlewares/requirePlatformAdmin.js @@ -0,0 +1,29 @@ +const getGlobalModels = require('../services/getGlobalModelService'); + +/** + * Require platform_admin or root. Use after verifyToken. + */ +async function requirePlatformAdmin(req, res, next) { + if (!req.user) { + return res.status(401).json({ success: false, message: 'Authentication required' }); + } + + const tokenRoles = req.user.platformRoles || []; + let allowed = tokenRoles.includes('platform_admin') || tokenRoles.includes('root'); + + if (!allowed && req.user.globalUserId) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + if (pr?.roles?.includes('platform_admin') || pr?.roles?.includes('root')) { + allowed = true; + } + } + + if (!allowed) { + return res.status(403).json({ success: false, message: 'Platform admin required.' }); + } + + return next(); +} + +module.exports = { requirePlatformAdmin }; diff --git a/backend/routes/platformTenantRoutes.js b/backend/routes/platformTenantRoutes.js new file mode 100644 index 00000000..4e8ea97e --- /dev/null +++ b/backend/routes/platformTenantRoutes.js @@ -0,0 +1,213 @@ +const express = require('express'); +const { verifyToken } = require('../middlewares/verifyToken'); +const { requirePlatformAdmin } = require('../middlewares/requirePlatformAdmin'); +const { + getMergedTenants, + getTenantByKey, + pingTenantDatabase, + provisionPivotCatalogOrg, + serializeTenantForAdmin, + validateNewTenantPayload, + upsertStoredTenantRow, + syncTenantUriCache, +} = require('../services/tenantConfigService'); +const { invalidateTenantConnection } = require('../connectionsManager'); + +const router = express.Router(); + +async function listTenantsWithHealth(req) { + const tenants = await getMergedTenants(req); + return Promise.all( + tenants.map(async (tenant) => { + const health = await pingTenantDatabase(tenant.tenantKey, tenant); + return serializeTenantForAdmin(tenant, { health }); + }) + ); +} + +router.get('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenants = await listTenantsWithHealth(req); + res.json({ success: true, data: { tenants } }); + } catch (err) { + console.error('GET /admin/platform/tenants failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.get('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + const health = await pingTenantDatabase(tenantKey, tenant); + res.json({ success: true, data: serializeTenantForAdmin(tenant, { health }) }); + } catch (err) { + console.error('GET /admin/platform/tenants/:tenantKey failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const validation = validateNewTenantPayload(req.body); + if (validation.error) { + return res.status(400).json({ success: false, message: validation.error }); + } + + const existing = await getTenantByKey(req, validation.row.tenantKey); + if (existing) { + return res.status(409).json({ + success: false, + message: `Tenant "${validation.row.tenantKey}" already exists.`, + code: 'TENANT_EXISTS', + }); + } + + const updatedBy = req.user.globalUserId || req.user.userId || null; + invalidateTenantConnection(validation.row.tenantKey); + + let saved = await upsertStoredTenantRow(req, validation.row, updatedBy); + let health = await pingTenantDatabase(saved.tenantKey, saved); + let pivotCatalog = null; + + if (saved.pivotPilot && health.ok) { + try { + pivotCatalog = await provisionPivotCatalogOrg(req, saved.tenantKey, saved); + saved = await upsertStoredTenantRow( + req, + { ...saved, pivotCatalogOrgId: pivotCatalog.orgId }, + updatedBy + ); + } catch (catalogErr) { + console.warn('Pivot catalog auto-provision failed:', catalogErr.message); + } + } + + health = await pingTenantDatabase(saved.tenantKey, saved); + res.status(201).json({ + success: true, + data: serializeTenantForAdmin(saved, { health, pivotCatalog }), + }); + } catch (err) { + console.error('POST /admin/platform/tenants failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const existing = await getTenantByKey(req, tenantKey); + if (!existing) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + + const confirmations = { + ...(existing.provisioningConfirmations || {}), + ...(req.body.provisioningConfirmations || {}), + }; + + const updated = { + ...existing, + ...(req.body.name !== undefined ? { name: req.body.name } : {}), + ...(req.body.subdomain !== undefined ? { subdomain: req.body.subdomain } : {}), + ...(req.body.location !== undefined ? { location: req.body.location } : {}), + ...(req.body.status !== undefined ? { status: req.body.status } : {}), + ...(req.body.statusMessage !== undefined ? { statusMessage: req.body.statusMessage } : {}), + ...(req.body.tenantType !== undefined ? { tenantType: req.body.tenantType } : {}), + ...(req.body.pivotPilot !== undefined ? { pivotPilot: req.body.pivotPilot } : {}), + ...(req.body.mongoUri !== undefined ? { mongoUri: req.body.mongoUri } : {}), + ...(req.body.mongoDatabaseName !== undefined ? { mongoDatabaseName: req.body.mongoDatabaseName } : {}), + provisioningConfirmations: confirmations, + }; + + const mergedPreview = (await getMergedTenants(req)).map((row) => + row.tenantKey === tenantKey ? updated : row + ); + const activeCount = mergedPreview.filter((t) => t.status === 'active').length; + if (activeCount < 1) { + return res.status(400).json({ + success: false, + message: 'At least one tenant must remain active.', + code: 'AT_LEAST_ONE_ACTIVE_TENANT_REQUIRED', + }); + } + + const updatedBy = req.user.globalUserId || req.user.userId || null; + invalidateTenantConnection(tenantKey); + const saved = await upsertStoredTenantRow(req, updated, updatedBy); + const health = await pingTenantDatabase(tenantKey, saved); + + res.json({ success: true, data: serializeTenantForAdmin(saved, { health }) }); + } catch (err) { + console.error('PUT /admin/platform/tenants/:tenantKey failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants/:tenantKey/health-check', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + const health = await pingTenantDatabase(tenantKey, tenant); + res.json({ success: true, data: serializeTenantForAdmin(tenant, { health }) }); + } catch (err) { + console.error('POST health-check failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants/:tenantKey/provision-pivot-catalog', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + const tenant = await getTenantByKey(req, tenantKey); + if (!tenant) { + return res.status(404).json({ success: false, message: 'Tenant not found.' }); + } + + const health = await pingTenantDatabase(tenantKey, tenant); + if (!health.ok) { + return res.status(400).json({ + success: false, + message: 'Database connection must be healthy before provisioning Pivot Catalog org.', + data: { health }, + }); + } + + const pivotCatalog = await provisionPivotCatalogOrg(req, tenantKey, tenant); + const updatedBy = req.user.globalUserId || req.user.userId || null; + const saved = await upsertStoredTenantRow( + req, + { ...tenant, pivotCatalogOrgId: pivotCatalog.orgId }, + updatedBy + ); + + res.json({ + success: true, + data: { + ...serializeTenantForAdmin(saved, { health }), + pivotCatalog, + }, + }); + } catch (err) { + console.error('POST provision-pivot-catalog failed:', err); + res.status(500).json({ success: false, message: err.message }); + } +}); + +router.post('/admin/platform/tenants/sync-cache', verifyToken, requirePlatformAdmin, async (req, res) => { + try { + const cache = await syncTenantUriCache(req); + res.json({ success: true, data: { tenantKeys: Object.keys(cache) } }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +module.exports = router; From bcc68efc765d79be4099d081280dd0d57d6e69db Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:11 +0900 Subject: [PATCH 10/39] MER-192: Preserve dynamically provisioned tenants on legacy tenant-config updates. --- backend/routes/adminRoutes.js | 85 ++++++++++------------------------- 1 file changed, 23 insertions(+), 62 deletions(-) diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js index da6f7845..8afcbaf9 100644 --- a/backend/routes/adminRoutes.js +++ b/backend/routes/adminRoutes.js @@ -9,58 +9,17 @@ const { connectToDatabase } = require('../connectionsManager'); const { getConnections, disconnectSocket, disconnectAll } = require('../socket'); const { createSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); +const getGlobalModels = require('../services/getGlobalModelService'); +const { + DEFAULT_TENANTS, + normalizeTenantRows, + mergeTenantRows, +} = require('../constants/defaultTenants'); const ACCESS_TOKEN_EXPIRY = '1m'; const REFRESH_TOKEN_EXPIRY = '30d'; const ACCESS_TOKEN_EXPIRY_MS = 60 * 1000; const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; -const TENANT_STATUSES = new Set(['active', 'coming_soon', 'maintenance', 'hidden']); -const DEFAULT_TENANTS = [ - { - tenantKey: 'rpi', - name: 'Rensselaer Polytechnic Institute', - subdomain: 'rpi', - location: 'Troy, NY', - status: 'active', - statusMessage: '', - }, - { - tenantKey: 'tvcog', - name: 'Center of Gravity', - subdomain: 'tvcog', - location: 'Troy, NY', - status: 'active', - statusMessage: '', - }, -]; - -function normalizeTenantRows(rows = []) { - return rows - .map((row) => { - const tenantKey = String(row?.tenantKey || '').trim().toLowerCase(); - if (!tenantKey) return null; - const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active'; - return { - tenantKey, - name: String(row?.name || tenantKey).trim(), - subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(), - location: String(row?.location || '').trim(), - status, - statusMessage: String(row?.statusMessage || '').trim().slice(0, 240), - }; - }) - .filter(Boolean); -} - -function mergeTenantRows(baseRows = [], overrideRows = []) { - const merged = new Map(); - normalizeTenantRows(baseRows).forEach((row) => merged.set(row.tenantKey, row)); - normalizeTenantRows(overrideRows).forEach((row) => { - const base = merged.get(row.tenantKey) || {}; - merged.set(row.tenantKey, { ...base, ...row }); - }); - return Array.from(merged.values()); -} router.get('/health', async (req, res) => { try { @@ -281,8 +240,6 @@ router.get('/admin/user/:userId/analytics', verifyToken, requireAdmin, async (re } }); -const getGlobalModels = require('../services/getGlobalModelService'); - /** * GET /admin/platform-admins – list platform admins (GlobalUsers with platform_admin role) */ @@ -452,18 +409,23 @@ router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) = } const incoming = normalizeTenantRows(req.body.tenants); const incomingByKey = new Map(incoming.map((row) => [row.tenantKey, row])); - const nextTenants = mergeTenantRows( - DEFAULT_TENANTS, - DEFAULT_TENANTS.map((row) => { - const update = incomingByKey.get(row.tenantKey); - if (!update) return row; - return { - ...row, - status: update.status || row.status, - statusMessage: update.statusMessage || '', - }; - }) + const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); + const existingDoc = await TenantConfig.findOne({ configKey: 'default' }).lean(); + const storedRows = existingDoc?.tenants || []; + const storedDynamic = storedRows.filter( + (row) => !DEFAULT_TENANTS.some((base) => base.tenantKey === row.tenantKey) ); + const defaultOverrides = DEFAULT_TENANTS.map((row) => { + const update = incomingByKey.get(row.tenantKey); + if (!update) return null; + return { + tenantKey: row.tenantKey, + status: update.status || row.status, + statusMessage: update.statusMessage || '', + }; + }).filter(Boolean); + const nextStored = [...storedDynamic, ...defaultOverrides]; + const nextTenants = mergeTenantRows(DEFAULT_TENANTS, nextStored); const activeCount = nextTenants.filter((tenant) => tenant.status === 'active').length; if (activeCount < 1) { return res.status(400).json({ @@ -473,11 +435,10 @@ router.put('/admin/tenant-config', verifyToken, requireAdmin, async (req, res) = }); } - const { TenantConfig } = getGlobalModels(req, 'TenantConfig'); const updatedBy = req.user.globalUserId || req.user.userId || null; const doc = await TenantConfig.findOneAndUpdate( { configKey: 'default' }, - { $set: { tenants: nextTenants, updatedBy } }, + { $set: { tenants: nextStored, updatedBy } }, { new: true, upsert: true, setDefaultsOnInsert: true } ).lean(); From 31484474efa23930346f91d1f6ab0502e3d0c742 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:16 +0900 Subject: [PATCH 11/39] MER-192: Fix global auth and session resolution for platform admin users. --- backend/routes/authRoutes.js | 23 +++++++++++++++++++---- backend/services/authGlobalService.js | 5 ++++- backend/utilities/sessionUtils.js | 11 +++++------ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 4134c0f9..8cac8c20 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -489,12 +489,22 @@ router.get('/validate-token', verifyToken, async (req, res) => { // On www we only return communities (no tenant DB); frontend uses this to redirect to tenant. if (req.school === 'www') { if (!req.user || !req.user.globalUserId) { - return res.json({ success: true, message: 'Token is valid', data: { user: null, communities: [] } }); + return res.json({ success: true, message: 'Token is valid', data: { user: null, communities: [], platformRoles: req.user?.platformRoles || [] } }); } const { TenantMembership } = getGlobalModels(req, 'TenantMembership'); const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean(); const communities = memberships.map(m => m.tenantKey); - return res.json({ success: true, message: 'Token is valid', data: { user: null, communities } }); + let platformRoles = req.user.platformRoles || []; + if (!platformRoles.length) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + platformRoles = pr?.roles || []; + } + return res.json({ + success: true, + message: 'Token is valid', + data: { user: null, communities, platformRoles }, + }); } const { User, Friendship } = getModels(req, 'User', 'Friendship'); @@ -565,12 +575,17 @@ router.get('/validate-token', verifyToken, async (req, res) => { const data = { user: user, friendRequests: friendRequests, - pendingOrgInvites: pendingOrgInvites + pendingOrgInvites: pendingOrgInvites, + platformRoles: req.user.platformRoles || [], }; if (req.user.globalUserId) { - const { TenantMembership } = getGlobalModels(req, 'TenantMembership'); + const { TenantMembership, PlatformRole } = getGlobalModels(req, 'TenantMembership', 'PlatformRole'); const memberships = await TenantMembership.find({ globalUserId: req.user.globalUserId, status: 'active' }).lean(); data.communities = memberships.map(m => m.tenantKey); + if (!data.platformRoles.length) { + const pr = await PlatformRole.findOne({ globalUserId: req.user.globalUserId }).lean(); + data.platformRoles = pr?.roles || []; + } } res.json({ success: true, diff --git a/backend/services/authGlobalService.js b/backend/services/authGlobalService.js index 613e6d33..068c39d7 100644 --- a/backend/services/authGlobalService.js +++ b/backend/services/authGlobalService.js @@ -4,11 +4,14 @@ */ const jwt = require('jsonwebtoken'); const { randomUUID } = require('crypto'); -const getModels = require('./getModelService'); const getGlobalModels = require('./getGlobalModelService'); const { createGlobalSession } = require('../utilities/sessionUtils'); const { getCookieDomain } = require('../utilities/cookieUtils'); +function getModels(req, ...names) { + return require('./getModelService')(req, ...names); +} + const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || '15m'; const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '30d'; const REFRESH_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; diff --git a/backend/utilities/sessionUtils.js b/backend/utilities/sessionUtils.js index b0290e77..a23c8fe1 100644 --- a/backend/utilities/sessionUtils.js +++ b/backend/utilities/sessionUtils.js @@ -1,7 +1,10 @@ const jwt = require('jsonwebtoken'); -const getModels = require('../services/getModelService'); const getGlobalModels = require('../services/getGlobalModelService'); +function getModels(req, ...names) { + return require('../services/getModelService')(req, ...names); +} + const REFRESH_TOKEN_EXPIRY_DAYS = 30; const REFRESH_TOKEN_EXPIRY_MS = REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000; @@ -110,18 +113,14 @@ async function createGlobalSession(globalUserId, refreshToken, req) { * Validate a refresh token and return the session (tenant DB - legacy) */ async function validateSession(refreshToken, req) { - const { Session, User } = getModels(req, 'Session', 'User'); - try { - // First verify the JWT token const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET); - // New tokens use globalUserId; legacy use userId if (decoded.globalUserId) { return validateGlobalSession(refreshToken, req); } - // Find the session in database (tenant) + const { Session, User } = getModels(req, 'Session', 'User'); const session = await Session.findOne({ refreshToken }); if (!session) { From 5abc84f54b48e76a64864eec5f8b770eaa126f46 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:16 +0900 Subject: [PATCH 12/39] MER-192: Allow admin users to bypass tenant coming_soon and maintenance gates. --- backend/middlewares/tenantStatusGate.js | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 backend/middlewares/tenantStatusGate.js diff --git a/backend/middlewares/tenantStatusGate.js b/backend/middlewares/tenantStatusGate.js new file mode 100644 index 00000000..46f5cbeb --- /dev/null +++ b/backend/middlewares/tenantStatusGate.js @@ -0,0 +1,107 @@ +const jwt = require('jsonwebtoken'); +const getModels = require('../services/getModelService'); +const getGlobalModels = require('../services/getGlobalModelService'); +const authGlobalService = require('../services/authGlobalService'); +const { isAdminLevelAccount } = require('../services/adminMfaService'); + +/** Paths reachable while a tenant is coming_soon / maintenance (login + status page + assets). */ +const PUBLIC_DURING_TENANT_OUTAGE_PREFIXES = [ + '/login', + '/signup', + '/register', + '/validate-token', + '/refresh-token', + '/tenant-status', + '/auth/', + '/google-login', + '/apple-login', + '/forgot-password', + '/verify-code', + '/reset-password', + '/mfa/', + '/health', + '/api/tenant-config', + '/static', + '/assets', + '/favicon', + '/manifest.json', + '/proxy-image', +]; + +function normalizePath(req) { + const raw = (req.path || req.url || '').split('?')[0]; + return raw && raw.trim() !== '' ? raw : '/'; +} + +function isPublicPathDuringTenantOutage(path) { + return PUBLIC_DURING_TENANT_OUTAGE_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + +function readAccessToken(req) { + return ( + req.cookies?.accessToken || + (req.headers.authorization && req.headers.authorization.split(' ')[1]) + ); +} + +/** + * True when the caller is a platform or tenant admin (used to bypass coming_soon / maintenance gates). + */ +async function isAdminLevelRequest(req) { + const token = readAccessToken(req); + if (!token || !process.env.JWT_SECRET) return false; + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const platformRoles = Array.isArray(decoded.platformRoles) ? decoded.platformRoles : []; + + if (platformRoles.includes('platform_admin') || platformRoles.includes('root')) { + return true; + } + + if (decoded.globalUserId && req.globalDb) { + const { PlatformRole } = getGlobalModels(req, 'PlatformRole'); + const pr = await PlatformRole.findOne({ globalUserId: decoded.globalUserId }).lean(); + if (pr?.roles?.includes('platform_admin') || pr?.roles?.includes('root')) { + return true; + } + } + + let tenantUser = null; + if (decoded.globalUserId && req.db) { + const { tenantUser: resolved } = await authGlobalService.resolveTenantUserForRequest( + req, + decoded.globalUserId + ); + tenantUser = resolved; + } else if (decoded.userId && req.db) { + const { User } = getModels(req, 'User'); + tenantUser = await User.findById(decoded.userId).lean(); + } + + return isAdminLevelAccount(tenantUser, platformRoles); + } catch (_) { + return false; + } +} + +/** + * @param {string} status - tenant lifecycle status + * @returns {boolean} skip coming_soon / maintenance enforcement + */ +async function shouldBypassTenantStatusGate(req, status) { + if (!status || status === 'active' || status === 'hidden') return true; + + const path = normalizePath(req); + if (isPublicPathDuringTenantOutage(path)) return true; + + return isAdminLevelRequest(req); +} + +module.exports = { + shouldBypassTenantStatusGate, + isAdminLevelRequest, + isPublicPathDuringTenantOutage, +}; From 33516d98a344f838c1f33c278b80d8997bc5de92 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:16 +0900 Subject: [PATCH 13/39] MER-192: Wire tenant lifecycle middleware, www routing, and platform admin bootstrap. --- backend/app.js | 94 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 30 deletions(-) diff --git a/backend/app.js b/backend/app.js index 1027db21..0dafd628 100644 --- a/backend/app.js +++ b/backend/app.js @@ -20,20 +20,43 @@ function createApp() { const server = createServer(app); let tenantConfigCache = null; let tenantConfigLastFetchedAt = 0; + let tenantKeysCache = ['rpi', 'tvcog']; + let tenantKeysLastFetchedAt = 0; + let dynamicCorsOrigins = []; + let tenantBootstrapComplete = false; - const corsOrigin = process.env.NODE_ENV === 'production' - ? ['https://www.meridian.study', 'https://meridian.study', 'https://rpi.meridian.study', 'https://tvcog.meridian.study'] - : 'http://localhost:3000'; - initSocket(server, { origin: corsOrigin }); + const staticProductionOrigins = [ + 'https://www.meridian.study', + 'https://meridian.study', + 'https://rpi.meridian.study', + 'https://tvcog.meridian.study', + ]; + + function isAllowedCorsOrigin(origin) { + if (!origin) return true; + if (process.env.NODE_ENV !== 'production') { + return origin.startsWith('http://localhost'); + } + if (staticProductionOrigins.includes(origin)) return true; + return new RegExp(`^https://[a-z0-9_-]+\\.${BASE_DOMAIN.replace('.', '\\.')}$`).test(origin); + } const corsOptions = { - origin: process.env.NODE_ENV === 'production' - ? ['https://www.meridian.study', 'https://meridian.study', 'https://rpi.meridian.study', 'https://tvcog.meridian.study'] - : 'http://localhost:3000', + origin(origin, callback) { + if (isAllowedCorsOrigin(origin)) { + callback(null, true); + } else { + callback(new Error(`Origin ${origin} not allowed by CORS`)); + } + }, credentials: true, - optionsSuccessStatus: 200 + optionsSuccessStatus: 200, }; + initSocket(server, { + origin: process.env.NODE_ENV === 'production' ? staticProductionOrigins : 'http://localhost:3000', + }); + app.set('trust proxy', true); app.get('/health', (req, res) => res.status(200).json({ ok: true })); @@ -81,14 +104,22 @@ function createApp() { app.use(async (req, res, next) => { try { - // Debug logging to identify polling routes - // const timestamp = new Date().toISOString(); - // const method = req.method; - // const path = req.path || req.url; - // const userAgent = req.get('user-agent') || 'unknown'; - - // console.log(`[${timestamp}] ${method}: ${path} | School: ${req.headers.host?.split('.')[0] || 'unknown'} | User-Agent: ${userAgent.substring(0, 50)}`); - + req.globalDb = await connectToGlobalDatabase(); + + const tenantConfigService = require('./services/tenantConfigService'); + const { syncTenantUriCache, getMergedTenants, BASE_DOMAIN } = tenantConfigService; + + if (!tenantBootstrapComplete) { + await syncTenantUriCache(req); + const tenants = await getMergedTenants(req); + tenantKeysCache = tenants.map((tenant) => tenant.tenantKey); + tenantKeysLastFetchedAt = Date.now(); + dynamicCorsOrigins = tenants.map( + (tenant) => `https://${tenant.subdomain || tenant.tenantKey}.${BASE_DOMAIN}` + ); + tenantBootstrapComplete = true; + } + const host = req.headers.host || ''; // Extract subdomain: for 'rpi.meridian.study' -> 'rpi', for 'localhost:5001' -> default tenant let subdomain = host.split('.')[0]; @@ -103,15 +134,19 @@ function createApp() { // Development only: allow X-Tenant header or ?school= to override tenant (for local testing) if (process.env.NODE_ENV !== 'production') { const override = req.headers['x-tenant'] || req.query.school; - const validTenants = ['rpi', 'tvcog']; // keep in sync with connectionsManager - if (override && validTenants.includes(override.toLowerCase())) { + const now = Date.now(); + if (now - tenantKeysLastFetchedAt > 30000) { + const tenants = await getMergedTenants(req); + tenantKeysCache = tenants.map((tenant) => tenant.tenantKey); + tenantKeysLastFetchedAt = now; + } + if (override && tenantKeysCache.includes(String(override).toLowerCase())) { subdomain = override.toLowerCase(); } } - + req.db = await connectToDatabase(subdomain); req.school = subdomain; - req.globalDb = await connectToGlobalDatabase(); next(); } catch (error) { console.error('Error establishing database connection:', error); @@ -135,6 +170,7 @@ function createApp() { '/error', '/select-school', '/tenant-status', + '/platform-admin', '/static', '/health', '/validate-token', @@ -144,6 +180,9 @@ function createApp() { '/api/event-system-config/analytics-config', '/api/android-tester', '/api/tenant-config', + '/validate-token', + '/refresh-token', + '/admin/platform', ]; app.use((req, res, next) => { if (req.school !== 'www') return next(); @@ -183,22 +222,15 @@ function createApp() { return row?.status || 'active'; } + const { shouldBypassTenantStatusGate } = require('./middlewares/tenantStatusGate'); + app.use(async (req, res, next) => { try { if (req.school === 'www') return next(); const status = await getTenantStatus(req.school, req); - if (status === 'active' || status === 'hidden') return next(); + if (await shouldBypassTenantStatusGate(req, status)) return next(); const path = (req.path || req.url || '').split('?')[0]; - const allowStatusPage = path === '/tenant-status' || path.startsWith('/tenant-status/'); - const allowSharedAssets = - path.startsWith('/static') || - path.startsWith('/assets') || - path.startsWith('/favicon') || - path === '/manifest.json' || - path === '/health' || - path === '/api/tenant-config'; - if (allowStatusPage || allowSharedAssets) return next(); const acceptHeader = (req.headers.accept || '').toLowerCase(); const secFetchDest = (req.headers['sec-fetch-dest'] || '').toLowerCase(); @@ -249,6 +281,7 @@ function createApp() { const taskManagementRoutes = require('./routes/taskManagementRoutes.js'); const roomRoutes = require('./routes/roomRoutes.js'); const adminRoutes = require('./routes/adminRoutes.js'); + const platformTenantRoutes = require('./routes/platformTenantRoutes.js'); const eventsRoutes = require('./events/index.js'); const notificationRoutes = require('./routes/notificationRoutes.js'); const qrRoutes = require('./routes/qrRoutes.js'); @@ -287,6 +320,7 @@ function createApp() { app.use('/org-event-management', taskManagementRoutes); app.use('/admin', roomRoutes); app.use(adminRoutes); + app.use(platformTenantRoutes); app.use(formRoutes); app.use('/notifications', notificationRoutes); app.use('/api/qr', qrRoutes); From 72c99ba40849f5f4e9334ac6e19ef7f2d07c2c05 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:25 +0900 Subject: [PATCH 14/39] MER-192: Add platform admin route protection and expose platform roles in auth. --- frontend/src/App.js | 5 +++ frontend/src/AuthContext.js | 13 ++++++-- .../PlatformProtectedRoute.jsx | 32 +++++++++++++++++++ frontend/src/config/tenantRedirect.js | 2 ++ 4 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx diff --git a/frontend/src/App.js b/frontend/src/App.js index 09e48258..85c9e7e8 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -26,6 +26,8 @@ import DeveloperOnboard from './pages/DeveloperOnboarding/DeveloperOnboarding'; import QR from './pages/QR/QR'; import EventQRRedirect from './pages/QR/EventQRRedirect'; import Admin from './pages/Admin/Admin'; +import PlatformAdmin from './pages/PlatformAdmin/PlatformAdmin'; +import PlatformProtectedRoute from './components/PlatformProtectedRoute/PlatformProtectedRoute'; import OIEDash from './pages/OIEDash/OIEDash'; import NewBadge from './pages/NewBadge/NewBadge'; import CreateOrg from './pages/CreateOrg/CreateOrg'; @@ -225,6 +227,9 @@ function App() { }/> }/> }/> + }> + } /> + }/> }/> }/> diff --git a/frontend/src/AuthContext.js b/frontend/src/AuthContext.js index ba3814d4..2c270487 100644 --- a/frontend/src/AuthContext.js +++ b/frontend/src/AuthContext.js @@ -127,7 +127,13 @@ export const AuthProvider = ({ children }) => { window.location.href = `/select-school${next}`; return; } - setUser(response.data.user); + setUser( + response.data.user + ? { ...response.data.user, platformRoles: response.data.platformRoles || [] } + : (response.data.platformRoles?.length + ? { platformRoles: response.data.platformRoles, roles: [] } + : null) + ); // Set friend requests if provided if (response.data.friendRequests) { setFriendRequests({ @@ -145,6 +151,10 @@ export const AuthProvider = ({ children }) => { } // Determine auth method from user data (only when user exists) const u = response.data.user; + const platformRoles = response.data.platformRoles || []; + if (u || platformRoles.length) { + setIsAuthenticated(true); + } if (u) { if (u.samlProvider) { setAuthMethod('saml'); @@ -176,7 +186,6 @@ export const AuthProvider = ({ children }) => { // non-fatal: leave anonymous regs in storage to retry next time } } - setIsAuthenticated(true); getCheckedIn(); // Root-configurable onboarding gate for all users missing newly required/unseen steps. diff --git a/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx b/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx new file mode 100644 index 00000000..e5297b00 --- /dev/null +++ b/frontend/src/components/PlatformProtectedRoute/PlatformProtectedRoute.jsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import useAuth from '../../hooks/useAuth'; +import { useNotification } from '../../NotificationContext'; + +function isPlatformAdmin(user) { + const roles = user?.platformRoles || []; + return roles.includes('platform_admin') || roles.includes('root'); +} + +const PlatformProtectedRoute = () => { + const { isAuthenticated, isAuthenticating, user } = useAuth(); + const { addNotification } = useNotification(); + + useEffect(() => { + if (!isAuthenticating && isAuthenticated && !isPlatformAdmin(user)) { + addNotification({ + title: 'Unauthorized', + message: 'Platform admin access is required.', + type: 'error', + }); + } + }, [isAuthenticated, isAuthenticating, user, addNotification]); + + if (isAuthenticating) return null; + if (!isAuthenticated) return ; + if (!isPlatformAdmin(user)) return ; + + return ; +}; + +export default PlatformProtectedRoute; diff --git a/frontend/src/config/tenantRedirect.js b/frontend/src/config/tenantRedirect.js index b50a386c..c2119e87 100644 --- a/frontend/src/config/tenantRedirect.js +++ b/frontend/src/config/tenantRedirect.js @@ -116,6 +116,8 @@ const WWW_ALLOWED_PATHS = [ '/error', '/select-school', '/tenant-status', + '/platform-admin', + '/login', ]; export function isPathAllowedOnWww(pathname) { From e17e78d29b90152c47d4f5a399d558080207aac7 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:25 +0900 Subject: [PATCH 15/39] MER-192: Add Platform Admin dashboard shell on www. --- .../src/pages/PlatformAdmin/PlatformAdmin.jsx | 31 +++++++++++++++++++ .../pages/PlatformAdmin/PlatformAdmin.scss | 0 2 files changed, 31 insertions(+) create mode 100644 frontend/src/pages/PlatformAdmin/PlatformAdmin.jsx create mode 100644 frontend/src/pages/PlatformAdmin/PlatformAdmin.scss diff --git a/frontend/src/pages/PlatformAdmin/PlatformAdmin.jsx b/frontend/src/pages/PlatformAdmin/PlatformAdmin.jsx new file mode 100644 index 00000000..556c3746 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/PlatformAdmin.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Dashboard from '../../components/Dashboard/Dashboard'; +import TenantManagementPage from './TenantManagement/TenantManagementPage'; +import AdminLogo from '../../assets/Brand Image/ADMIN.svg'; +import '../Admin/Admin.scss'; +import './PlatformAdmin.scss'; + +function PlatformAdmin() { + const navigate = useNavigate(); + + const menuItems = [ + { + label: 'Tenants', + icon: 'mdi:city-variant-outline', + element: , + }, + ]; + + return ( + navigate('/select-school')} + enableSubSidebar={false} + /> + ); +} + +export default PlatformAdmin; diff --git a/frontend/src/pages/PlatformAdmin/PlatformAdmin.scss b/frontend/src/pages/PlatformAdmin/PlatformAdmin.scss new file mode 100644 index 00000000..e69de29b From 3c5e201f402caf91aa4bd84c9c81156891574826 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:25 +0900 Subject: [PATCH 16/39] MER-192: Add Linear-style tenant management UI with visibility and health checks. --- .../TenantManagement/TenantManagementPage.jsx | 627 +++++++++++++ .../TenantManagementPage.scss | 833 ++++++++++++++++++ .../TenantManagement/tenantHealthUtils.js | 12 + .../TenantManagement/tenantStatusConstants.js | 31 + 4 files changed, 1503 insertions(+) create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.scss create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/tenantHealthUtils.js create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/tenantStatusConstants.js diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx new file mode 100644 index 00000000..9a604a51 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx @@ -0,0 +1,627 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Icon } from '@iconify-icon/react'; +import { useFetch, authenticatedRequest } from '../../../hooks/useFetch'; +import { setTenantConfigCache } from '../../../config/tenantRedirect'; +import { useNotification } from '../../../NotificationContext'; +import TenantLifecycleModal from './TenantLifecycleModal/TenantLifecycleModal'; +import TenantStatusDropdown from './TenantStatusDropdown/TenantStatusDropdown'; +import { formatTenantHealthMessage, isTenantHealthOk } from './tenantHealthUtils'; +import './TenantManagementPage.scss'; + +const EMPTY_FORM = { + tenantKey: '', + name: '', + subdomain: '', + location: '', + tenantType: 'pivot', + mongoDatabaseName: '', + mongoUri: '', + status: 'coming_soon', +}; + +function StatusBadge({ status }) { + return {status.replace(/_/g, ' ')}; +} + +function HealthDot({ health }) { + if (!health) return ; + if (isTenantHealthOk(health)) { + return ; + } + return ; +} + +function ManualStepsPanel({ tenant, onConfirmStep, savingStepId }) { + if (!tenant?.manualSteps?.length) return null; + const checklistId = `tenant-setup-${tenant.tenantKey}`; + + return ( +
+

Setup checklist

+
    + {tenant.manualSteps.map((step) => ( +
  • +
    + + + +
    +
    + {step.title} + + {step.automated ? 'Automated' : 'Manual'} + +
    +

    {step.description}

    + {step.command ? {step.command} : null} + {step.orgId ? pivotCatalogOrgId: {step.orgId} : null} + {!step.automated && !step.completed ? ( + + ) : null} + {step.id === 'verify_picker' && step.completed && step.requiresActiveStatus ? ( +

    Verified on picker — activate subdomain when ready to go live.

    + ) : null} +
    +
    +
  • + ))} +
+
+ ); +} + +function CreateTenantForm({ form, creating, onChange, onSubmit, onCancel }) { + return ( +
+
+
+

New tenant

+

Create city

+

Starts as until you activate the subdomain.

+
+ +
+
+
+
+ + + + + + + +
+

+ Stored in global TenantConfig. Legacy MONGO_URI_RPI / MONGO_URI_TVCOG env vars still take precedence. +

+
+
+ + +
+
+
+ ); +} + +function TenantDetail({ + tenant, + actionKey, + onHealthCheck, + onProvisionCatalog, + onConfirmStep, + onSaveVisibility, + savingVisibility, + savingStepId, +}) { + const savedStatus = tenant.status || 'coming_soon'; + const [lifecycleRequest, setLifecycleRequest] = useState(null); + + const checklistComplete = tenant.provisioningComplete === true; + const showActivateCta = checklistComplete && savedStatus !== 'active'; + const infraHeadingId = `tenant-infra-${tenant.tenantKey}`; + + const openStatusDialog = (nextStatus, mode = 'status') => { + if (nextStatus === savedStatus && mode !== 'message') return; + setLifecycleRequest({ + mode: mode === 'activate' ? 'activate' : 'status', + targetStatus: nextStatus, + initialMessage: tenant.statusMessage || '', + }); + }; + + const openMessageDialog = () => { + setLifecycleRequest({ + mode: 'message', + targetStatus: savedStatus, + initialMessage: tenant.statusMessage || '', + }); + }; + + return ( +
+
+
+

{tenant.tenantKey}

+

{tenant.name}

+

+ {tenant.subdomainUrl} + {tenant.statusMessage ? ( + {tenant.statusMessage} + ) : null} +

+
+ +
+
+ openStatusDialog(next)} + /> + +
+ {showActivateCta ? ( + + ) : null} +
+
+ +
+ {tenant.pivotPilot ? Pivot pilot : null} + {!checklistComplete ? ( + Setup in progress + ) : savedStatus !== 'active' ? ( + Ready to activate + ) : null} +
+ +
+

Infrastructure

+
+
+ Database + + {tenant.health + ? isTenantHealthOk(tenant.health) + ? `Connected · ${tenant.health.latencyMs}ms` + : tenant.health.error || 'Unreachable' + : 'Not checked'} + +
+
+ Location + {tenant.location || '—'} +
+ {tenant.pivotCatalogOrgId ? ( +
+ Pivot Catalog org + {tenant.pivotCatalogOrgId} +
+ ) : null} +
+
+ + {tenant.pivotPilot ? ( + + ) : null} +
+
+ + + + setLifecycleRequest(null)} + onConfirm={(payload) => onSaveVisibility(tenant.tenantKey, payload)} + /> +
+ ); +} + +function TenantManagementPage() { + const { addNotification } = useNotification(); + const [form, setForm] = useState(EMPTY_FORM); + const [creating, setCreating] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [savingStepId, setSavingStepId] = useState(null); + const [savingVisibilityKey, setSavingVisibilityKey] = useState(null); + const [actionKey, setActionKey] = useState(null); + const [search, setSearch] = useState(''); + + const { data, loading, error, refetch } = useFetch('/admin/platform/tenants', { + cache: { enabled: true, ttlMs: 15000 }, + }); + + const tenants = data?.success ? data.data?.tenants || [] : []; + const filteredTenants = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return tenants; + return tenants.filter( + (t) => + t.name?.toLowerCase().includes(q) || + t.tenantKey?.toLowerCase().includes(q) || + t.subdomain?.toLowerCase().includes(q) || + t.location?.toLowerCase().includes(q) + ); + }, [tenants, search]); + + const selected = useMemo( + () => tenants.find((tenant) => tenant.tenantKey === selectedKey) || null, + [tenants, selectedKey] + ); + + const handleFormChange = (field, value) => { + setForm((prev) => { + const next = { ...prev, [field]: value }; + if (field === 'tenantKey' && !prev.subdomain) next.subdomain = value; + if (field === 'tenantKey' && !prev.mongoDatabaseName) next.mongoDatabaseName = value; + return next; + }); + }; + + const handleCreate = useCallback(async (e) => { + e.preventDefault(); + setCreating(true); + const payload = { + ...form, + status: 'coming_soon', + statusMessage: '', + pivotPilot: form.tenantType === 'pivot', + mongoDatabaseName: form.mongoDatabaseName || form.tenantKey, + }; + const { data: res, error: reqError } = await authenticatedRequest('/admin/platform/tenants', { + method: 'POST', + data: payload, + headers: { 'Content-Type': 'application/json' }, + }); + setCreating(false); + if (reqError || !res?.success) { + addNotification({ + title: 'Create failed', + message: res?.message || reqError || 'Unable to create tenant', + type: 'error', + }); + return; + } + addNotification({ + title: 'Tenant created', + message: `${form.tenantKey} is coming soon — finish setup, then activate`, + type: 'success', + }); + try { + const cfgRes = await fetch('/api/tenant-config', { credentials: 'include' }); + const cfgPayload = await cfgRes.json(); + if (cfgPayload?.success) setTenantConfigCache(cfgPayload.data?.tenants || []); + } catch (_) {} + setForm(EMPTY_FORM); + setShowCreate(false); + setSelectedKey(res.data.tenantKey); + refetch(); + }, [addNotification, form, refetch]); + + const runHealthCheck = useCallback(async (tenantKey) => { + setActionKey(`${tenantKey}-health`); + const { data: res, error: reqError } = await authenticatedRequest( + `/admin/platform/tenants/${tenantKey}/health-check`, + { method: 'POST' } + ); + setActionKey(null); + if (reqError || !res?.success) { + addNotification({ + title: 'Health check', + message: res?.message || reqError || 'Health check failed', + type: 'error', + }); + return; + } + const health = res?.data?.health; + addNotification({ + title: 'Health check', + message: formatTenantHealthMessage(health), + type: isTenantHealthOk(health) ? 'success' : 'error', + }); + refetch(); + }, [addNotification, refetch]); + + const provisionCatalog = useCallback(async (tenantKey) => { + setActionKey(`${tenantKey}-catalog`); + const { data: res } = await authenticatedRequest( + `/admin/platform/tenants/${tenantKey}/provision-pivot-catalog`, + { method: 'POST' } + ); + setActionKey(null); + if (res?.success) { + addNotification({ + title: 'Pivot Catalog', + message: `Org ${res.data.pivotCatalog?.orgId || res.data.pivotCatalogOrgId}`, + type: 'success', + }); + refetch(); + } else { + addNotification({ + title: 'Provision failed', + message: res?.message || 'Unable to provision catalog org', + type: 'error', + }); + } + }, [addNotification, refetch]); + + const saveVisibility = useCallback(async (tenantKey, { status, statusMessage }) => { + setSavingVisibilityKey(tenantKey); + const { data: res, error: reqError } = await authenticatedRequest(`/admin/platform/tenants/${tenantKey}`, { + method: 'PUT', + data: { status, statusMessage }, + headers: { 'Content-Type': 'application/json' }, + }); + setSavingVisibilityKey(null); + if (reqError || !res?.success) { + addNotification({ + title: 'Save failed', + message: res?.message || reqError || 'Unable to update tenant visibility', + type: 'error', + }); + return false; + } + addNotification({ title: 'Saved', message: `${tenantKey} visibility updated`, type: 'success' }); + try { + const cfgRes = await fetch('/api/tenant-config', { credentials: 'include' }); + const cfgPayload = await cfgRes.json(); + if (cfgPayload?.success) setTenantConfigCache(cfgPayload.data?.tenants || []); + } catch (_) {} + refetch(); + return true; + }, [addNotification, refetch]); + + const confirmManualStep = useCallback(async (tenant, stepId) => { + const fieldMap = { dns: 'dns', cors: 'cors', verify_picker: 'pickerVerified' }; + const field = fieldMap[stepId]; + if (!field) { + addNotification({ + title: 'Cannot save step', + message: 'This checklist item is completed automatically.', + type: 'error', + }); + return; + } + + setSavingStepId(stepId); + const { data: res, error: reqError } = await authenticatedRequest(`/admin/platform/tenants/${tenant.tenantKey}`, { + method: 'PUT', + data: { + provisioningConfirmations: { + ...(tenant.provisioningConfirmations || {}), + [field]: true, + }, + }, + headers: { 'Content-Type': 'application/json' }, + }); + setSavingStepId(null); + if (reqError || !res?.success) { + addNotification({ + title: 'Could not update checklist', + message: res?.message || reqError || 'Save failed', + type: 'error', + }); + return; + } + addNotification({ + title: 'Checklist updated', + message: stepId === 'verify_picker' ? 'School picker verification saved' : 'Step marked complete', + type: 'success', + }); + refetch(); + }, [addNotification, refetch]); + + const openCreate = () => { + setShowCreate(true); + setSelectedKey(null); + }; + + const selectTenant = (tenantKey) => { + setSelectedKey(tenantKey); + setShowCreate(false); + }; + + return ( +
+
+
+

Tenants

+

Provision cities, run setup checklists, and control subdomain visibility

+
+
+ + +
+
+ + {error ?
{error}
: null} + +
+ + +
+ {showCreate ? ( + setShowCreate(false)} + /> + ) : selected ? ( + + ) : ( +
+ +

Select a tenant

+

Choose a city from the list or create a new tenant to get started.

+ +
+ )} +
+
+
+ ); +} + +export default TenantManagementPage; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.scss new file mode 100644 index 00000000..08787ac4 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.scss @@ -0,0 +1,833 @@ +// Tenant management content (Linear-inspired) +.linear-admin { + --la-bg: #f7f8f8; + --la-surface: #ffffff; + --la-border: #e6e6e6; + --la-border-subtle: #ebebeb; + --la-text: #171717; + --la-text-secondary: #6b6f76; + --la-text-tertiary: #969696; + --la-accent: #5e6ad2; + --la-accent-hover: #4f58b9; + --la-accent-soft: rgba(94, 106, 210, 0.12); + --la-row-hover: #f0f0f0; + --la-row-selected: #eceef1; + --la-success: #0d9488; + --la-error: #dc2626; + --la-radius: 8px; + --la-radius-sm: 6px; + + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + color: var(--la-text); + background: var(--la-bg); + + &__toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--la-border-subtle); + background: var(--la-surface); + flex-shrink: 0; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.3; + } + + &__subtitle { + margin: 4px 0 0; + font-size: 13px; + color: var(--la-text-secondary); + line-height: 1.4; + } + + &__toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + &__error { + margin: 12px 24px 0; + padding: 10px 12px; + border-radius: var(--la-radius-sm); + background: #fef2f2; + border: 1px solid #fecaca; + color: var(--la-error); + font-size: 13px; + } + + &__split { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + } + + &__sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--la-border-subtle); + background: var(--la-surface); + min-height: 0; + } + + &__search { + position: relative; + padding: 12px; + border-bottom: 1px solid var(--la-border-subtle); + flex-shrink: 0; + } + + &__search-icon { + position: absolute; + left: 22px; + top: 50%; + transform: translateY(-50%); + color: var(--la-text-tertiary); + font-size: 16px; + pointer-events: none; + } + + &__list { + flex: 1; + overflow-y: auto; + padding: 6px; + } + + &__empty-list { + padding: 24px 12px; + margin: 0; + font-size: 13px; + color: var(--la-text-tertiary); + text-align: center; + } + + &__main { + flex: 1; + min-width: 0; + overflow-y: auto; + background: var(--la-bg); + } +} + +// List rows +.linear-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: var(--la-radius-sm); + background: transparent; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; + transition: background 0.1s ease; + + &:hover { + background: var(--la-row-hover); + } + + &.is-selected { + background: var(--la-row-selected); + } + + &__content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + + &__title { + font-size: 13px; + font-weight: 500; + color: var(--la-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__sub { + font-size: 12px; + color: var(--la-text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.linear-health { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--ok { + background: var(--la-success); + box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.2); + } + + &--error { + background: var(--la-error); + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.15); + } + + &--unknown { + background: #d4d4d4; + } +} + +// Badges & tags +.linear-badge { + font-size: 11px; + font-weight: 500; + padding: 2px 7px; + border-radius: 4px; + text-transform: capitalize; + white-space: nowrap; + background: #f0f0f0; + color: var(--la-text-secondary); + + &--active { + background: #ecfdf5; + color: #047857; + } + + &--coming_soon { + background: #eff6ff; + color: #1d4ed8; + } + + &--maintenance { + background: #fffbeb; + color: #b45309; + } + + &--hidden { + background: #f5f5f5; + color: #737373; + } +} + +.linear-tag { + font-size: 11px; + font-weight: 500; + padding: 2px 7px; + border-radius: 4px; + background: #f0f0f0; + color: var(--la-text-secondary); + + &--auto { + background: var(--la-accent-soft); + color: var(--la-accent); + } + + &--manual { + background: #fff7ed; + color: #c2410c; + } + + &--pivot { + background: #fdf2f8; + color: #9d174d; + } +} + +// Buttons +.linear-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + height: 32px; + border-radius: var(--la-radius-sm); + border: 1px solid transparent; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; + white-space: nowrap; + + iconify-icon { + font-size: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--primary { + background: var(--la-text); + color: #fff; + border-color: var(--la-text); + + &:hover:not(:disabled) { + background: #262626; + } + } + + &--secondary { + background: var(--la-surface); + color: var(--la-text); + border-color: var(--la-border); + + &:hover:not(:disabled) { + background: var(--la-row-hover); + } + } + + &--ghost { + background: transparent; + color: var(--la-text-secondary); + border-color: transparent; + + &:hover:not(:disabled) { + background: var(--la-row-hover); + color: var(--la-text); + } + } + + &--icon { + width: 32px; + padding: 0; + } + + &--sm { + height: 28px; + padding: 0 10px; + font-size: 12px; + } +} + +// Inputs +.linear-input { + width: 100%; + height: 32px; + padding: 0 10px; + border: 1px solid var(--la-border); + border-radius: var(--la-radius-sm); + background: var(--la-surface); + font-family: inherit; + font-size: 13px; + color: var(--la-text); + transition: border-color 0.12s ease, box-shadow 0.12s ease; + box-sizing: border-box; + + &::placeholder { + color: var(--la-text-tertiary); + } + + &:focus { + outline: none; + border-color: var(--la-accent); + box-shadow: 0 0 0 3px var(--la-accent-soft); + } + + &--search { + padding-left: 32px; + background: #f7f8f8; + border-color: transparent; + + &:focus { + background: var(--la-surface); + border-color: var(--la-border); + box-shadow: none; + } + } +} + +select.linear-input { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23969696' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; +} + +// Detail panel +.linear-detail { + padding: 24px 28px 32px; + max-width: 720px; + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + } + + &__eyebrow { + margin: 0 0 4px; + font-size: 12px; + font-weight: 500; + color: var(--la-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__title { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; + } + + &__meta { + margin: 6px 0 0; + font-size: 13px; + color: var(--la-text-secondary); + } + + &__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + flex-shrink: 0; + } + + &__stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 28px; + padding-bottom: 24px; + border-bottom: 1px solid var(--la-border-subtle); + } +} + +.linear-stat { + padding: 12px 14px; + background: var(--la-surface); + border: 1px solid var(--la-border-subtle); + border-radius: var(--la-radius); + + &--wide { + grid-column: 1 / -1; + } + + &__label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--la-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; + } + + &__value { + font-size: 13px; + font-weight: 500; + color: var(--la-text); + word-break: break-all; + + &.is-ok { + color: var(--la-success); + } + + &.is-error { + color: var(--la-error); + } + } +} + +// Form +.linear-form { + &__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 14px; + margin-bottom: 12px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } + } + + &__hint { + margin: 0 0 20px; + font-size: 12px; + line-height: 1.5; + color: var(--la-text-tertiary); + + code { + font-size: 11px; + padding: 1px 4px; + background: #f0f0f0; + border-radius: 3px; + } + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--la-border-subtle); + } +} + +.linear-field { + display: flex; + flex-direction: column; + gap: 6px; + + &--full { + grid-column: 1 / -1; + } + + &__label { + font-size: 12px; + font-weight: 500; + color: var(--la-text-secondary); + } +} + +// Checklist +.linear-section { + &__title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + color: var(--la-text); + } +} + +.linear-visibility { + margin-bottom: 28px; + padding-bottom: 24px; + border-bottom: 1px solid var(--la-border-subtle); + + &__hint { + margin: 0 0 14px; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary); + } + + &__fields { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 12px 14px; + margin-bottom: 12px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } + } + + &__actions { + display: flex; + justify-content: flex-start; + } +} + +// Tenant detail (semantic layout) +.tenant-detail { + padding: 24px 28px 32px; + max-width: 720px; + + &--create .tenant-detail__header { + margin-bottom: 20px; + } + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 16px; + flex-wrap: wrap; + position: relative; + z-index: 5; + overflow: visible; + } + + &__identity { + flex: 1; + min-width: 200px; + } + + &__eyebrow { + margin: 0 0 4px; + font-size: 12px; + font-weight: 500; + color: var(--la-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__title { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; + } + + &__meta { + margin: 6px 0 0; + font-size: 13px; + color: var(--la-text-secondary); + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + + a { + color: var(--la-text-secondary); + text-decoration: none; + + &:hover { + color: var(--la-accent); + } + } + } + + &__message-preview { + font-size: 12px; + color: var(--la-text-tertiary); + font-style: italic; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__lifecycle { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; + flex-shrink: 0; + position: relative; + z-index: 30; + } + + &__lifecycle-primary { + display: flex; + align-items: center; + gap: 6px; + } + + &__message-btn { + flex-shrink: 0; + } + + &__activate { + white-space: nowrap; + } + + &__section { + margin-bottom: 28px; + padding-bottom: 24px; + border-bottom: 1px solid var(--la-border-subtle); + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 0; + } + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding-bottom: 16px; + margin-bottom: 20px; + } + + &__soft-tag { + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + background: #fff7ed; + color: #c2410c; + + &--ready { + background: #ecfdf5; + color: #047857; + } + } +} + +.linear-checklist { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + + &__item { + background: var(--la-surface); + border: 1px solid var(--la-border-subtle); + border-radius: var(--la-radius); + overflow: hidden; + + &.is-done { + border-color: #bbf7d0; + background: #fafdfa; + } + } + + &__row { + display: flex; + gap: 12px; + padding: 14px; + } + + &__icon { + flex-shrink: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--la-text-tertiary); + margin-top: 1px; + + iconify-icon { + font-size: 18px; + } + + &.is-done { + color: var(--la-success); + } + } + + &__body { + flex: 1; + min-width: 0; + } + + &__head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; + } + + &__label { + font-size: 13px; + font-weight: 500; + color: var(--la-text); + } + + &__desc { + margin: 0 0 8px; + font-size: 13px; + line-height: 1.45; + color: var(--la-text-secondary); + } + + &__note { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--la-text-tertiary); + } +} + +.linear-code { + display: block; + margin-top: 6px; + padding: 8px 10px; + background: #18181b; + color: #e4e4e7; + border-radius: var(--la-radius-sm); + font-size: 12px; + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + overflow-x: auto; + line-height: 1.4; + + &--inline { + display: inline; + padding: 2px 6px; + margin-top: 0; + background: #f0f0f0; + color: var(--la-text); + font-size: 12px; + } +} + +// Empty state +.linear-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 100%; + min-height: 320px; + padding: 48px 24px; + color: var(--la-text-secondary); + + &__icon { + font-size: 40px; + color: #d4d4d4; + margin-bottom: 16px; + } + + h3 { + margin: 0 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--la-text); + letter-spacing: -0.02em; + } + + p { + margin: 0 0 20px; + font-size: 13px; + max-width: 280px; + line-height: 1.5; + color: var(--la-text-tertiary); + } +} + +@media (max-width: 768px) { + .linear-admin__split { + flex-direction: column; + } + + .linear-admin__sidebar { + width: 100%; + max-height: 240px; + border-right: none; + border-bottom: 1px solid var(--la-border-subtle); + } +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/tenantHealthUtils.js b/frontend/src/pages/PlatformAdmin/TenantManagement/tenantHealthUtils.js new file mode 100644 index 00000000..b1073b21 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/tenantHealthUtils.js @@ -0,0 +1,12 @@ +export function isTenantHealthOk(health) { + if (!health) return false; + return health.ok === true || health.ok === 1; +} + +export function formatTenantHealthMessage(health) { + if (isTenantHealthOk(health)) { + const ms = health.latencyMs != null ? ` · ${health.latencyMs}ms` : ''; + return `Database connected${ms}`; + } + return health?.error || 'Database unreachable'; +} diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/tenantStatusConstants.js b/frontend/src/pages/PlatformAdmin/TenantManagement/tenantStatusConstants.js new file mode 100644 index 00000000..774073f9 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/tenantStatusConstants.js @@ -0,0 +1,31 @@ +export const TENANT_STATUS_OPTIONS = [ + { value: 'coming_soon', label: 'Coming soon', meta: 'School picker only; subdomain not live', icon: 'mdi:clock-outline' }, + { value: 'active', label: 'Active', meta: 'Live on subdomain and school picker', icon: 'mdi:check-circle-outline' }, + { value: 'maintenance', label: 'Maintenance', meta: 'Blocked with optional user message', icon: 'mdi:wrench-outline' }, + { value: 'hidden', label: 'Hidden', meta: 'Omitted from picker; direct URL may work', icon: 'mdi:eye-off-outline' }, +]; + +export function statusLabel(value) { + if (!value) return 'Unknown'; + return TENANT_STATUS_OPTIONS.find((o) => o.value === value)?.label || String(value).replace(/_/g, ' '); +} + +export function getSoftWarnings(tenant, nextStatus) { + if (!tenant || !nextStatus) return []; + const warnings = []; + const checklistComplete = tenant.provisioningComplete === true; + + if (nextStatus === 'active' && !checklistComplete) { + warnings.push('Setup checklist is not complete. You can still go live, but users may hit incomplete configuration.'); + } + if (nextStatus === 'active' && tenant.status === 'coming_soon' && checklistComplete) { + warnings.push('DNS and CORS should be in place before traffic hits this subdomain.'); + } + if (nextStatus === 'hidden' && tenant.status === 'active') { + warnings.push('Hidden tenants are removed from the school picker but remain reachable by direct URL.'); + } + if (nextStatus === 'maintenance' && !tenant.statusMessage) { + warnings.push('Consider adding a status message so users know why access is limited.'); + } + return warnings; +} From c4a3eb15dfd977fca9f043c8cfaee5d927bafc34 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Tue, 26 May 2026 08:31:25 +0900 Subject: [PATCH 17/39] MER-192: Add tenant status dropdown and Popup-based lifecycle confirmation modal. --- .../TenantLifecycleModal.jsx | 216 ++++++++++++++++++ .../TenantLifecycleModal.scss | 162 +++++++++++++ .../TenantStatusDropdown.jsx | 88 +++++++ .../TenantStatusDropdown.scss | 89 ++++++++ 4 files changed, 555 insertions(+) create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantLifecycleModal/TenantLifecycleModal.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantLifecycleModal/TenantLifecycleModal.scss create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantStatusDropdown/TenantStatusDropdown.jsx create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/TenantStatusDropdown/TenantStatusDropdown.scss diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantLifecycleModal/TenantLifecycleModal.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantLifecycleModal/TenantLifecycleModal.jsx new file mode 100644 index 00000000..9e6af4a5 --- /dev/null +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantLifecycleModal/TenantLifecycleModal.jsx @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import Popup from '../../../../components/Popup/Popup'; +import { getSoftWarnings, statusLabel } from '../tenantStatusConstants'; +import './TenantLifecycleModal.scss'; + +const SESSION_CLEAR_MS = 320; + +function TenantLifecycleModalContent({ + handleClose = () => {}, + session, + tenant, + saving, + onConfirm, +}) { + const [attachMessage, setAttachMessage] = useState(null); + const [messageDraft, setMessageDraft] = useState(''); + + const { mode, targetStatus, initialMessage } = session; + const isMessageOnly = mode === 'message'; + const isActivate = mode === 'activate'; + const nextStatus = isMessageOnly ? (tenant.status || 'coming_soon') : targetStatus; + const warnings = isMessageOnly ? [] : getSoftWarnings(tenant, nextStatus); + const statusLabelLower = statusLabel(nextStatus).toLowerCase(); + + useEffect(() => { + const hasMessage = Boolean((initialMessage || '').trim()); + setAttachMessage(isMessageOnly ? true : hasMessage ? true : null); + setMessageDraft(initialMessage || ''); + }, [isMessageOnly, initialMessage, mode, targetStatus]); + + const handleConfirm = useCallback(async () => { + if (!isMessageOnly && attachMessage === null) return; + + let statusMessage = tenant.statusMessage || ''; + if (isMessageOnly || attachMessage === true) { + statusMessage = messageDraft.trim(); + } else if (attachMessage === false) { + statusMessage = ''; + } + + const ok = await onConfirm({ + status: isMessageOnly ? tenant.status || 'coming_soon' : nextStatus, + statusMessage, + }); + if (ok !== false) { + handleClose(); + } + }, [ + attachMessage, + handleClose, + isMessageOnly, + messageDraft, + nextStatus, + onConfirm, + tenant.status, + tenant.statusMessage, + ]); + + const dialogTitle = isMessageOnly + ? 'Edit status message' + : isActivate + ? 'Activate subdomain' + : `Change status to ${statusLabel(nextStatus)}`; + + const subdomainUrl = tenant.subdomainUrl || `${tenant.subdomain || tenant.tenantKey}.meridian.study`; + + return ( +
+

{dialogTitle}

+ + {!isMessageOnly ? ( +

+ Are you sure you want to {isActivate ? 'activate' : 'set'} {subdomainUrl} + {isActivate ? ' for users' : ` to ${statusLabelLower}`}? +

+ ) : ( +

+ Message shown on the tenant status page when the city is not fully available. +

+ )} + + {warnings.length > 0 ? ( +
    + {warnings.map((text) => ( +
  • {text}
  • + ))} +
+ ) : null} + + {!isMessageOnly ? ( +
+ Status message +

Optional. Shown to users when this tenant is not active.

+
+ + +
+ {attachMessage === true ? ( +