From ffa6a4842b79b49a3c38042d79ff9ea9fb7c0203 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:13:59 -0400 Subject: [PATCH 1/4] MER-178: add configurable disabled beta page behavior with popup controls. Extend org beta feature keys for budgeting/governance/lifecycle/verification requests and support tenant-level disabled-menu behavior (coming soon vs hide), then wire Club Dash and Org Beta Features UI with popup-based admin controls. --- backend/constants/orgBetaFeatures.js | 40 ++++++- backend/schemas/orgManagementConfig.js | 34 ++++++ frontend/src/constants/orgBetaFeatures.js | 33 +++++- frontend/src/pages/ClubDash/ClubDash.jsx | 84 ++++++++++++-- .../OrgBetaFeatures/OrgBetaFeatures.jsx | 107 +++++++++++++++++- .../OrgBetaFeatures/OrgBetaFeatures.scss | 85 ++++++++++---- 6 files changed, 348 insertions(+), 35 deletions(-) diff --git a/backend/constants/orgBetaFeatures.js b/backend/constants/orgBetaFeatures.js index f12b6339..44e7d05e 100644 --- a/backend/constants/orgBetaFeatures.js +++ b/backend/constants/orgBetaFeatures.js @@ -3,12 +3,42 @@ * Keep keys in sync with Meridian/frontend/src/constants/orgBetaFeatures.js */ const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; +const ORG_BETA_FEATURE_ORG_BUDGETING = 'org_budgeting'; +const ORG_BETA_FEATURE_ORG_GOVERNANCE = 'org_governance'; +const ORG_BETA_FEATURE_ORG_LIFECYCLE = 'org_lifecycle'; +const ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS = 'org_verification_requests'; +const ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON = 'coming_soon'; const ORG_BETA_FEATURE_CATALOG = { [ORG_BETA_FEATURE_ORG_TASKS]: { label: 'Organization task hub', description: 'Cross-event operational tasks and org-level task board in Club Dashboard.', - clubDashMenuKey: 'tasks' + clubDashMenuKey: 'tasks', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_BUDGETING]: { + label: 'Budgeting', + description: 'Budgets page inside club settings.', + clubDashMenuKey: 'settings.budgets', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_GOVERNANCE]: { + label: 'Governance', + description: 'Governance page inside club settings.', + clubDashMenuKey: 'settings.governance', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_LIFECYCLE]: { + label: 'Life cycle', + description: 'Lifecycle page inside club settings.', + clubDashMenuKey: 'settings.lifecycle', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS]: { + label: 'Verification requests', + description: 'Verification requests page inside club settings.', + clubDashMenuKey: 'settings.verification_requests', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON } }; @@ -41,12 +71,18 @@ function getBetaFeatureCatalogForApi() { key, label: ORG_BETA_FEATURE_CATALOG[key].label, description: ORG_BETA_FEATURE_CATALOG[key].description, - clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null + clubDashMenuKey: ORG_BETA_FEATURE_CATALOG[key].clubDashMenuKey || null, + disabledMenuBehavior: + ORG_BETA_FEATURE_CATALOG[key].disabledMenuBehavior || ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON })); } module.exports = { ORG_BETA_FEATURE_ORG_TASKS, + ORG_BETA_FEATURE_ORG_BUDGETING, + ORG_BETA_FEATURE_ORG_GOVERNANCE, + ORG_BETA_FEATURE_ORG_LIFECYCLE, + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS, ORG_BETA_FEATURE_KEYS, ORG_BETA_FEATURE_CATALOG, orgHasBetaFeature, diff --git a/backend/schemas/orgManagementConfig.js b/backend/schemas/orgManagementConfig.js index b0e82a36..0fecec20 100644 --- a/backend/schemas/orgManagementConfig.js +++ b/backend/schemas/orgManagementConfig.js @@ -178,6 +178,40 @@ const orgManagementConfigSchema = new mongoose.Schema({ } } }, + + // How disabled org beta pages appear in Club Dashboard navigation. + betaFeatures: { + disabledMenuBehaviorByKey: { + type: { + org_tasks: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_budgeting: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_governance: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_lifecycle: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + }, + org_verification_requests: { + type: String, + enum: ['coming_soon', 'hide'], + default: 'coming_soon' + } + }, + default: () => ({}) + } + }, // Verification type settings enableCustomVerificationTypes: { diff --git a/frontend/src/constants/orgBetaFeatures.js b/frontend/src/constants/orgBetaFeatures.js index 2e1525c6..5f44659e 100644 --- a/frontend/src/constants/orgBetaFeatures.js +++ b/frontend/src/constants/orgBetaFeatures.js @@ -3,12 +3,43 @@ * Keep keys in sync with Meridian/backend/constants/orgBetaFeatures.js */ export const ORG_BETA_FEATURE_ORG_TASKS = 'org_tasks'; +export const ORG_BETA_FEATURE_ORG_BUDGETING = 'org_budgeting'; +export const ORG_BETA_FEATURE_ORG_GOVERNANCE = 'org_governance'; +export const ORG_BETA_FEATURE_ORG_LIFECYCLE = 'org_lifecycle'; +export const ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS = 'org_verification_requests'; +export const ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON = 'coming_soon'; +export const ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE = 'hide'; export const ORG_BETA_FEATURE_CATALOG = { [ORG_BETA_FEATURE_ORG_TASKS]: { label: 'Organization task hub', description: 'Tasks tab and org-level task hub APIs.', - clubDashMenuKey: 'tasks' + clubDashMenuKey: 'tasks', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_BUDGETING]: { + label: 'Budgeting', + description: 'Budgets page inside club settings.', + clubDashMenuKey: 'settings.budgets', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_GOVERNANCE]: { + label: 'Governance', + description: 'Governance page inside club settings.', + clubDashMenuKey: 'settings.governance', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_LIFECYCLE]: { + label: 'Life cycle', + description: 'Lifecycle page inside club settings.', + clubDashMenuKey: 'settings.lifecycle', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON + }, + [ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS]: { + label: 'Verification requests', + description: 'Verification requests page inside club settings.', + clubDashMenuKey: 'settings.verification_requests', + disabledMenuBehavior: ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON } }; diff --git a/frontend/src/pages/ClubDash/ClubDash.jsx b/frontend/src/pages/ClubDash/ClubDash.jsx index 2978efbe..5148dd0d 100644 --- a/frontend/src/pages/ClubDash/ClubDash.jsx +++ b/frontend/src/pages/ClubDash/ClubDash.jsx @@ -42,7 +42,16 @@ import ClubDashOnboarding from './ClubDashOnboarding/ClubDashOnboarding'; import GovernanceSettings from './OrgSettings/components/GovernanceSettings'; import BudgetSettings from './OrgSettings/components/BudgetSettings'; import LifecycleSettings from './OrgSettings/components/LifecycleSettings'; -import { ORG_BETA_FEATURE_ORG_TASKS, orgHasBetaFeature } from '../../constants/orgBetaFeatures'; +import { + ORG_BETA_FEATURE_ORG_TASKS, + ORG_BETA_FEATURE_ORG_BUDGETING, + ORG_BETA_FEATURE_ORG_GOVERNANCE, + ORG_BETA_FEATURE_ORG_LIFECYCLE, + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS, + ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON, + ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE, + orgHasBetaFeature +} from '../../constants/orgBetaFeatures'; /** Set to true to always show the onboarding popup (ignores localStorage) */ const FORCE_CLUB_DASH_ONBOARDING = false; @@ -295,6 +304,39 @@ function ClubDash(){ const orgMongoId = orgOverview?._id; const tasksComingSoon = !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_TASKS); + const budgetingComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_BUDGETING); + const governanceComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_GOVERNANCE); + const lifecycleComingSoon = + !adminBypass && !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_LIFECYCLE); + const verificationRequestsComingSoon = + !adminBypass && + !orgHasBetaFeature(orgOverview, ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS); + const disabledMenuBehaviorByKey = + configData?.data?.betaFeatures?.disabledMenuBehaviorByKey || {}; + const getDisabledMenuBehavior = (featureKey) => { + const mode = disabledMenuBehaviorByKey[featureKey]; + if (mode === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE) return mode; + return ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON; + }; + const tasksDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_TASKS); + const budgetingDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_BUDGETING); + const governanceDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_GOVERNANCE); + const lifecycleDisabledMenuBehavior = getDisabledMenuBehavior(ORG_BETA_FEATURE_ORG_LIFECYCLE); + const verificationRequestsDisabledMenuBehavior = getDisabledMenuBehavior( + ORG_BETA_FEATURE_ORG_VERIFICATION_REQUESTS + ); + const tasksHidden = tasksComingSoon && tasksDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const budgetingHidden = + budgetingComingSoon && budgetingDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const governanceHidden = + governanceComingSoon && governanceDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const lifecycleHidden = + lifecycleComingSoon && lifecycleDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; + const verificationRequestsHidden = + verificationRequestsComingSoon && + verificationRequestsDisabledMenuBehavior === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE; useEffect(() => { if (orgData.loading) return; @@ -333,7 +375,8 @@ function ClubDash(){ label: 'Tasks', icon: 'mdi:check-all', key: 'tasks', - comingSoon: tasksComingSoon, + hidden: tasksHidden, + comingSoon: tasksComingSoon && !tasksHidden, element: tasksComingSoon ? null : ( Loading tasks…}> @@ -402,7 +445,9 @@ function ClubDash(){ { label: 'Lifecycle', icon: 'mdi:state-machine', - element: ( + hidden: lifecycleHidden, + comingSoon: lifecycleComingSoon && !lifecycleHidden, + element: lifecycleComingSoon ? null : ( ) }, @@ -420,12 +467,20 @@ function ClubDash(){ label: 'Budgets', icon: 'mdi:cash-multiple', requiresFinances: true, - element: + hidden: budgetingHidden, + comingSoon: budgetingComingSoon && !budgetingHidden, + element: budgetingComingSoon ? null : ( + + ) }, { label: 'Verification Requests', icon: 'mdi:shield-check', - element: + hidden: verificationRequestsHidden, + comingSoon: verificationRequestsComingSoon && !verificationRequestsHidden, + element: verificationRequestsComingSoon ? null : ( + + ) }, { label: 'Danger Zone', @@ -454,10 +509,13 @@ function ClubDash(){ userPermissions.canAccessBudgets; return { ...item, - subItems: item.subItems.filter((sub) => !sub.requiresFinances || showBudgets) + subItems: item.subItems.filter( + (sub) => (!sub.requiresFinances || showBudgets) && !sub.hidden + ) }; }); return withFilteredSettings.filter((item) => { + if (item.hidden) return false; if (isAdminView && isSiteAdmin) return true; if (!item.requiresPermission) return true; return userPermissions[item.requiresPermission]; @@ -474,7 +532,17 @@ function ClubDash(){ adminBypass, isAdminView, isSiteAdmin, - tasksComingSoon + tasksComingSoon, + tasksHidden, + budgetingComingSoon, + budgetingHidden, + governanceComingSoon, + governanceHidden, + lifecycleComingSoon, + lifecycleHidden, + verificationRequestsComingSoon, + verificationRequestsHidden, + configData ]); menuItemsRef.current = menuItems; diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx index 4852587f..2e3cb39a 100644 --- a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.jsx @@ -4,9 +4,12 @@ import { useGradient } from '../../../../hooks/useGradient'; import { useNotification } from '../../../../NotificationContext'; import apiRequest from '../../../../utils/postRequest'; import { Icon } from '@iconify-icon/react'; +import Popup from '../../../../components/Popup/Popup'; import { ORG_BETA_FEATURE_KEYS, - ORG_BETA_FEATURE_CATALOG + ORG_BETA_FEATURE_CATALOG, + ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON, + ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE } from '../../../../constants/orgBetaFeatures'; import './OrgBetaFeatures.scss'; @@ -33,6 +36,8 @@ function OrgBetaFeatures() { page: 1 }); const [savingByOrgId, setSavingByOrgId] = useState({}); + const [savingDisabledBehavior, setSavingDisabledBehavior] = useState(false); + const [showDisabledBehaviorPopup, setShowDisabledBehaviorPopup] = useState(false); useEffect(() => { const id = window.setTimeout(() => { @@ -58,6 +63,18 @@ function OrgBetaFeatures() { const { data: orgs, loading, error, refetch } = useFetch( `/org-management/organizations?${listQuery}` ); + const { data: configRes, refetch: refetchConfig } = useFetch('/org-management/config'); + const disabledMenuBehaviorByKey = useMemo(() => { + const fromConfig = configRes?.data?.betaFeatures?.disabledMenuBehaviorByKey || {}; + return ORG_BETA_FEATURE_KEYS.reduce((acc, key) => { + const mode = fromConfig[key]; + acc[key] = + mode === ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE + ? ORG_BETA_DISABLED_MENU_BEHAVIOR_HIDE + : ORG_BETA_DISABLED_MENU_BEHAVIOR_COMING_SOON; + return acc; + }, {}); + }, [configRes]); const pagination = orgs?.pagination; const totalItems = pagination?.total ?? 0; @@ -129,6 +146,46 @@ function OrgBetaFeatures() { [patchOrgBetaFeatures] ); + const updateDisabledBehavior = useCallback( + async (featureKey, nextMode) => { + setSavingDisabledBehavior(true); + try { + const nextMap = { + ...disabledMenuBehaviorByKey, + [featureKey]: nextMode + }; + const res = await apiRequest( + '/org-management/config', + { betaFeatures: { disabledMenuBehaviorByKey: nextMap } }, + { method: 'PUT' } + ); + if (res?.success) { + addNotification({ + title: 'Saved', + message: 'Disabled beta menu behavior updated.', + type: 'success' + }); + refetchConfig(); + } else { + addNotification({ + title: 'Error', + message: res?.message || 'Could not save disabled menu behavior.', + type: 'error' + }); + } + } catch (e) { + addNotification({ + title: 'Error', + message: e?.message || 'Could not save disabled menu behavior.', + type: 'error' + }); + } finally { + setSavingDisabledBehavior(false); + } + }, + [addNotification, disabledMenuBehaviorByKey, refetchConfig] + ); + const isInitialLoad = loading && orgs == null; if (isInitialLoad) { @@ -181,6 +238,14 @@ function OrgBetaFeatures() { + {error && orgs != null && ( @@ -286,6 +351,46 @@ function OrgBetaFeatures() { )} + setShowDisabledBehaviorPopup(false)} + customClassName="org-beta-features__config-popup" + > +
+

