diff --git a/client/src/App/App.container.jsx b/client/src/App/App.container.jsx index 40b14b8a5..2d2023cfe 100644 --- a/client/src/App/App.container.jsx +++ b/client/src/App/App.container.jsx @@ -2,15 +2,19 @@ import { ApolloProvider } from "@apollo/client"; import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; import { ConfigProvider } from "antd"; import enLocale from "antd/es/locale/en_US"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; +import { connect, useSelector } from "react-redux"; import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; import client from "../utils/GraphQLClient"; import App from "./App"; import * as Sentry from "@sentry/react"; import getTheme from "./themeProvider"; import { CookiesProvider } from "react-cookie"; +import { createStructuredSelector } from "reselect"; +import { selectCurrentUser } from "../redux/user/user.selectors.js"; +import { selectDarkMode } from "../redux/application/application.selectors"; +import { setDarkMode } from "../redux/application/application.actions"; // Base Split configuration const config = { @@ -32,17 +36,47 @@ function SplitClientProvider({ children }) { return children; } -function AppContainer() { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(true); // Manage dark mode state - const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); // Memoize theme +const mapDispatchToProps = (dispatch) => ({ + setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)) +}); - // Set data-theme attribute on document root +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser +}); + +function AppContainer({ currentUser, setDarkMode }) { + const { t } = useTranslation(); + const isDarkMode = useSelector(selectDarkMode); + const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); + + // Update data-theme attribute when dark mode changes useEffect(() => { document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); - return () => document.documentElement.removeAttribute("data-theme"); // Cleanup + return () => document.documentElement.removeAttribute("data-theme"); }, [isDarkMode]); + // Sync Redux darkMode with localStorage on user change + useEffect(() => { + if (currentUser?.uid) { + const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`); + if (savedMode !== null) { + setDarkMode(JSON.parse(savedMode)); + } else { + setDarkMode(false); // default to light mode + } + } else { + setDarkMode(false); + } + // eslint-disable-next-line + }, [currentUser?.uid]); + + // Persist darkMode to localStorage when it or user changes + useEffect(() => { + if (currentUser?.uid) { + localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode)); + } + }, [isDarkMode, currentUser?.uid]); + return ( @@ -60,10 +94,6 @@ function AppContainer() { - {/* Optional: Button to toggle dark mode for testing */} - @@ -72,4 +102,4 @@ function AppContainer() { ); } -export default Sentry.withProfiler(AppContainer); +export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer)); diff --git a/client/src/components/header/buildLeftMenuItems.jsx b/client/src/components/header/buildLeftMenuItems.jsx index 166779572..e742d33d0 100644 --- a/client/src/components/header/buildLeftMenuItems.jsx +++ b/client/src/components/header/buildLeftMenuItems.jsx @@ -25,7 +25,7 @@ import { UsergroupAddOutlined, UserOutlined } from "@ant-design/icons"; -import { FaCalendarAlt, FaCarCrash, FaTasks } from "react-icons/fa"; +import { FaCalendarAlt, FaCarCrash, FaMoon, FaSun, FaTasks } from "react-icons/fa"; import { BsKanban } from "react-icons/bs"; import { FiLogOut } from "react-icons/fi"; import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi"; @@ -33,6 +33,7 @@ import { RiSurveyLine } from "react-icons/ri"; import { IoBusinessOutline } from "react-icons/io5"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx"; +import { Tooltip } from "antd"; const buildLeftMenuItems = ({ t, @@ -41,7 +42,9 @@ const buildLeftMenuItems = ({ setTaskUpsertContext, setReportCenterContext, signOutStart, - accountingChildren + accountingChildren, + handleDarkModeToggle, + darkMode }) => { return [ { @@ -331,6 +334,15 @@ const buildLeftMenuItems = ({ label: t("user.actions.signout"), onClick: () => signOutStart() }, + { + key: "darkmode-toggle", + id: "header-darkmode-toggle", + label: darkMode ? t("user.actions.light_theme") : t("user.actions.dark_theme"), + icon: ( + {darkMode ? : } + ), + onClick: handleDarkModeToggle + }, { key: "help", id: "header-help", diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 3312bf9f4..c3fcd1342 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -12,7 +12,7 @@ import { createStructuredSelector } from "reselect"; import { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.queries.js"; -import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors"; +import { selectDarkMode, selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors"; import { setModalContext } from "../../redux/modals/modals.actions"; import { signOutStart } from "../../redux/user/user.actions"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; @@ -22,13 +22,15 @@ import NotificationCenterContainer from "../notification-center/notification-cen import TaskCenterContainer from "../task-center/task-center.container.jsx"; import buildAccountingChildren from "./buildAccountingChildren.jsx"; import buildLeftMenuItems from "./buildLeftMenuItems.jsx"; +import { toggleDarkMode } from "../../redux/application/application.actions"; // --- Redux mappings --- const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, recentItems: selectRecentItems, selectedHeader: selectSelectedHeader, - bodyshop: selectBodyshop + bodyshop: selectBodyshop, + darkMode: selectDarkMode }); const mapDispatchToProps = (dispatch) => ({ @@ -38,7 +40,8 @@ const mapDispatchToProps = (dispatch) => ({ setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })), signOutStart: () => dispatch(signOutStart()), setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })), - setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })) + setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })), + toggleDarkMode: () => dispatch(toggleDarkMode()) }); // --- Utility Hooks --- @@ -84,22 +87,22 @@ function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnecte } // --- Main Component --- -function Header(props) { - const { - handleMenuClick, - currentUser, - bodyshop, - selectedHeader, - signOutStart, - setBillEnterContext, - setTimeTicketContext, - setPaymentContext, - setReportCenterContext, - recentItems, - setCardPaymentContext, - setTaskUpsertContext - } = props; - +function Header({ + handleMenuClick, + currentUser, + bodyshop, + selectedHeader, + signOutStart, + setBillEnterContext, + setTimeTicketContext, + setPaymentContext, + setReportCenterContext, + recentItems, + setCardPaymentContext, + setTaskUpsertContext, + toggleDarkMode, + darkMode +}) { // Feature flags const { treatments: { ImEXPay, DmsAp, Simple_Inventory } @@ -216,6 +219,10 @@ function Header(props) { [handleMenuClick] ); + const handleDarkModeToggle = useCallback(() => { + toggleDarkMode(); + }, [toggleDarkMode]); + // --- Menu Items --- // built externally to keep the component clean, but on this level to prevent unnecessary re-renders @@ -257,9 +264,21 @@ function Header(props) { setTaskUpsertContext, setReportCenterContext, signOutStart, - accountingChildren + accountingChildren, + darkMode, + handleDarkModeToggle }), - [t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren] + [ + t, + bodyshop, + recentItems, + setTaskUpsertContext, + setReportCenterContext, + signOutStart, + accountingChildren, + darkMode, + handleDarkModeToggle + ] ); const rightMenuItems = useMemo(() => { @@ -292,6 +311,7 @@ function Header(props) { ), onClick: handleTaskCenterClick }); + return items; }, [ scenarioNotificationsOn, diff --git a/client/src/redux/application/application.actions.js b/client/src/redux/application/application.actions.js index 4d362c6d8..c9e972b40 100644 --- a/client/src/redux/application/application.actions.js +++ b/client/src/redux/application/application.actions.js @@ -77,3 +77,11 @@ export const setWssStatus = (status) => ({ type: ApplicationActionTypes.SET_WSS_STATUS, payload: status }); +export const toggleDarkMode = () => ({ + type: ApplicationActionTypes.TOGGLE_DARK_MODE +}); + +export const setDarkMode = (value) => ({ + type: ApplicationActionTypes.SET_DARK_MODE, + payload: value +}); diff --git a/client/src/redux/application/application.reducer.js b/client/src/redux/application/application.reducer.js index 6d5421e27..e531a7d39 100644 --- a/client/src/redux/application/application.reducer.js +++ b/client/src/redux/application/application.reducer.js @@ -16,7 +16,8 @@ const INITIAL_STATE = { }, jobReadOnly: false, partnerVersion: null, - alerts: {} + alerts: {}, + darkMode: false }; const applicationReducer = (state = INITIAL_STATE, action) => { @@ -104,6 +105,16 @@ const applicationReducer = (state = INITIAL_STATE, action) => { alerts: newAlertsMap }; } + case ApplicationActionTypes.TOGGLE_DARK_MODE: + return { + ...state, + darkMode: !state.darkMode + }; + case ApplicationActionTypes.SET_DARK_MODE: + return { + ...state, + darkMode: action.payload + }; default: return state; } diff --git a/client/src/redux/application/application.selectors.js b/client/src/redux/application/application.selectors.js index 6b0d1c2c4..606031be7 100644 --- a/client/src/redux/application/application.selectors.js +++ b/client/src/redux/application/application.selectors.js @@ -24,3 +24,4 @@ export const selectProblemJobs = createSelector([selectApplication], (applicatio export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable); export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus); export const selectAlerts = createSelector([selectApplication], (application) => application.alerts); +export const selectDarkMode = createSelector([selectApplication], (application) => application.darkMode); diff --git a/client/src/redux/application/application.types.js b/client/src/redux/application/application.types.js index 26c1b4c7d..9b1695f32 100644 --- a/client/src/redux/application/application.types.js +++ b/client/src/redux/application/application.types.js @@ -14,6 +14,8 @@ const ApplicationActionTypes = { SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS", SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE", SET_WSS_STATUS: "SET_WSS_STATUS", - ADD_ALERTS: "ADD_ALERTS" + ADD_ALERTS: "ADD_ALERTS", + TOGGLE_DARK_MODE: "TOGGLE_DARK_MODE", + SET_DARK_MODE: "SET_DARK_MODE" }; export default ApplicationActionTypes; diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 482fa095f..545192a24 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -3781,7 +3781,9 @@ "actions": { "changepassword": "Change Password", "signout": "Sign Out", - "updateprofile": "Update Profile" + "updateprofile": "Update Profile", + "light_theme": "Switch to Light Theme", + "dark_theme": "Switch to Dark Theme" }, "errors": { "updating": "Error updating user or association {{message}}" diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 28b7af48e..c4260b4e3 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -3781,7 +3781,9 @@ "actions": { "changepassword": "", "signout": "desconectar", - "updateprofile": "Actualización del perfil" + "updateprofile": "Actualización del perfil", + "light_theme": "", + "dark_theme": "" }, "errors": { "updating": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index e667cc379..df34dcf4e 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -3781,7 +3781,9 @@ "actions": { "changepassword": "", "signout": "Déconnexion", - "updateprofile": "Mettre à jour le profil" + "updateprofile": "Mettre à jour le profil", + "light_theme": "", + "dark_theme": "" }, "errors": { "updating": ""