feature/IO-3499-React-19 - Checkpoint

This commit is contained in:
Dave
2026-01-23 18:12:01 -05:00
parent 7f43ba33f6
commit 745ec57510
8 changed files with 127 additions and 101 deletions

View File

@@ -3,11 +3,10 @@ import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react"; import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US"; import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { CookiesProvider } from "react-cookie"; import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions"; import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors"; import { selectDarkMode } from "../redux/application/application.selectors";
@@ -28,93 +27,99 @@ const config = {
function SplitClientProvider({ children }) { function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" }); const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => { useEffect(() => {
if (splitClient && imexshopid) { if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`); console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
} }
}, [splitClient, imexshopid]); }, [splitClient, imexshopid]);
return children; return children;
} }
const mapStateToProps = createStructuredSelector({ function AppContainer() {
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)),
signOutStart: () => dispatch(signOutStart())
});
function AppContainer({ currentUser, setDarkMode, signOutStart }) {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode); const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
const theme = () => getTheme(isDarkMode);
const antdInput = () => ({ autoComplete: "new-password" });
const antdForm = () => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
});
// Global seamless logout listener with redirect to /signin // Global seamless logout listener with redirect to /signin
useEffect(() => { useEffect(() => {
const handleSeamlessLogout = (event) => { const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return; if (event.data?.type !== "seamlessLogoutRequest") return;
const requestOrigin = event.origin; // Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) { if (currentUser?.authorized !== true) {
window.parent.postMessage( window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
{ type: "seamlessLogoutResponse", status: "already_logged_out" },
requestOrigin || "*"
);
return; return;
} }
signOutStart(); dispatch(signOutStart());
window.parent.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, requestOrigin || "*"); window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
}; };
window.addEventListener("message", handleSeamlessLogout); window.addEventListener("message", handleSeamlessLogout);
return () => { return () => {
window.removeEventListener("message", handleSeamlessLogout); window.removeEventListener("message", handleSeamlessLogout);
}; };
}, [signOutStart, currentUser]); }, [dispatch, currentUser?.authorized]);
// Update data-theme attribute // Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
return () => document.documentElement.removeAttribute("data-theme");
}, [isDarkMode]); }, [isDarkMode]);
// Sync darkMode with localStorage // Sync darkMode with localStorage
useEffect(() => { useEffect(() => {
if (currentUser?.uid) { const uid = currentUser?.uid;
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) { if (!uid) {
setDarkMode(JSON.parse(savedMode)); dispatch(setDarkMode(false));
} else { return;
setDarkMode(false);
}
} else {
setDarkMode(false);
} }
}, [currentUser?.uid, setDarkMode]);
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode // Persist darkMode
useEffect(() => { useEffect(() => {
if (currentUser?.uid) { const uid = currentUser?.uid;
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode)); if (!uid) return;
}
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]); }, [isDarkMode, currentUser?.uid]);
return ( return (
<CookiesProvider> <CookiesProvider>
<ApolloProvider client={client}> <ApolloProvider client={client}>
<ConfigProvider <ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={theme}
form={{
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar /> <GlobalLoadingBar />
<SplitFactoryProvider config={config}> <SplitFactoryProvider config={config}>
<SplitClientProvider> <SplitClientProvider>
@@ -127,4 +132,4 @@ function AppContainer({ currentUser, setDarkMode, signOutStart }) {
); );
} }
export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer)); export default Sentry.withProfiler(AppContainer);

View File

@@ -24,6 +24,7 @@ const NotificationCenterComponent = ({
onNotificationClick, onNotificationClick,
unreadCount, unreadCount,
isEmployee, isEmployee,
isDarkMode,
ref ref
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -112,14 +113,16 @@ const NotificationCenterComponent = ({
<Alert title={t("notifications.labels.employee-notification")} type="warning" /> <Alert title={t("notifications.labels.employee-notification")} type="warning" />
</div> </div>
) : ( ) : (
<Virtuoso <div className={isDarkMode ? "notification-center--dark" : "notification-center--light"} style={{ height: "400px", width: "100%" }}>
ref={virtuosoRef} <Virtuoso
style={{ height: "400px", width: "100%" }} ref={virtuosoRef}
data={notifications} style={{ height: "100%", width: "100%" }}
totalCount={notifications.length} data={notifications}
endReached={loadMore} totalCount={notifications.length}
itemContent={renderNotification} endReached={loadMore}
/> itemContent={renderNotification}
/>
</div>
)} )}
</div> </div>
); );

View File

@@ -5,6 +5,7 @@ import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries"; import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js"; import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js"; import { useIsEmployee } from "../../utils/useIsEmployee.js";
@@ -22,7 +23,7 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
* @returns {JSX.Element} * @returns {JSX.Element}
* @constructor * @constructor
*/ */
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser }) => { const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser, isDarkMode }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -213,13 +214,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
loadMore={loadMore} loadMore={loadMore}
onNotificationClick={handleNotificationClick} onNotificationClick={handleNotificationClick}
unreadCount={unreadCount} unreadCount={unreadCount}
isDarkMode={isDarkMode}
/> />
); );
}; };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
}); });
export default connect(mapStateToProps, null)(NotificationCenterContainer); export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -173,3 +173,11 @@
} }
} }
} }
.notification-center--dark {
color-scheme: dark;
}
.notification-center--light {
color-scheme: light;
}

View File

@@ -22,6 +22,7 @@ const TaskCenterComponent = ({
hasMore, hasMore,
createNewTask, createNewTask,
incompleteTaskCount, incompleteTaskCount,
isDarkMode,
ref ref
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -140,22 +141,24 @@ const TaskCenterComponent = ({
{tasks.length === 0 && !loading ? ( {tasks.length === 0 && !loading ? (
<div className="no-tasks-message">{t("tasks.labels.no_tasks")}</div> <div className="no-tasks-message">{t("tasks.labels.no_tasks")}</div>
) : ( ) : (
<Virtuoso <div className={isDarkMode ? "task-center--dark" : "task-center--light"} style={{ height: "550px", width: "100%" }}>
ref={virtuosoRef} <Virtuoso
style={{ height: "550px", width: "100%" }} ref={virtuosoRef}
groupCounts={groupCounts} style={{ height: "100%", width: "100%" }}
groupContent={groupContent} groupCounts={groupCounts}
itemContent={itemContent} groupContent={groupContent}
endReached={hasMore && !loading ? onLoadMore : undefined} itemContent={itemContent}
components={{ endReached={hasMore && !loading ? onLoadMore : undefined}
Footer: () => components={{
loading ? ( Footer: () =>
<div className="loading-footer"> loading ? (
<Spin /> <div className="loading-footer">
</div> <Spin />
) : null </div>
}} ) : null
/> }}
/>
</div>
)} )}
</div> </div>
); );

View File

@@ -3,6 +3,7 @@ import { useQuery } from "@apollo/client/react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { INITIAL_TASKS, TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket"; import { INITIAL_TASKS, TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket";
import { useIsEmployee } from "../../utils/useIsEmployee"; import { useIsEmployee } from "../../utils/useIsEmployee";
import TaskCenterComponent from "./task-center.component"; import TaskCenterComponent from "./task-center.component";
@@ -11,7 +12,8 @@ import { QUERY_TASKS_NO_DUE_DATE_PAGINATED, QUERY_TASKS_WITH_DUE_DATES } from ".
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
@@ -24,7 +26,8 @@ const TaskCenterContainer = ({
bodyshop, bodyshop,
currentUser, currentUser,
setTaskUpsertContext, setTaskUpsertContext,
incompleteTaskCount incompleteTaskCount,
isDarkMode
}) => { }) => {
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const { isConnected } = useSocket(); const { isConnected } = useSocket();
@@ -128,6 +131,7 @@ const TaskCenterContainer = ({
hasMore={hasMore} hasMore={hasMore}
createNewTask={createNewTask} createNewTask={createNewTask}
incompleteTaskCount={incompleteTaskCount} incompleteTaskCount={incompleteTaskCount}
isDarkMode={isDarkMode}
/> />
); );
}; };

View File

@@ -141,3 +141,11 @@
text-align: center; text-align: center;
} }
} }
.task-center--dark {
color-scheme: dark;
}
.task-center--light {
color-scheme: light;
}

View File

@@ -1,6 +1,5 @@
import "./utils/sentry"; //Must be first. import "./utils/sentry"; // Must be first.
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { ConfigProvider } from "antd";
import Dinero from "dinero.js"; import Dinero from "dinero.js";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
@@ -14,7 +13,7 @@ import { persistor, store } from "./redux/store";
import reportWebVitals from "./reportWebVitals"; import reportWebVitals from "./reportWebVitals";
import "./translations/i18n"; import "./translations/i18n";
import "./utils/CleanAxios"; import "./utils/CleanAxios";
//import * as amplitude from "@amplitude/analytics-browser"; // import * as amplitude from "@amplitude/analytics-browser";
import { PostHogProvider } from "posthog-js/react"; import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js"; import posthog from "posthog-js";
@@ -52,39 +51,32 @@ posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter); const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
const router = sentryCreateBrowserRouter( const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />), {
createRoutesFromElements(<Route path="*" element={<AppContainer />} />), future: {
{ v7_startTransition: true,
future: { v7_relativeSplatPath: true
v7_startTransition: true,
v7_relativeSplatPath: true,
},
} }
); });
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
let styles = const styles =
"font-weight: bold; font-size: 50px;color: red; 6px 6px 0 rgb(226,91,14) , 9px 9px 0 rgb(245,221,8) , 12px 12px 0 rgb(5,148,68) "; "font-weight: bold; font-size: 50px;color: red; 6px 6px 0 rgb(226,91,14) , 9px 9px 0 rgb(245,221,8) , 12px 12px 0 rgb(5,148,68) ";
console.log("%c %s", styles, `VER: ${import.meta.env.VITE_APP_INSTANCE}`); console.log("%c %s", styles, `VER: ${import.meta.env.VITE_APP_INSTANCE}`);
} }
function App() { function App() {
return ( return (
<PersistGate loading={<LoadingSpinner message="Restoring your settings..." />} persistor={persistor}> <Provider store={store}>
<Provider store={store}> <PersistGate loading={<LoadingSpinner message="Restoring your settings..." />} persistor={persistor}>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</PostHogProvider> </PostHogProvider>
</Provider> </PersistGate>
</PersistGate> </Provider>
); );
} }
// Used for ANTD Component Tokens ReactDOM.createRoot(document.getElementById("root")).render(<App />);
// https://ant.design/docs/react/migrate-less-variables
ReactDOM.createRoot(document.getElementById("root")).render(
<ConfigProvider>
<App />
</ConfigProvider>
);
reportWebVitals(); reportWebVitals();