Disabled menu behavior

+

+ Choose what users see when a beta feature is disabled for an organization. +

+
+ {features.map((feature) => ( + + ))} +
+
+
); diff --git a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss index 9101754c..d9545fb5 100644 --- a/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss +++ b/frontend/src/pages/FeatureAdmin/OrgManagement/OrgBetaFeatures/OrgBetaFeatures.scss @@ -1,28 +1,5 @@ .org-beta-features { - &__header { - // h1 { - // margin: 0 0 0.35rem; - // font-size: 1.75rem; - // } - - // p { - // margin: 0; - // color: var(--text-muted, #5c6570); - // max-width: 40rem; - // } - - // img { - // position: absolute; - // right: 0; - // top: 0; - // width: 120px; - // height: auto; - // opacity: 0.35; - // pointer-events: none; - // } - } - .content { display: flex; flex-direction: column; @@ -67,12 +44,74 @@ font: inherit; } + &__config-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + background: var(--surface, #fff); + color: inherit; + font: inherit; + cursor: pointer; + transition: background 140ms ease; + + &:hover { + background: #f5f7f8; + } + } + &__meta { font-size: 0.9rem; color: var(--text-muted, #5c6570); margin: 0 0 0.75rem; } + &__disabled-config { + border-radius: 10px; + padding: 0.9rem; + background: var(--surface, #fff); + + h2 { + margin: 0 0 0.35rem; + font-size: 1rem; + } + + p { + margin: 0 0 0.75rem; + color: var(--text-muted, #5c6570); + font-size: 0.9rem; + } + } + + &__config-popup { + width: min(680px, calc(100vw - 32px)); + } + + &__disabled-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.65rem; + } + + &__disabled-item { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + + select { + font: inherit; + font-weight: 400; + padding: 0.4rem 0.5rem; + border-radius: 8px; + border: 1px solid var(--border-color, #d0d7de); + background: var(--surface, #fff); + } + } + &__table-wrap { overflow-x: auto; border: 1px solid var(--border-color, #d0d7de); From a05d5e8a9fa2862635e0c4be53007a56ae544a7e Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:16:13 -0400 Subject: [PATCH 2/4] minor logo patch --- frontend/src/assets/Brand Image/ADMIN.svg | 17 ++++++----------- frontend/src/assets/Brand Image/BEACON1.svg | 4 ++++ .../OrgSettings/components/GeneralSettings.jsx | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 frontend/src/assets/Brand Image/BEACON1.svg diff --git a/frontend/src/assets/Brand Image/ADMIN.svg b/frontend/src/assets/Brand Image/ADMIN.svg index f7bfca26..5f7ec97b 100644 --- a/frontend/src/assets/Brand Image/ADMIN.svg +++ b/frontend/src/assets/Brand Image/ADMIN.svg @@ -1,25 +1,20 @@ - - - - + + + - + - + - - - - - + diff --git a/frontend/src/assets/Brand Image/BEACON1.svg b/frontend/src/assets/Brand Image/BEACON1.svg new file mode 100644 index 00000000..30269811 --- /dev/null +++ b/frontend/src/assets/Brand Image/BEACON1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx b/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx index 4424a662..42a61342 100644 --- a/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx +++ b/frontend/src/pages/ClubDash/OrgSettings/components/GeneralSettings.jsx @@ -425,7 +425,7 @@ const GeneralSettings = ({ org, expandedClass, adminBypass = false }) => { ) }, { - title: 'Weekly Meeting Time - NOTE: on reoccuring meeting refactor replace this with a create event button that creates popup for event creation w/ reoccurance embeded.', + title: 'Weekly Meeting Time ', subtitle: 'The time of your weekly meeting', action: (
From 02ae8abf09fd965cc8d32693e6013651b5022203 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:55:44 -0400 Subject: [PATCH 3/4] MER-187: expand admin analytics interactions and add lightweight frontend caching. Improve the admin analytics detailed graph workflow with richer controls, keybind hints, padded time buckets, and reusable tooltip UI while reducing repeated load on heavy admin endpoints through TTL-based client caching. --- .../KeybindTooltip/KeybindTooltip.jsx | 32 ++ .../KeybindTooltip/KeybindTooltip.scss | 37 +++ frontend/src/hooks/useFetch.js | 50 ++- .../pages/Admin/BadgeManager/BadgeManager.jsx | 6 +- .../AdminPlatformAnalytics.jsx | 304 ++++++++++++++++-- .../AdminPlatformAnalytics.scss | 189 ++++++----- .../AdminPlatformMetricChart.jsx | 7 +- .../AdminPlatformMetricChart.scss | 124 +++++++ .../Admin/General/SiteHealth/SiteHealth.jsx | 6 +- .../pages/Admin/ManageUsers/ManageUsers.jsx | 4 +- .../Admin/OperatorHubMode/OperatorHubMode.jsx | 5 +- .../PlatformAdminsPage/PlatformAdminsPage.jsx | 14 +- .../src/pages/Admin/QRManager/QRManager.jsx | 10 +- .../WebSocketConnections.jsx | 6 +- frontend/src/utils/analyticsDashboardUtils.js | 9 +- 15 files changed, 667 insertions(+), 136 deletions(-) create mode 100644 frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx create mode 100644 frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss create mode 100644 frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss diff --git a/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx new file mode 100644 index 00000000..6820aade --- /dev/null +++ b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import './KeybindTooltip.scss'; + +/** + * Reusable keybind tooltip for hover-triggered UI hints. + * + * Intended usage: + * - Place inside a parent trigger that has `position: relative`. + * - Reveal on hover/focus from the parent container CSS. + * - Keep tooltip non-interactive (`pointer-events: none`) for simple hint behavior. + * + * @param {Object} props + * @param {string|React.ReactNode} props.label - Tooltip body label (e.g. "Month") + * @param {string|React.ReactNode} [props.keybind] - Optional keyboard hint shown in a boxed (e.g. "M") + * @param {string} [props.className] - Optional class name for placement/style overrides + * + * @example + * + */ +function KeybindTooltip({ label, keybind, className = '' }) { + return ( + + {label} + {keybind ? {keybind} : null} + + ); +} + +export default KeybindTooltip; diff --git a/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss new file mode 100644 index 00000000..797a0975 --- /dev/null +++ b/frontend/src/components/Interface/KeybindTooltip/KeybindTooltip.scss @@ -0,0 +1,37 @@ +.keybind-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%) translateY(4px); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + z-index: 20; + white-space: nowrap; + padding: 0.22rem 0.45rem; + border-radius: 6px; + border: 1px solid rgba(30, 41, 59, 0.25); + background: #0f172a; + color: #f8fafc; + font-size: 0.7rem; + line-height: 1.2; + font-weight: 500; + + kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.1rem; + height: 1rem; + margin-left: 0.2rem; + padding: 0 0.2rem; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.65); + background: rgba(148, 163, 184, 0.18); + color: #e2e8f0; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.01em; + font-family: 'Inter', sans-serif; + } +} diff --git a/frontend/src/hooks/useFetch.js b/frontend/src/hooks/useFetch.js index a462cfcb..2e18e65f 100644 --- a/frontend/src/hooks/useFetch.js +++ b/frontend/src/hooks/useFetch.js @@ -1,6 +1,24 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import axios from "axios"; +const DEFAULT_CACHE_TTL_MS = 60 * 1000; +const fetchResponseCache = new Map(); + +function stableSerialize(value) { + if (value === null || value === undefined) return ""; + if (typeof value !== "object") return String(value); + if (Array.isArray(value)) return `[${value.map((v) => stableSerialize(v)).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${k}:${stableSerialize(value[k])}`).join(",")}}`; +} + +function buildFetchCacheKey(url, options) { + const method = (options?.method || "GET").toUpperCase(); + const paramsPart = stableSerialize(options?.params || {}); + const dataPart = stableSerialize(options?.data || null); + return `${method}|${url}|params:${paramsPart}|data:${dataPart}`; +} + /** * Authenticated request with credentials and 401 refresh retry. * Use for one-off mutations (POST/PUT/DELETE) so components don't use axios directly. @@ -64,16 +82,36 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { data: options.data || null, headers: options.headers || {}, params: options.params || {}, - }), [options.method, options.data, options.headers, options.params]); + cache: options.cache || null, + }), [options.method, options.data, options.headers, options.params, options.cache]); const fetchData = useCallback(async (options = {}) => { - const { silent = false } = options; + const { silent = false, bypassCache = false } = options; // Don't fetch if URL is null or undefined if (!url) { setLoading(false); setData(null); return; } + + const requestMethod = (memoizedOptions.method || "GET").toUpperCase(); + const cacheConfig = memoizedOptions.cache; + const useCache = requestMethod === "GET" && Boolean(cacheConfig?.enabled); + const cacheKey = useCache ? buildFetchCacheKey(url, memoizedOptions) : null; + const cacheTtlMs = + typeof cacheConfig?.ttlMs === "number" && cacheConfig.ttlMs > 0 + ? cacheConfig.ttlMs + : DEFAULT_CACHE_TTL_MS; + + if (useCache && cacheKey && !bypassCache) { + const cached = fetchResponseCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < cacheTtlMs) { + setData(cached.data); + setError(null); + setLoading(false); + return; + } + } if (!silent) setLoading(true); setError(null); @@ -87,6 +125,9 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { params: memoizedOptions.params, }); setData(response.data); + if (useCache && cacheKey) { + fetchResponseCache.set(cacheKey, { data: response.data, timestamp: Date.now() }); + } } catch (err) { if (err.response?.status === 401 && (err.response?.data?.code === 'TOKEN_EXPIRED' || err.response?.data?.code === 'NO_TOKEN')) { @@ -102,6 +143,9 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { params: memoizedOptions.params, }); setData(retryResponse.data); + if (useCache && cacheKey) { + fetchResponseCache.set(cacheKey, { data: retryResponse.data, timestamp: Date.now() }); + } } catch (refreshError) { const refreshCode = refreshError.response?.data?.code; const shouldForceLogin = @@ -130,6 +174,6 @@ export const useFetch = (url, options = { method: "GET", data: null }) => { fetchData(); }, [fetchData]); - const refetch = useCallback((opts) => fetchData(opts), [fetchData]); + const refetch = useCallback((opts = {}) => fetchData({ bypassCache: true, ...opts }), [fetchData]); return { data, loading, error, refetch }; }; diff --git a/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx b/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx index 18933891..68ce6e57 100644 --- a/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx +++ b/frontend/src/pages/Admin/BadgeManager/BadgeManager.jsx @@ -7,8 +7,12 @@ import Popup from '../../../components/Popup/Popup'; import { useNotification } from '../../../NotificationContext'; import postRequest from '../../../utils/postRequest'; +const BADGE_MANAGER_CACHE_TTL_MS = 60 * 1000; + const BadgeManager = ({}) => { - const badgeGrants = useFetch('/get-badge-grants'); + const badgeGrants = useFetch('/get-badge-grants', { + cache: { enabled: true, ttlMs: BADGE_MANAGER_CACHE_TTL_MS } + }); const { addNotification } = useNotification(); const [createPopup, setCreatePopup] = useState(false); const [newBadge, setNewBadge] = useState({ diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx index 3f8959a2..84920731 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx @@ -15,11 +15,15 @@ import { subWeeks, addWeeks, subDays, - addDays + addDays, + startOfHour, + addHours } from 'date-fns'; import { Icon } from '@iconify-icon/react'; import { useFetch } from '../../../../hooks/useFetch'; import useAuth from '../../../../hooks/useAuth'; +import Popup from '../../../../components/Popup/Popup'; +import KeybindTooltip from '../../../../components/Interface/KeybindTooltip/KeybindTooltip'; import KpiCard from '../../../../components/Analytics/Dashboard/KpiCard'; import ComparisonBadge from '../../../../components/Analytics/Dashboard/ComparisonBadge'; import { @@ -34,6 +38,12 @@ import 'rsuite/DateRangePicker/styles/index.css'; const TREND_METRICS_PARAM = 'screen_views,sessions,unique_visitors,explore_screen_views,new_users'; +const ADMIN_ANALYTICS_CACHE_TTL_MS = 2 * 60 * 1000; +const QUICK_RANGE_OPTIONS = [ + { id: 'month', label: 'month', shortcut: 'M' }, + { id: 'week', label: 'week', shortcut: 'W' }, + { id: 'day', label: 'day', shortcut: 'D' } +]; const TREND_METRIC_DEFS = [ { key: 'screen_views', title: 'Page views', color: '#45A1FC' }, @@ -87,6 +97,72 @@ function parseBucketBoundary(bucketValue, boundary = 'start') { return parsed; } +function getRangeGranularityRank(mode) { + if (mode === 'all') return 0; + if (mode === 'month') return 1; + if (mode === 'week') return 2; + if (mode === 'day') return 3; + if (mode === 'custom') return 4; + return 5; +} + +function toBucketDate(bucket, granularity) { + if (!bucket) return null; + if (granularity === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(String(bucket))) { + const parsed = parseISO(`${bucket}T00:00:00`); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + const parsed = new Date(bucket); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function formatBucketValue(date, granularity, sampleBucket) { + if (granularity === 'day') { + return format(date, 'yyyy-MM-dd'); + } + if (granularity === 'hour') { + const sample = String(sampleBucket || ''); + if (sample.endsWith('Z') || sample.includes('.')) { + return date.toISOString(); + } + return format(date, "yyyy-MM-dd'T'HH:mm:ss"); + } + return date.toISOString(); +} + +function padTimeseriesRows(rows, { granularity, start, end, capEndAtNow = false }) { + if (!Array.isArray(rows) || !rows.length || !start || !end) return rows; + if (granularity !== 'day' && granularity !== 'hour') return rows; + + const now = new Date(); + const rawEnd = capEndAtNow && end > now ? now : end; + const rangeStart = granularity === 'hour' ? startOfHour(start) : startOfDay(start); + const rangeEnd = granularity === 'hour' ? startOfHour(rawEnd) : startOfDay(rawEnd); + if (rangeEnd < rangeStart) return rows; + + const sampleBucket = rows[0]?.bucket; + const byTs = new Map(); + rows.forEach((row) => { + const dt = toBucketDate(row?.bucket, granularity); + if (!dt) return; + const keyDate = granularity === 'hour' ? startOfHour(dt) : startOfDay(dt); + byTs.set(keyDate.getTime(), Number(row?.value) || 0); + }); + + const padded = []; + let cursor = rangeStart; + while (cursor <= rangeEnd) { + const cursorKey = cursor.getTime(); + padded.push({ + bucket: formatBucketValue(cursor, granularity, sampleBucket), + value: byTs.get(cursorKey) ?? 0 + }); + cursor = granularity === 'hour' ? addHours(cursor, 1) : addDays(cursor, 1); + } + + return padded; +} + function KpiInfoTitle({ label, info }) { return ( @@ -113,6 +189,7 @@ function AdminPlatformAnalytics() { const [debouncedCustomRange, setDebouncedCustomRange] = useState(null); const [chartHoverSync, setChartHoverSync] = useState(null); const [showFiltersPopup, setShowFiltersPopup] = useState(false); + const [showDetailedChartsPopup, setShowDetailedChartsPopup] = useState(false); const [isCustomRangePickerOpen, setIsCustomRangePickerOpen] = useState(false); const handleChartHoverSyncChange = useCallback((signal) => { if (!signal || signal.type === 'leave') { @@ -157,7 +234,8 @@ function AdminPlatformAnalytics() { const { data: snapRes, loading, error, refetch } = useFetch(snapshotUrl, { method: 'GET', - params: snapshotParams + params: snapshotParams, + cache: { enabled: true, ttlMs: ADMIN_ANALYTICS_CACHE_TTL_MS } }); const payload = snapRes?.data; @@ -186,7 +264,8 @@ function AdminPlatformAnalytics() { const prevTsUrl = isAuthenticated && comparisonEnabled && prevTimeseriesParams ? '/dashboard/timeseries' : null; const { data: prevTsRes, loading: prevTsLoading } = useFetch(prevTsUrl, { method: 'GET', - params: prevTimeseriesParams || {} + params: prevTimeseriesParams || {}, + cache: { enabled: true, ttlMs: ADMIN_ANALYTICS_CACHE_TTL_MS } }); const comparePeriodLabel = @@ -205,18 +284,38 @@ function AdminPlatformAnalytics() { const trendCharts = useMemo(() => { const prevInner = prevTsRes?.data; const useFullMonthDayDomain = debouncedMode === 'month' && ts?.granularity === 'day'; + const currentWindow = + debouncedMode === 'custom' && debouncedCustomRange + ? debouncedCustomRange + : computeRange(debouncedMode, debouncedAnchor); + const previousWindowStart = kpi?.windows?.previous?.start ? new Date(kpi.windows.previous.start) : null; + const previousWindowEnd = kpi?.windows?.previous?.end ? new Date(kpi.windows.previous.end) : null; return TREND_METRIC_DEFS.map((def) => { const curRows = ts?.series?.[def.key]; const pRows = comparisonEnabled ? prevInner?.series?.[def.key] : null; const curRowsExcludingEnd = Array.isArray(curRows) && curRows.length > 1 ? curRows.slice(0, -1) : curRows; const pRowsExcludingEnd = Array.isArray(pRows) && pRows.length > 1 ? pRows.slice(0, -1) : pRows; - - if (useFullMonthDayDomain && curRowsExcludingEnd?.length) { + const paddedCurrentRows = padTimeseriesRows(curRowsExcludingEnd, { + granularity: ts?.granularity, + start: currentWindow.start, + end: currentWindow.end, + capEndAtNow: true + }); + const paddedPreviousRows = comparisonEnabled + ? padTimeseriesRows(pRowsExcludingEnd, { + granularity: ts?.granularity, + start: previousWindowStart, + end: previousWindowEnd, + capEndAtNow: false + }) + : pRowsExcludingEnd; + + if (useFullMonthDayDomain && paddedCurrentRows?.length) { const spec = buildComparisonVisxSeriesForCalendarMonthView( debouncedAnchor, - curRowsExcludingEnd, - pRowsExcludingEnd, + paddedCurrentRows, + paddedPreviousRows, def.color, { thisPeriod: 'This period', compare: comparePeriodLabel }, { @@ -231,13 +330,13 @@ function AdminPlatformAnalytics() { showEndGlyph: spec.showEndGlyph, totalValue: metricTotalsFromKpi[def.key] ?? - curRowsExcludingEnd.reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + paddedCurrentRows.reduce((sum, row) => sum + (Number(row?.value) || 0), 0) }; } const { series } = buildComparisonVisxSeries( - curRowsExcludingEnd, - pRowsExcludingEnd, + paddedCurrentRows, + paddedPreviousRows, def.color, { thisPeriod: 'This period', compare: comparePeriodLabel }, { excludePreviousPeriodEnd: false } @@ -249,25 +348,33 @@ function AdminPlatformAnalytics() { showEndGlyph: false, totalValue: metricTotalsFromKpi[def.key] ?? - (curRowsExcludingEnd || []).reduce((sum, row) => sum + (Number(row?.value) || 0), 0) + (paddedCurrentRows || []).reduce((sum, row) => sum + (Number(row?.value) || 0), 0) }; }); - }, [ts, prevTsRes, comparisonEnabled, comparePeriodLabel, debouncedMode, debouncedAnchor, previousPeriodMode, metricTotalsFromKpi]); + }, [ts, prevTsRes, comparisonEnabled, comparePeriodLabel, debouncedMode, debouncedAnchor, previousPeriodMode, metricTotalsFromKpi, debouncedCustomRange, kpi]); const showCompare = previousPeriodMode !== 'none' && kpi?.deltas; const handleRangeModeChange = useCallback((mode) => { + const previousMode = rangeMode; + const previousWindow = + previousMode === 'custom' && customRange + ? customRange + : computeRange(previousMode, anchorDate); + const shrinkingToMoreGranular = + getRangeGranularityRank(mode) > getRangeGranularityRank(previousMode); + const baseDate = shrinkingToMoreGranular ? previousWindow.start : new Date(); + setRangeMode(mode); if (mode !== 'custom') { setCustomRange(null); } if (mode === 'all') return; if (mode === 'custom') return; - const now = new Date(); - if (mode === 'month') setAnchorDate(startOfMonth(now)); - else if (mode === 'week') setAnchorDate(startOfWeek(now, { weekStartsOn: 0 })); - else setAnchorDate(now); - }, []); + if (mode === 'month') setAnchorDate(startOfMonth(baseDate)); + else if (mode === 'week') setAnchorDate(startOfWeek(baseDate, { weekStartsOn: 0 })); + else setAnchorDate(startOfDay(baseDate)); + }, [rangeMode, customRange, anchorDate]); const handleTrendRangeSelect = useCallback(({ startXValue, endXValue }) => { if (!startXValue || !endXValue) return; const normalizedStart = parseBucketBoundary(startXValue, 'start'); @@ -307,21 +414,43 @@ function AdminPlatformAnalytics() { if (rangeMode === 'month') setAnchorDate((d) => subMonths(startOfMonth(d), 1)); else if (rangeMode === 'week') setAnchorDate((d) => subWeeks(startOfWeek(d, { weekStartsOn: 0 }), 1)); else if (rangeMode === 'day') setAnchorDate((d) => subDays(d, 1)); - }, [rangeMode]); + else if (rangeMode === 'custom' && customRange?.start && customRange?.end) { + const windowMs = customRange.end.getTime() - customRange.start.getTime(); + if (windowMs <= 0) return; + const nextStart = new Date(customRange.start.getTime() - windowMs); + const nextEnd = new Date(customRange.end.getTime() - windowMs); + setCustomRange({ start: nextStart, end: nextEnd }); + setAnchorDate(nextStart); + } + }, [rangeMode, customRange]); const navNext = useCallback(() => { if (rangeMode === 'month') setAnchorDate((d) => addMonths(startOfMonth(d), 1)); else if (rangeMode === 'week') setAnchorDate((d) => addWeeks(startOfWeek(d, { weekStartsOn: 0 }), 1)); else if (rangeMode === 'day') setAnchorDate((d) => addDays(d, 1)); - }, [rangeMode]); + else if (rangeMode === 'custom' && customRange?.start && customRange?.end) { + const windowMs = customRange.end.getTime() - customRange.start.getTime(); + if (windowMs <= 0) return; + const nextStart = new Date(customRange.start.getTime() + windowMs); + const nextEnd = new Date(customRange.end.getTime() + windowMs); + setCustomRange({ start: nextStart, end: nextEnd }); + setAnchorDate(nextStart); + } + }, [rangeMode, customRange]); useEffect(() => { const handleKeyDown = (event) => { if (rangeMode === 'all' || isTypingTarget(event.target)) return; + const key = String(event.key || '').toLowerCase(); if (showFiltersPopup && event.key === 'Escape') { setShowFiltersPopup(false); return; } - if (event.key === 'ArrowLeft') { + if (key === 'm' || key === 'w' || key === 'd') { + event.preventDefault(); + if (key === 'm') handleRangeModeChange('month'); + else if (key === 'w') handleRangeModeChange('week'); + else handleRangeModeChange('day'); + } else if (event.key === 'ArrowLeft') { event.preventDefault(); navPrev(); } else if (event.key === 'ArrowRight') { @@ -332,7 +461,7 @@ function AdminPlatformAnalytics() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [rangeMode, navPrev, navNext, showFiltersPopup]); + }, [rangeMode, navPrev, navNext, showFiltersPopup, handleRangeModeChange]); if (!isAuthenticated) { return null; @@ -385,6 +514,17 @@ function AdminPlatformAnalytics() { adjacent: 'Previous period', lastYear: 'Same window last year' }; + const detailedRangeBoundsLabel = (() => { + if (rangeMode === 'custom' && customRange) { + return `${format(customRange.start, 'MMM d, yyyy h:mm aa')} – ${format(customRange.end, 'MMM d, yyyy h:mm aa')}`; + } + if (rangeMode === 'all') { + const allRange = computeRange('all', anchorDate); + return `${format(allRange.start, 'MMM d, yyyy')} – ${format(allRange.end, 'MMM d, yyyy')}`; + } + const computed = computeRange(rangeMode, anchorDate); + return `${format(computed.start, 'MMM d, yyyy')} – ${format(computed.end, 'MMM d, yyyy')}`; + })(); return (
@@ -422,14 +562,18 @@ function AdminPlatformAnalytics() {
)}
- {['month', 'week', 'day'].map((m) => ( + {QUICK_RANGE_OPTIONS.map(({ id, label, shortcut }) => ( ))}
@@ -481,10 +625,16 @@ function AdminPlatformAnalytics() { ))}
@@ -672,14 +822,23 @@ function AdminPlatformAnalytics() {

Trends

- +
+ + +
{trendCharts.map(({ def, series, xDomain, showEndGlyph, totalValue }) => ( @@ -703,6 +862,81 @@ function AdminPlatformAnalytics() {
+ setShowDetailedChartsPopup(false)} + customClassName="wider-content admin-platform-analytics__detailed-popup" + > +
+
+

Detailed trend graphs

+

Expanded charts with denser point-level detail. Uses the same date range and comparison settings.

+
+
+
+ Date bounds: {detailedRangeBoundsLabel} +
+
+
+ {QUICK_RANGE_OPTIONS.map(({ id, label, shortcut }) => ( + + ))} +
+
+ +
+
+
+
+ {trendCharts.map(({ def, series, xDomain, showEndGlyph, totalValue }) => ( + + ))} +
+
+
+

Mobile app snapshot diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss index 9bf8ebbd..da379e1b 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss @@ -217,6 +217,16 @@ } } + &__kbd-tooltip-button { + position: relative; + overflow: visible; + + &:hover .keybind-tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + &__kpis.analytics-container { margin-bottom: 1rem; display: grid; @@ -295,6 +305,12 @@ } } + &__chart-actions { + display: flex; + align-items: center; + gap: 0.45rem; + } + &__chart-reset-btn { padding: 0.3rem 0.65rem; font-size: 0.78rem; @@ -309,6 +325,97 @@ } } + &__detailed-popup { + max-height: 92vh; + max-width: min(1200px, 96vw) !important; + + .close-popup { + top: 12px; + right: 12px; + } + } + + &__detailed-popup-body { + padding-top: 1.2rem; + display: flex; + flex-direction: column; + max-height: calc(92vh - 40px); + overflow: hidden; + } + + &__detailed-popup-header { + margin-bottom: 0.7rem; + + h3 { + margin: 0; + font-size: 1.1rem; + } + + p { + margin: 0.25rem 0 0 0; + font-size: 0.84rem; + color: #64748b; + } + } + + &__detailed-popup-charts { + display: flex; + flex-direction: column; + gap: 0.7rem; + border-top: 1px solid var(--lighterborder); + padding-top: 0.7rem; + overflow-y: auto; + min-height: 0; + padding-right: 4px; + } + + &__detailed-popup-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.6rem; + margin-bottom: 0.65rem; + padding: 0.55rem; + border: 1px solid var(--lighterborder); + border-radius: 8px; + background: color-mix(in srgb, var(--background, #fff) 94%, #94a3b8 6%); + } + + &__detailed-popup-bounds { + margin-bottom: 0; + font-size: 0.8rem; + color: #334155; + font-weight: 500; + } + + &__detailed-popup-bounds--prominent { + display: flex; + align-items: center; + min-height: 34px; + padding: 0.42rem 0.65rem; + border: 1px solid rgba(37, 99, 235, 0.28); + border-radius: 7px; + background: rgba(59, 130, 246, 0.1); + color: #1e3a8a; + font-size: 0.82rem; + white-space: nowrap; + + strong { + margin-right: 0.3rem; + font-weight: 700; + } + } + + &__detailed-popup-controls-right { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + } + &__chart { height: 280px; position: relative; @@ -416,86 +523,4 @@ } } - /* Match legacy `.visit-chart` / AnalyticsChart look (General admin) */ - .admin-platform-trend-chart.visit-chart { - width: 100%; - padding: 14px 14px 10px; - background: transparent; - border-radius: 0; - box-shadow: none; - border: none; - box-sizing: border-box; - margin-bottom: 0; - } - - .admin-platform-trend-chart .header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - margin-left: 8px; - } - - .admin-platform-trend-chart .header h2 { - font-size: 0.85rem; - font-weight: 500; - color: var(--lighter-text); - font-family: 'Inter', sans-serif; - margin: 0; - text-transform: uppercase; - } - - .admin-platform-trend-chart__total { - margin-right: 10px; - font-size: 1rem; - font-weight: 600; - color: var(--text, #111827); - - } - - .admin-platform-trend-chart__loading { - font-size: 0.75rem; - color: #94a3b8; - white-space: nowrap; - } - - .chart-empty-visx { - display: flex; - align-items: center; - justify-content: center; - min-height: 200px; - color: #94a3b8; - font-size: 0.875rem; - } - - /* Visx tooltip (EventDashboardChart) — EventDashboard.scss may not load here */ - .rsvp-chart-tooltip { - padding: 8px 10px; - background: var(--background, #fff); - border: 1px solid var(--lighterborder, #e5e7eb); - border-radius: 8px; - font-size: 12px; - box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.08)); - } - - .rsvp-chart-tooltip-date { - font-weight: 600; - margin-bottom: 4px; - color: var(--text, #111827); - } - - .rsvp-chart-tooltip-row { - display: flex; - align-items: center; - gap: 6px; - margin-top: 2px; - color: var(--lighter-text, #64748b); - } - - .rsvp-chart-tooltip-dot { - width: 8px; - height: 8px; - border-radius: 999px; - flex-shrink: 0; - } } diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx index e74ae905..306fe717 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.jsx @@ -1,6 +1,7 @@ import React from 'react'; import EventDashboardChart from '../../../ClubDash/EventsManagement/components/EventDashboard/components/EventDashboardChart/EventDashboardChart'; import { formatBucketAxisLabel } from '../../../../utils/analyticsDashboardUtils'; +import './AdminPlatformMetricChart.scss'; /** * Single metric chart styled like legacy `.visit-chart` (General / Analytics), @@ -20,14 +21,15 @@ function AdminPlatformMetricChart({ hoverSyncSignal, onHoverSyncChange, enableRangeSelection = false, - onRangeSelect + onRangeSelect, + detailedView = false }) { const xTickFormat = React.useCallback((x) => formatBucketAxisLabel(x, granularity), [granularity]); const isEmpty = !series?.length || series.every((s) => !s?.data?.length); return ( -
+
{totalValue != null ? ( @@ -48,6 +50,7 @@ function AdminPlatformMetricChart({ showLine showGlyph={showEndGlyph} showGlyphPrimaryOnly={showEndGlyph} + showPointMarkers={detailedView} xDomain={xDomain} emptyMessage={emptyMessage} xTickFormat={xTickFormat} diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss new file mode 100644 index 00000000..b5373ddd --- /dev/null +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformMetricChart.scss @@ -0,0 +1,124 @@ +.admin-platform-trend-chart.visit-chart { + width: 100%; + padding: 14px 14px 10px; + background: transparent; + border-radius: 0; + box-shadow: none; + border: none; + box-sizing: border-box; + margin-bottom: 0; +} + +.admin-platform-trend-chart .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + margin-left: 8px; +} + +.admin-platform-trend-chart .header h2 { + font-size: 0.85rem; + font-weight: 500; + color: var(--lighter-text); + font-family: 'Inter', sans-serif; + margin: 0; + text-transform: uppercase; +} + +.admin-platform-trend-chart__total { + margin-right: 10px; + font-size: 1rem; + font-weight: 600; + color: var(--text, #111827); +} + +.admin-platform-trend-chart__loading { + font-size: 0.75rem; + color: #94a3b8; + white-space: nowrap; +} + +.admin-platform-trend-chart .chart-container-visx { + width: 100%; + min-height: 160px; + overflow: visible; +} + +.admin-platform-trend-chart .chart-container-visx > div { + width: 100% !important; +} + +.admin-platform-trend-chart--detailed { + border: 1px solid var(--lighterborder); + border-radius: 10px; + padding: 14px; + background: var(--background, #fff); +} + +.chart-empty-visx { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #94a3b8; + font-size: 0.875rem; +} + +/* Keep tooltip visuals tied to chart component usage, not parent pages. */ +.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: 8px 10px; + background: var(--background, #fff); + border: 1px solid var(--lighterborder, #e5e7eb); + border-radius: 8px; + font-size: 12px; + box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.08)); +} + +.rsvp-chart-tooltip-date { + font-weight: 600; + margin-bottom: 4px; + color: var(--text, #111827); +} + +.rsvp-chart-tooltip-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + color: var(--lighter-text, #64748b); +} + +.rsvp-chart-tooltip-dot { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; +} diff --git a/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx b/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx index 7dc74fea..2fe50c77 100644 --- a/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx +++ b/frontend/src/pages/Admin/General/SiteHealth/SiteHealth.jsx @@ -5,6 +5,8 @@ import { Icon } from '@iconify-icon/react/dist/iconify.mjs'; import PulseDot from '../../../../components/Interface/PulseDot/PulseDot'; import AnimatedNumber from '../../../../components/Interface/AnimatedNumber/AnimatedNumber'; +const SITE_HEALTH_CACHE_TTL_MS = 5000; + function formatUptime(seconds) { const d = Math.floor(seconds / (3600 * 24)); const h = Math.floor((seconds % (3600 * 24)) / 3600); @@ -20,7 +22,9 @@ function formatUptime(seconds) { } const SiteHealth = ({}) => { - const health = useFetch('/health'); + const health = useFetch('/health', { + cache: { enabled: true, ttlMs: SITE_HEALTH_CACHE_TTL_MS } + }); const [good, setGood] = useState(true); const [showDetailed, setShowDetailed] = useState(false); diff --git a/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx b/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx index 286fe732..da76d808 100644 --- a/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx +++ b/frontend/src/pages/Admin/ManageUsers/ManageUsers.jsx @@ -7,6 +7,7 @@ import defaultAvatar from '../../../assets/defaultAvatar.svg'; import './ManageUsers.scss'; const AVAILABLE_ROLES = ['user', 'admin', 'moderator', 'developer', 'oie', 'beta']; +const ADMIN_USER_ANALYTICS_CACHE_TTL_MS = 30 * 1000; function ManageUsers() { const { addNotification } = useNotification(); @@ -19,7 +20,8 @@ function ManageUsers() { const [impersonating, setImpersonating] = useState(false); const { data: analyticsData, loading: analyticsLoading } = useFetch( - selectedUser ? `/admin/user/${selectedUser._id}/analytics?limit=50` : null + selectedUser ? `/admin/user/${selectedUser._id}/analytics?limit=50` : null, + { cache: { enabled: true, ttlMs: ADMIN_USER_ANALYTICS_CACHE_TTL_MS } } ); const fetchUsers = useCallback(async () => { diff --git a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx index a31dde8f..2dcfac58 100644 --- a/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx +++ b/frontend/src/pages/Admin/OperatorHubMode/OperatorHubMode.jsx @@ -34,6 +34,7 @@ const SUGGESTED_COMMUNITY_DEFAULTS = { /** User must type this exactly (trimmed) to confirm a tenant mode switch. */ const CONFIRM_TYPE_PHRASE = 'UPDATE TENANT MODE'; +const ADMIN_PAGE_CACHE_TTL_MS = 60 * 1000; /** * @param {'community_with_defaults' | 'community_layout' | 'classic'} variant @@ -161,7 +162,9 @@ 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; const current = config?.operatorDashboardMode === 'engagement_hub' ? 'engagement_hub' : 'classic'; diff --git a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx index 831a9778..9126dadb 100644 --- a/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx +++ b/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx @@ -8,6 +8,8 @@ import apiRequest from '../../../utils/postRequest'; import '../General/General.scss'; import './PlatformAdminsPage.scss'; +const ADMIN_PAGE_CACHE_TTL_MS = 60 * 1000; + function PlatformAdminsPage() { const { addNotification } = useNotification(); const { AdminGrad } = useGradient(); @@ -18,17 +20,23 @@ function PlatformAdminsPage() { const [savingTenants, setSavingTenants] = useState(false); const [savingAutoClaim, setSavingAutoClaim] = useState(false); - const { data: listResponse, loading, error: fetchError, refetch } = useFetch('/admin/platform-admins'); + const { data: listResponse, loading, error: fetchError, refetch } = useFetch('/admin/platform-admins', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const list = listResponse?.success ? (listResponse.data || []) : []; const { data: tenantConfigResponse, loading: tenantConfigLoading, error: tenantConfigFetchError, refetch: refetchTenantConfig, - } = useFetch('/admin/tenant-config'); + } = useFetch('/admin/tenant-config', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const tenantRows = tenantConfigResponse?.success ? (tenantConfigResponse.data?.tenants || []) : []; - const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config'); + const { data: orgConfigResponse, refetch: refetchOrgConfig } = useFetch('/org-management/config', { + cache: { enabled: true, ttlMs: ADMIN_PAGE_CACHE_TTL_MS }, + }); const orgConfig = orgConfigResponse?.data; const autoClaimEnabled = orgConfig?.autoClaimEnabled ?? false; diff --git a/frontend/src/pages/Admin/QRManager/QRManager.jsx b/frontend/src/pages/Admin/QRManager/QRManager.jsx index 8cc4d690..178a0779 100644 --- a/frontend/src/pages/Admin/QRManager/QRManager.jsx +++ b/frontend/src/pages/Admin/QRManager/QRManager.jsx @@ -12,6 +12,8 @@ import EventDashboardChart from '../../ClubDash/EventsManagement/components/Even import DateTimePicker from '../../../components/DateTimePicker/DateTimePicker'; import './QRManager.scss'; +const QR_MANAGER_CACHE_TTL_MS = 90 * 1000; + function SparklinePreview({ data = [], color = '#4DAA57', width = 72, height = 28, id }) { if (!data?.length) return null; const values = data.map((d) => d.y); @@ -126,8 +128,12 @@ const QRManager = () => { return `${base}?${params}`; }, [dateRangeOverride]); - const { data: qrData, loading: qrLoading, error: qrError, refetch: refetchQRCodes } = useFetch(`/api/qr?${qrParams}`); - const { data: analyticsData, refetch: refetchAnalytics } = useFetch(analyticsUrl); + const { data: qrData, loading: qrLoading, error: qrError, refetch: refetchQRCodes } = useFetch(`/api/qr?${qrParams}`, { + cache: { enabled: true, ttlMs: QR_MANAGER_CACHE_TTL_MS } + }); + const { data: analyticsData, refetch: refetchAnalytics } = useFetch(analyticsUrl, { + cache: { enabled: true, ttlMs: QR_MANAGER_CACHE_TTL_MS } + }); const qrCodes = qrData?.qrCodes || []; const totalPages = qrData?.pagination?.totalPages || 1; diff --git a/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx b/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx index 1a18d2ac..9f0e84cf 100644 --- a/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx +++ b/frontend/src/pages/Admin/WebSocketConnections/WebSocketConnections.jsx @@ -5,6 +5,8 @@ import { useNotification } from '../../../NotificationContext'; import { Icon } from '@iconify-icon/react'; import './WebSocketConnections.scss'; +const WEBSOCKET_CACHE_TTL_MS = 5000; + function formatDuration(ms) { if (!ms) return '—'; const s = Math.floor((Date.now() - ms) / 1000); @@ -18,7 +20,9 @@ const WebSocketConnections = () => { const [disconnecting, setDisconnecting] = useState(null); const [disconnectAllConfirm, setDisconnectAllConfirm] = useState(false); - const { data, loading, error, refetch } = useFetch('/websocket-connections'); + const { data, loading, error, refetch } = useFetch('/websocket-connections', { + cache: { enabled: true, ttlMs: WEBSOCKET_CACHE_TTL_MS } + }); const handleDisconnect = async (socketId) => { setDisconnecting(socketId); diff --git a/frontend/src/utils/analyticsDashboardUtils.js b/frontend/src/utils/analyticsDashboardUtils.js index 06b6792a..05f3c252 100644 --- a/frontend/src/utils/analyticsDashboardUtils.js +++ b/frontend/src/utils/analyticsDashboardUtils.js @@ -185,9 +185,10 @@ export function buildComparisonVisxSeriesForCalendarMonthView( const todayIso = format(now, 'yyyy-MM-dd'); const cutoffIso = viewingCurrentMonth && todayIso < monthEnd ? todayIso : monthEnd; - const curInRange = (currentRows || []) - .filter((r) => r?.bucket && r.bucket >= monthStart && r.bucket <= cutoffIso) - .sort((a, b) => (a.bucket < b.bucket ? -1 : a.bucket > b.bucket ? 1 : 0)); + const curBy = new Map((currentRows || []).map((r) => [r.bucket, Number(r.value) || 0])); + const curInRange = fullDomain + .filter((x) => x >= monthStart && x <= cutoffIso) + .map((x) => ({ bucket: x, value: curBy.get(x) ?? 0 })); if (!curInRange.length) { return { series: [], xDomain: undefined, showEndGlyph: false }; @@ -202,7 +203,7 @@ export function buildComparisonVisxSeriesForCalendarMonthView( .slice(-1)[0] : null; - const cur = curInRange.map((r) => ({ x: r.bucket, y: r.value })); + const cur = curInRange.map((r) => ({ x: r.bucket, y: Number(r.value) || 0 })); const buildPrevAcrossFullMonth = () => fullDomain From dfeaade1b2bfcd59359e4efd0a0dabf6a1cca544 Mon Sep 17 00:00:00 2001 From: AZ0228 <53315675+AZ0228@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:48:59 -0400 Subject: [PATCH 4/4] MER-187: refine admin analytics range arrow interactions. Add hover/active feedback for the time-range arrows, mirror pressed state for keyboard arrow navigation, and debounce arrow-key range changes to avoid rapid repeat navigation. --- .../AdminPlatformAnalytics.jsx | 48 +++++++++++++++++-- .../AdminPlatformAnalytics.scss | 21 ++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx index 84920731..185b5877 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { DateRangePicker } from 'rsuite'; import { @@ -39,6 +39,7 @@ import 'rsuite/DateRangePicker/styles/index.css'; const TREND_METRICS_PARAM = 'screen_views,sessions,unique_visitors,explore_screen_views,new_users'; const ADMIN_ANALYTICS_CACHE_TTL_MS = 2 * 60 * 1000; +const ARROW_KEY_NAV_DEBOUNCE_MS = 140; const QUICK_RANGE_OPTIONS = [ { id: 'month', label: 'month', shortcut: 'M' }, { id: 'week', label: 'week', shortcut: 'W' }, @@ -191,6 +192,9 @@ function AdminPlatformAnalytics() { const [showFiltersPopup, setShowFiltersPopup] = useState(false); const [showDetailedChartsPopup, setShowDetailedChartsPopup] = useState(false); const [isCustomRangePickerOpen, setIsCustomRangePickerOpen] = useState(false); + const [keyboardNavActive, setKeyboardNavActive] = useState(null); + const arrowKeyDebounceRef = useRef(0); + const keyboardActiveTimeoutRef = useRef(null); const handleChartHoverSyncChange = useCallback((signal) => { if (!signal || signal.type === 'leave') { setChartHoverSync(null); @@ -437,6 +441,24 @@ function AdminPlatformAnalytics() { } }, [rangeMode, customRange]); + const flashKeyboardArrowState = useCallback((direction) => { + setKeyboardNavActive(direction); + if (keyboardActiveTimeoutRef.current) { + clearTimeout(keyboardActiveTimeoutRef.current); + } + keyboardActiveTimeoutRef.current = setTimeout(() => { + setKeyboardNavActive(null); + }, 120); + }, []); + + useEffect(() => { + return () => { + if (keyboardActiveTimeoutRef.current) { + clearTimeout(keyboardActiveTimeoutRef.current); + } + }; + }, []); + useEffect(() => { const handleKeyDown = (event) => { if (rangeMode === 'all' || isTypingTarget(event.target)) return; @@ -452,16 +474,24 @@ function AdminPlatformAnalytics() { else handleRangeModeChange('day'); } else if (event.key === 'ArrowLeft') { event.preventDefault(); + const now = Date.now(); + if (now - arrowKeyDebounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + arrowKeyDebounceRef.current = now; + flashKeyboardArrowState('left'); navPrev(); } else if (event.key === 'ArrowRight') { event.preventDefault(); + const now = Date.now(); + if (now - arrowKeyDebounceRef.current < ARROW_KEY_NAV_DEBOUNCE_MS) return; + arrowKeyDebounceRef.current = now; + flashKeyboardArrowState('right'); navNext(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [rangeMode, navPrev, navNext, showFiltersPopup, handleRangeModeChange]); + }, [rangeMode, navPrev, navNext, showFiltersPopup, handleRangeModeChange, flashKeyboardArrowState]); if (!isAuthenticated) { return null; @@ -533,7 +563,12 @@ function AdminPlatformAnalytics() {
{rangeMode !== 'all' && rangeMode !== 'custom' ? (
- @@ -546,7 +581,12 @@ function AdminPlatformAnalytics() { )}`} {rangeMode === 'day' && format(anchorDate, 'MMM d, yyyy')} -
diff --git a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss index da379e1b..ecf262b1 100644 --- a/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss +++ b/frontend/src/pages/Admin/General/AdminPlatformAnalytics/AdminPlatformAnalytics.scss @@ -160,6 +160,27 @@ border-radius: 6px; background: #fff; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease; + + &:hover { + border-color: rgba(69, 161, 252, 0.55); + background: rgba(69, 161, 252, 0.1); + color: #1e40af; + } + + &:active { + border-color: rgba(30, 64, 175, 0.6); + background: rgba(30, 64, 175, 0.16); + color: #1e3a8a; + transform: translateY(1px) scale(0.98); + } + + &.is-key-active { + border-color: rgba(30, 64, 175, 0.6); + background: rgba(30, 64, 175, 0.16); + color: #1e3a8a; + transform: translateY(1px) scale(0.98); + } } }