From a00b3a7e807f42fd196eaa3500dd38180ff02fed Mon Sep 17 00:00:00 2001 From: trevor-anderson Date: Tue, 20 Feb 2024 10:43:25 -0500 Subject: [PATCH] feat: split PageContainer into RootAppLayout, useAuthRefresh, and AppBar --- src/components/AppBar/AppBar.stories.tsx | 84 ++++++++++ .../AppBar/AppBar.tsx | 74 ++++----- src/components/AppBar/AppBarLogoButton.tsx | 44 +++++ .../AppBar/AppBarMenu/AppBarMenu.tsx | 15 ++ .../AppBar/AppBarMenu/DarkModeSwitch.tsx | 77 +++++++++ .../AppBarMenu/DesktopAppBarMenu.stories.tsx | 84 ++++++++++ .../AppBar/AppBarMenu/DesktopAppBarMenu.tsx | 67 ++++++++ .../AppBarMenu/MobileAppBarMenu.stories.tsx | 83 ++++++++++ .../AppBar/AppBarMenu/MobileAppBarMenu.tsx | 75 +++++++++ .../AppBarMenu/MobileAppBarMenuAuthButton.tsx | 32 ++++ .../AppBarMenu/MobileAppBarMenuButton.tsx | 31 ++++ .../AppBarMenu/UserAvatarMenuButton.tsx | 81 ++++++++++ .../AppBar/AppBarMenu/classNames.ts | 10 ++ .../AppBar/AppBarMenu/elementIDs.ts | 8 + src/components/AppBar/AppBarMenu/index.ts | 4 + .../AppBarMenu/useAppBarMenuOptionConfigs.tsx | 81 ++++++++++ .../useMobileAppBarMenuButtonState.ts | 24 +++ src/components/AppBar/classNames.ts | 9 ++ src/components/AppBar/elementIDs.ts | 14 ++ src/components/AppBar/helpers.ts | 17 ++ src/components/AppBar/index.ts | 5 + src/hooks/useAuthRefresh.ts | 35 ++++ .../PageContainer/AppBar/AppBarLogoBtn.tsx | 53 ------ .../PageContainer/AppBar/DarkModeSwitch.tsx | 73 --------- .../DesktopAppBarMenu/DesktopAppBarMenu.tsx | 79 --------- .../AppBar/DesktopAppBarMenu/UserMenu.tsx | 92 ----------- .../AppBar/DesktopAppBarMenu/index.ts | 1 - .../PageContainer/AppBar/MobileAppBarMenu.tsx | 139 ---------------- src/layouts/PageContainer/AppBar/index.ts | 1 - .../AppBar/useAppBarMenuConfigs.tsx | 152 ------------------ src/layouts/PageContainer/PageContainer.tsx | 46 ------ src/layouts/PageContainer/index.ts | 1 - src/layouts/RootAppLayout/RootAppLayout.tsx | 52 ++++++ src/layouts/RootAppLayout/elementIDs.ts | 7 + src/layouts/RootAppLayout/index.ts | 2 + 35 files changed, 974 insertions(+), 678 deletions(-) create mode 100644 src/components/AppBar/AppBar.stories.tsx rename src/{layouts/PageContainer => components}/AppBar/AppBar.tsx (51%) create mode 100644 src/components/AppBar/AppBarLogoButton.tsx create mode 100644 src/components/AppBar/AppBarMenu/AppBarMenu.tsx create mode 100644 src/components/AppBar/AppBarMenu/DarkModeSwitch.tsx create mode 100644 src/components/AppBar/AppBarMenu/DesktopAppBarMenu.stories.tsx create mode 100644 src/components/AppBar/AppBarMenu/DesktopAppBarMenu.tsx create mode 100644 src/components/AppBar/AppBarMenu/MobileAppBarMenu.stories.tsx create mode 100644 src/components/AppBar/AppBarMenu/MobileAppBarMenu.tsx create mode 100644 src/components/AppBar/AppBarMenu/MobileAppBarMenuAuthButton.tsx create mode 100644 src/components/AppBar/AppBarMenu/MobileAppBarMenuButton.tsx create mode 100644 src/components/AppBar/AppBarMenu/UserAvatarMenuButton.tsx create mode 100644 src/components/AppBar/AppBarMenu/classNames.ts create mode 100644 src/components/AppBar/AppBarMenu/elementIDs.ts create mode 100644 src/components/AppBar/AppBarMenu/index.ts create mode 100644 src/components/AppBar/AppBarMenu/useAppBarMenuOptionConfigs.tsx create mode 100644 src/components/AppBar/AppBarMenu/useMobileAppBarMenuButtonState.ts create mode 100644 src/components/AppBar/classNames.ts create mode 100644 src/components/AppBar/elementIDs.ts create mode 100644 src/components/AppBar/helpers.ts create mode 100644 src/components/AppBar/index.ts create mode 100644 src/hooks/useAuthRefresh.ts delete mode 100644 src/layouts/PageContainer/AppBar/AppBarLogoBtn.tsx delete mode 100644 src/layouts/PageContainer/AppBar/DarkModeSwitch.tsx delete mode 100644 src/layouts/PageContainer/AppBar/DesktopAppBarMenu/DesktopAppBarMenu.tsx delete mode 100644 src/layouts/PageContainer/AppBar/DesktopAppBarMenu/UserMenu.tsx delete mode 100644 src/layouts/PageContainer/AppBar/DesktopAppBarMenu/index.ts delete mode 100644 src/layouts/PageContainer/AppBar/MobileAppBarMenu.tsx delete mode 100644 src/layouts/PageContainer/AppBar/index.ts delete mode 100644 src/layouts/PageContainer/AppBar/useAppBarMenuConfigs.tsx delete mode 100644 src/layouts/PageContainer/PageContainer.tsx delete mode 100644 src/layouts/PageContainer/index.ts create mode 100644 src/layouts/RootAppLayout/RootAppLayout.tsx create mode 100644 src/layouts/RootAppLayout/elementIDs.ts create mode 100644 src/layouts/RootAppLayout/index.ts diff --git a/src/components/AppBar/AppBar.stories.tsx b/src/components/AppBar/AppBar.stories.tsx new file mode 100644 index 00000000..45151fd9 --- /dev/null +++ b/src/components/AppBar/AppBar.stories.tsx @@ -0,0 +1,84 @@ +import { + withNavDecorator, + withAppStateInfoDecorator, + type AppStateInfoDecoratorArgs, +} from "@/../.storybook/decorators"; +import { QUERIES } from "@/graphql/queries"; +import { STATIC_MOCK_USERS } from "@/tests/mockItems/staticMockUsers"; +import { AppBar } from "./AppBar"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/AppBar/AppBar", + component: AppBar, + decorators: [withNavDecorator, withAppStateInfoDecorator], + args: { + _mock_apollo_decorator_args: { + mocks: [ + { + request: { query: QUERIES.MY_PROFILE }, + result: { + data: { + myProfile: STATIC_MOCK_USERS.Guy_McPerson.profile, + }, + }, + }, + ], + }, + }, +} satisfies Meta; + +export default meta; + +/////////////////////////////////////////////////////////// +// STORIES + +type Story = StoryObj; + +export const UserNotAuthenticated = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: false, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const InactiveSubscription = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const StripeConnectOnboardingIncomplete = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const FullyOnboardedActiveUser = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: true, + }, + }, + }, +} satisfies Story; diff --git a/src/layouts/PageContainer/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx similarity index 51% rename from src/layouts/PageContainer/AppBar/AppBar.tsx rename to src/components/AppBar/AppBar.tsx index fc505452..194c62ca 100644 --- a/src/layouts/PageContainer/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -1,46 +1,43 @@ +import { lazy } from "react"; import { styled } from "@mui/material/styles"; import MuiAppBar from "@mui/material/AppBar"; -import { avatarClasses } from "@mui/material/Avatar"; -import { usePageLayoutContext } from "@app/PageLayoutContext/usePageLayoutContext"; -import { AppBarLogoBtn } from "./AppBarLogoBtn"; -import { DesktopAppBarMenu } from "./DesktopAppBarMenu"; -import { MobileAppBarMenu } from "./MobileAppBarMenu"; +import { ENV } from "@/app/env"; +import { AppBarLogoButton } from "./AppBarLogoButton"; +import { AppBarMenu } from "./AppBarMenu"; +import { appBarElementIDs } from "./elementIDs"; +import { useAppBarHeight } from "./helpers"; +import type { CSSObject } from "@emotion/react"; + +const DevTools = lazy(() => import(/* webpackChunkName: "DevTools" */ "@/components/DevTools")); /** * Mui Material AppBar, with position "fixed". * - * **Positioning:** To ensure components do not render behind AppBar, an offset is - * used to take up the same height and width. This positioning solution and its - * alternatives are described [here](https://mui.com/material-ui/react-app-bar/#fixed-placement). + * **Positioning:** To ensure components do not render behind AppBar, an offset is used + * to take up the same height and width. This positioning solution and its alternatives + * are described [here](https://mui.com/material-ui/react-app-bar/#fixed-placement). * - * > Don't add `theme.mixins.toolbar` as recommended in the docs, it makes the - * Offset, and just sets min-height and breakpoints, but it breaks other styles. + * > Don't add `theme.mixins.toolbar` as recommended in the docs. It does make an Offset, + * but (1) it breaks other styles, and (2) all it does is set min-height and breakpoints. */ -export const AppBar = () => { - const { isMobilePageLayout } = usePageLayoutContext(); - - return ( - <> - - - {isMobilePageLayout ? : } - -
- - ); -}; +export const AppBar = () => ( + <> + + + {ENV.IS_DEV && !ENV.IS_STORYBOOK && } + + +
+ +); -export const appBarElementIDs = { - root: "appbar-root", - fixedPositionOffset: "appbar-fixed-position-offset", -}; - -const StyledMuiAppBar = styled(MuiAppBar)(({ theme }) => { - // Get --app-bar-height from CSS variable set in PageContainer - const appBarHeight = "var(--app-bar-height)"; +const StyledMuiAppBar = styled(MuiAppBar)(({ theme: { palette, variables } }) => { + // Get height from the useAppBarHeight helper: + const appBarHeight = useAppBarHeight(variables); // These styles are all the same for both AppBar and its sibling offset (see jsdoc) - const sharedStyles = { + const sharedStyles: CSSObject = { + width: "100%", borderWidth: "0 0 1px 0", borderStyle: "solid", /* AppBar has two variants, Mobile and Desktop, the conditional rendering of which @@ -49,25 +46,24 @@ const StyledMuiAppBar = styled(MuiAppBar)(({ theme }) => { determine properties like height and width. Why not just use media queries for this? Because aside from factoring in viewport dimensions, isMobilePageLayout also factors in the user's browser as determined by `navigator.userAgent`. */ - ...(theme.variables.isMobilePageLayout + ...(variables.isMobilePageLayout ? { height: appBarHeight, minHeight: appBarHeight, maxHeight: appBarHeight, padding: "1.5rem", - backgroundColor: "transparent", + backgroundColor: palette.background.default, } : { height: appBarHeight, minHeight: appBarHeight, maxHeight: appBarHeight, - padding: "1rem 2rem", + padding: "1rem", }), }; return { ...sharedStyles, - width: "100%", display: "flex", flexDirection: "row", alignItems: "center", @@ -75,11 +71,7 @@ const StyledMuiAppBar = styled(MuiAppBar)(({ theme }) => { gap: "1.5rem", borderWidth: "0 0 1px 0", borderStyle: "solid", - borderColor: theme.palette.divider, - - [`& .${avatarClasses.root}:hover`]: { - cursor: "pointer", - }, + borderColor: palette.divider, // Apply sharedStyles to the sibling offset div [`& + #${appBarElementIDs.fixedPositionOffset}`]: { diff --git a/src/components/AppBar/AppBarLogoButton.tsx b/src/components/AppBar/AppBarLogoButton.tsx new file mode 100644 index 00000000..78735bfe --- /dev/null +++ b/src/components/AppBar/AppBarLogoButton.tsx @@ -0,0 +1,44 @@ +import { styled } from "@mui/material/styles"; +import Text, { typographyClasses } from "@mui/material/Typography"; +import { Logo, brandingClassNames } from "@/components/Branding"; +import { Anchor } from "@/components/Navigation/Anchor"; +import { APP_PATHS } from "@/routes/appPaths"; +import { isAuthenticatedStore } from "@/stores"; + +export const AppBarLogoButton = () => { + const isAuthenticated = isAuthenticatedStore.useSubToStore(); + + return ( + + + + Fixit + + + ); +}; + +const StyledDiv = styled("div")(({ theme: { palette, variables } }) => ({ + display: "flex", + flexDirection: "row", + alignItems: "center", + + "& > a": { + // Increase the anchor's default hover-opacity (0.7) + "&:hover": { + opacity: 0.75, + }, + + [`& > .${brandingClassNames.fixitLogoImg}`]: { + height: variables.isMobilePageLayout ? "2.75rem" : "2.25rem", + border: "1px solid white", + }, + + [`& > .${typographyClasses.root}`]: { + margin: "0 auto 0 0.5rem", + fontSize: "1.5rem", + fontWeight: 400, + color: palette.text.primary, + }, + }, +})); diff --git a/src/components/AppBar/AppBarMenu/AppBarMenu.tsx b/src/components/AppBar/AppBarMenu/AppBarMenu.tsx new file mode 100644 index 00000000..976df1cb --- /dev/null +++ b/src/components/AppBar/AppBarMenu/AppBarMenu.tsx @@ -0,0 +1,15 @@ +import { usePageLayoutContext } from "@/app/PageLayoutContext/usePageLayoutContext"; +import { DesktopAppBarMenu } from "./DesktopAppBarMenu"; +import { MobileAppBarMenu } from "./MobileAppBarMenu"; + +/** + * This component returns an AppBar menu component which is appropriate for the device/viewport. + * + * - When `isMobilePageLayout` is `true`: returns {@link MobileAppBarMenu} + * - When `isMobilePageLayout` is `false`: returns {@link DesktopAppBarMenu} + */ +export const AppBarMenu = () => { + const { isMobilePageLayout } = usePageLayoutContext(); + + return isMobilePageLayout ? : ; +}; diff --git a/src/components/AppBar/AppBarMenu/DarkModeSwitch.tsx b/src/components/AppBar/AppBarMenu/DarkModeSwitch.tsx new file mode 100644 index 00000000..52335e23 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/DarkModeSwitch.tsx @@ -0,0 +1,77 @@ +import { styled } from "@mui/material/styles"; +import Switch, { switchClasses as muiSwitchClasses } from "@mui/material/Switch"; +import Tooltip from "@mui/material/Tooltip"; +import LightModeIcon from "@mui/icons-material/LightMode"; +import DarkModeIcon from "@mui/icons-material/ModeNightSharp"; +import { THEME_NAMES } from "@/app/ThemeProvider/themes"; +import { themeStore } from "@/stores"; +import { appBarMenuElementIDs } from "./elementIDs"; + +export const DarkModeSwitch = () => { + const currentTheme = themeStore.useSubToStore(); + + const handleChange = () => themeStore.toggle(currentTheme); + + return ( + + } + icon={} + onChange={handleChange} + inputProps={{ "aria-label": "dark mode switch" }} + /> + + ); +}; + +const StyledSwitch = styled(Switch)(({ theme: { palette } }) => ({ + width: "65px", + height: "34px", + padding: "7px", + + [`& > .${muiSwitchClasses.switchBase}`]: { + height: "33px", + width: "33px", + paddingRight: "10px", // <-- Nudges LightModeIcon to the left a bit, not aligning for some reason. + transform: "translateX(6px)", + borderWidth: "2px", + borderStyle: "solid", + borderColor: palette.mode === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.7)", + opacity: 1, + backgroundColor: palette.primary.main, + + "&:hover": { + opacity: 1, + backgroundColor: palette.primary.dark, + }, + + // WHEN CHECKED: + [`&.${muiSwitchClasses.checked}`]: { + opacity: 1, + transform: "translateX(30px)", + backgroundColor: palette.primary.dark, + + "&:hover": { + opacity: 1, + backgroundColor: palette.primary.main, + }, + + // Styles applied to the `DarkModeIcon`: + "& > svg": { + position: "absolute", + top: "4px", + left: "1px", + height: "1.5rem", + width: "1.5rem", + color: palette.text.primary, + transform: "rotateZ(140deg)", + }, + }, + }, + + [`& .${muiSwitchClasses.track}`]: { + borderRadius: 10, + }, +})); diff --git a/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.stories.tsx b/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.stories.tsx new file mode 100644 index 00000000..93a5e4cf --- /dev/null +++ b/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.stories.tsx @@ -0,0 +1,84 @@ +import { + withNavDecorator, + withAppStateInfoDecorator, + type AppStateInfoDecoratorArgs, +} from "@/../.storybook/decorators"; +import { QUERIES } from "@/graphql/queries"; +import { STATIC_MOCK_USERS } from "@/tests/mockItems/staticMockUsers"; +import { DesktopAppBarMenu } from "./DesktopAppBarMenu"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/AppBar/DesktopAppBarMenu", + component: DesktopAppBarMenu, + decorators: [withNavDecorator, withAppStateInfoDecorator], + args: { + _mock_apollo_decorator_args: { + mocks: [ + { + request: { query: QUERIES.MY_PROFILE }, + result: { + data: { + myProfile: STATIC_MOCK_USERS.Guy_McPerson.profile, + }, + }, + }, + ], + }, + }, +} satisfies Meta; + +export default meta; + +/////////////////////////////////////////////////////////// +// STORIES + +type Story = StoryObj; + +export const UserNotAuthenticated = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: false, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const InactiveSubscription = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const StripeConnectOnboardingIncomplete = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const FullyOnboardedActiveUser = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: true, + }, + }, + }, +} satisfies Story; diff --git a/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.tsx b/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.tsx new file mode 100644 index 00000000..d3f22cf1 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/DesktopAppBarMenu.tsx @@ -0,0 +1,67 @@ +import { styled } from "@mui/material/styles"; +import Tooltip from "@mui/material/Tooltip"; +import { NavLink } from "@/components/Navigation"; +import { DarkModeSwitch } from "./DarkModeSwitch"; +import { UserAvatarMenuButton } from "./UserAvatarMenuButton"; +import { appBarMenuClassNames } from "./classNames"; +import { appBarMenuElementIDs } from "./elementIDs"; +import { useAppBarMenuOptionConfigs } from "./useAppBarMenuOptionConfigs"; + +export const DesktopAppBarMenu = () => { + const { appState, appStateBasedMenuOptions, authMenuOption } = useAppBarMenuOptionConfigs(); + + return ( + + {appState.isUserAuthenticated !== true && ( +
+ {appStateBasedMenuOptions.map(({ label, path, tooltip }) => + // Note: all opts used when !auth'd have paths, but TS doesn't know that + path ? ( + + + {label} + + + ) : null + )} +
+ )} + + + + {appState.isUserAuthenticated === true && ( + + )} +
+ ); +}; + +const StyledDiv = styled("div")(({ theme: { palette } }) => ({ + position: "relative", // so descendents can abs-position from here + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "2.5rem", + + [`& > .${appBarMenuClassNames.desktop.linksContainer}`]: { + display: "flex", + gap: "inherit", + + [`& > a`]: { + color: palette.text.primary, + fontSize: "0.9rem", + fontWeight: "bold", + "&:hover": { + opacity: 0.6, + }, + }, + }, + + [`& > #${appBarMenuElementIDs.desktopUserAvatarMenuButton}`]: { + marginLeft: "-1.5rem", // less of a "gap" when it's just the avatar + DarkModeSwitch + }, +})); diff --git a/src/components/AppBar/AppBarMenu/MobileAppBarMenu.stories.tsx b/src/components/AppBar/AppBarMenu/MobileAppBarMenu.stories.tsx new file mode 100644 index 00000000..290da9b0 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/MobileAppBarMenu.stories.tsx @@ -0,0 +1,83 @@ +import { + withNavDecorator, + withAppStateInfoDecorator, + type AppStateInfoDecoratorArgs, +} from "@/../.storybook/decorators"; +import { QUERIES } from "@/graphql/queries"; +import { STATIC_MOCK_USERS } from "@/tests/mockItems/staticMockUsers"; +import { MobileAppBarMenu } from "./MobileAppBarMenu"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/AppBar/MobileAppBarMenu", + component: MobileAppBarMenu, + decorators: [withNavDecorator, withAppStateInfoDecorator], + args: { + _mock_apollo_decorator_args: { + mocks: [ + { + request: { query: QUERIES.MY_PROFILE }, + result: { + data: { + myProfile: STATIC_MOCK_USERS.Guy_McPerson.profile, + }, + }, + }, + ], + }, + }, +} satisfies Meta; + +export default meta; + +/////////////////////////////////////////////////////////// +// STORIES + +type Story = StoryObj; +export const UserNotAuthenticated = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: false, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const InactiveSubscription = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: false, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const StripeConnectOnboardingIncomplete = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: false, + }, + }, + }, +} satisfies Story; + +export const FullyOnboardedActiveUser = { + args: { + _app_state_info_decorator_args: { + appState: { + isUserAuthenticated: true, + isAccountActive: true, + isConnectOnboardingComplete: true, + }, + }, + }, +} satisfies Story; diff --git a/src/components/AppBar/AppBarMenu/MobileAppBarMenu.tsx b/src/components/AppBar/AppBarMenu/MobileAppBarMenu.tsx new file mode 100644 index 00000000..7e7094c1 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/MobileAppBarMenu.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { styled } from "@mui/material/styles"; +import IconButton from "@mui/material/IconButton"; +import List, { listClasses } from "@mui/material/List"; +import ListItemButton, { listItemButtonClasses } from "@mui/material/ListItemButton"; +import { paperClasses } from "@mui/material/Paper"; +import CloseIcon from "@mui/icons-material/Close"; +import MenuIcon from "@mui/icons-material/Menu"; +import { AvatarMyProfile } from "@/components/Avatar/AvatarMyProfile"; +import { MobileModalContentBox } from "@/components/Modal/MobileModalContentBox"; +import { DarkModeSwitch } from "./DarkModeSwitch"; +import { MobileAppBarMenuAuthButton } from "./MobileAppBarMenuAuthButton"; +import { MobileAppBarMenuButton } from "./MobileAppBarMenuButton"; +import { useAppBarMenuOptionConfigs } from "./useAppBarMenuOptionConfigs"; + +export const MobileAppBarMenu = () => { + const { appState, appStateBasedMenuOptions, authMenuOption } = useAppBarMenuOptionConfigs(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleOpenMenu = () => setIsModalOpen(true); + const handleCloseMenu = () => setIsModalOpen(false); + + return ( + <> + {isModalOpen ? ( + + + + ) : appState.isAccountActive === true ? ( + + ) : ( + + + + )} + + + {appStateBasedMenuOptions.map((menuOption) => ( + + ))} + + Toggle Dark Mode + + + + + + + ); +}; + +const StyledMobileModalContentBox = styled(MobileModalContentBox)({ + [`& > .${paperClasses.root}`]: { + height: "80dvh", + width: "80dvw", + + [`& .${listClasses.root}`]: { + width: "100%", + padding: 0, + + [`& .${listItemButtonClasses.root}`]: { + height: "3.25rem", + padding: "2rem", + justifyContent: "space-between", + } + }, + }, +}); diff --git a/src/components/AppBar/AppBarMenu/MobileAppBarMenuAuthButton.tsx b/src/components/AppBar/AppBarMenu/MobileAppBarMenuAuthButton.tsx new file mode 100644 index 00000000..57deb324 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/MobileAppBarMenuAuthButton.tsx @@ -0,0 +1,32 @@ +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import { + useMobileAppBarMenuButtonState, + type UseMobileAppBarMenuButtonStateParams, +} from "./useMobileAppBarMenuButtonState"; + +export const MobileAppBarMenuAuthButton = ({ + menuOption, + handleCloseMenu, +}: MobileAppBarMenuAuthButtonProps) => { + const { showLoading, handleClick } = useMobileAppBarMenuButtonState({ + menuOption, + handleCloseMenu, + }); + + return ( + + ); +}; + +export type MobileAppBarMenuAuthButtonProps = UseMobileAppBarMenuButtonStateParams; diff --git a/src/components/AppBar/AppBarMenu/MobileAppBarMenuButton.tsx b/src/components/AppBar/AppBarMenu/MobileAppBarMenuButton.tsx new file mode 100644 index 00000000..79b8699a --- /dev/null +++ b/src/components/AppBar/AppBarMenu/MobileAppBarMenuButton.tsx @@ -0,0 +1,31 @@ +import CircularProgress from "@mui/material/CircularProgress"; +import ListItemButton from "@mui/material/ListItemButton"; +import Text from "@mui/material/Typography"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { + useMobileAppBarMenuButtonState, + type UseMobileAppBarMenuButtonStateParams, +} from "./useMobileAppBarMenuButtonState"; + +export const MobileAppBarMenuButton = ({ + menuOption, + handleCloseMenu, +}: MobileAppBarMenuButtonProps) => { + const { showLoading, handleClick } = useMobileAppBarMenuButtonState({ + menuOption, + handleCloseMenu, + }); + + return ( + + {menuOption.label} + {showLoading ? ( + + ) : ( + + )} + + ); +}; + +export type MobileAppBarMenuButtonProps = UseMobileAppBarMenuButtonStateParams; diff --git a/src/components/AppBar/AppBarMenu/UserAvatarMenuButton.tsx b/src/components/AppBar/AppBarMenu/UserAvatarMenuButton.tsx new file mode 100644 index 00000000..2cc82613 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/UserAvatarMenuButton.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { styled } from "@mui/material/styles"; +import MuiAvatar from "@mui/material/Avatar"; +import IconButton, { type IconButtonProps } from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { paperClasses } from "@mui/material/Paper"; +import { AvatarMyProfile } from "@/components/Avatar/AvatarMyProfile"; +import { appBarMenuElementIDs } from "./elementIDs"; +import type { AppBarMenuOptionConfigs } from "./useAppBarMenuOptionConfigs"; + +export const UserAvatarMenuButton = ({ + appState, + appStateBasedMenuOptions, + authMenuOption, +}: AppBarMenuOptionConfigs) => { + const [anchorEl, setAnchorEl] = useState(null); + + const handleOpenMenu: IconButtonProps["onClick"] = (event) => setAnchorEl(event.currentTarget); + const handleCloseMenu = () => setAnchorEl(null); + + const isOpen = Boolean(anchorEl); + + return ( + <> + + {appState.isAccountActive !== true ? : } + + {isOpen && ( + + {[...appStateBasedMenuOptions, authMenuOption].map(({ label, doNavAction }) => ( + + {label} + + ))} + + )} + + ); +}; + +const StyledMenu = styled(Menu)(({ theme: { palette } }) => ({ + [`& > .${paperClasses.root}`]: { + overflow: "visible", // so the "arrow" pseudo-element below can be seen + borderWidth: "1px", + borderStyle: "solid", + borderColor: palette.mode === "dark" ? "#404048" : palette.grey.A200, + "&::before": { + content: '""', + position: "absolute", + top: "-6px", + right: "12px", + height: 0, + width: 0, + borderLeft: "7px solid transparent", + borderRight: "7px solid transparent", + borderBottomWidth: "5px", + borderBottomStyle: "solid", + borderBottomColor: "inherit", + }, + }, +})); diff --git a/src/components/AppBar/AppBarMenu/classNames.ts b/src/components/AppBar/AppBarMenu/classNames.ts new file mode 100644 index 00000000..7d3cceb6 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/classNames.ts @@ -0,0 +1,10 @@ +/** + * Class names for `AppBarMenu` components (src/components/AppBar/AppBarMenu/). + */ +export const appBarMenuClassNames = { + /** Class names used in the `DESKTOP` variant of the `AppBarMenu`. */ + desktop: { + linksContainer: "appbar-menu__desktop-links-container", + link: "appbar-menu__desktop-link", + }, +} as const; diff --git a/src/components/AppBar/AppBarMenu/elementIDs.ts b/src/components/AppBar/AppBarMenu/elementIDs.ts new file mode 100644 index 00000000..f4f40666 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/elementIDs.ts @@ -0,0 +1,8 @@ +/** + * IDs of `AppBarMenu` components (src/components/AppBar/AppBarMenu/). + */ +export const appBarMenuElementIDs = { + darkModeSwitch: "appbar-menu__dark-mode-switch", + desktopUserAvatarMenuButton: "appbar-menu__desktop-user-avatar-menu-button", + desktopMenuRoot: "appbar-menu__desktop-menu-root", +} as const; diff --git a/src/components/AppBar/AppBarMenu/index.ts b/src/components/AppBar/AppBarMenu/index.ts new file mode 100644 index 00000000..9d18e75d --- /dev/null +++ b/src/components/AppBar/AppBarMenu/index.ts @@ -0,0 +1,4 @@ +export * from "./AppBarMenu"; + +export * from "./classNames"; +export * from "./elementIDs"; diff --git a/src/components/AppBar/AppBarMenu/useAppBarMenuOptionConfigs.tsx b/src/components/AppBar/AppBarMenu/useAppBarMenuOptionConfigs.tsx new file mode 100644 index 00000000..1379c479 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/useAppBarMenuOptionConfigs.tsx @@ -0,0 +1,81 @@ +import { useAppNavActions, type AppNavActionConfig } from "@/routes/appNavActions"; +import { + isAuthenticatedStore, + isActiveAccountStore, + isConnectOnboardingCompleteStore, + type IsAuthenticated, + type IsActiveAccount, + type IsConnectOnboardingComplete, +} from "@/stores"; + +/** + * This hook uses app-state to determine which {@link AppNavActionConfig} + * objects to include for the menu options rendered by `AppBarMenu` components. + * The app-state properties used are as follows: + * + * - `isUserAuthenticated` + * - `isAccountActive` + * - `isConnectOnboardingComplete` + * + * These app-state properties are included in the returned object for convenience, + * along with the following menu option configs: + * + * - `authOptionConfig` - Either the login or logout nav-action, depending on the + * value of `isUserAuthenticated`. This is separated from the rest of the menu + * option configs to allow for special styling and whatnot. + * + * - `menuOptionConfigs` - Non-auth-related menu option configs, which are determined + * by the value of the user/app-state properties listed above. + */ +export const useAppBarMenuOptionConfigs = (): AppBarMenuOptionConfigs => { + const isUserAuthenticated = isAuthenticatedStore.useSubToStore(); + const isAccountActive = isActiveAccountStore.useSubToStore(); + const isConnectOnboardingComplete = isConnectOnboardingCompleteStore.useSubToStore(); + + const appNavActions = useAppNavActions(); + + return { + appState: { + isAccountActive, + isUserAuthenticated, + isConnectOnboardingComplete, + }, + + authMenuOption: isUserAuthenticated !== true ? appNavActions.LOGIN : appNavActions.LOGOUT, + + appStateBasedMenuOptions: [ + ...(isUserAuthenticated !== true + ? [ + appNavActions.PRICING, + appNavActions.PRIVACY, + appNavActions.LOGIN, + appNavActions.REGISTER, + ] + : [ + ...(isAccountActive !== true + ? [ + // Inactive subscription opts + appNavActions.PRODUCTS, + ] + : [ + // Active subscription opts + appNavActions.STRIPE_CUSTOMER_DASHBOARD, + appNavActions.PROFILE, + isConnectOnboardingComplete !== true + ? appNavActions.STRIPE_CONNECT_ONBOARDING + : appNavActions.STRIPE_CONNECT_DASHBOARD, + ]), + ]), + ], + }; +}; + +export type AppBarMenuOptionConfigs = { + appState: { + isUserAuthenticated: IsAuthenticated; + isAccountActive: IsActiveAccount; + isConnectOnboardingComplete: IsConnectOnboardingComplete; + }; + authMenuOption: AppNavActionConfig; + appStateBasedMenuOptions: Array; +}; diff --git a/src/components/AppBar/AppBarMenu/useMobileAppBarMenuButtonState.ts b/src/components/AppBar/AppBarMenu/useMobileAppBarMenuButtonState.ts new file mode 100644 index 00000000..3270a556 --- /dev/null +++ b/src/components/AppBar/AppBarMenu/useMobileAppBarMenuButtonState.ts @@ -0,0 +1,24 @@ +import { useState } from "react"; +import type { AppNavActionConfig } from "@/routes/appNavActions"; + +export const useMobileAppBarMenuButtonState = ({ + menuOption, + handleCloseMenu, +}: UseMobileAppBarMenuButtonStateParams) => { + const [showLoading, setShowLoading] = useState(false); + + // handleClick: if a menu option navigates to a path, ensure the modal is closed first + const handleClick = async () => { + setShowLoading(true); + await menuOption.doNavAction(); + setShowLoading(false); + handleCloseMenu(); + }; + + return { showLoading, handleClick }; +}; + +export type UseMobileAppBarMenuButtonStateParams = { + menuOption: AppNavActionConfig; + handleCloseMenu: () => void; +}; diff --git a/src/components/AppBar/classNames.ts b/src/components/AppBar/classNames.ts new file mode 100644 index 00000000..bed5558c --- /dev/null +++ b/src/components/AppBar/classNames.ts @@ -0,0 +1,9 @@ +import { appBarMenuClassNames } from "./AppBarMenu/classNames"; + +/** + * Class names for `AppBar` components (src/components/AppBar/). + */ +export const appBarClassNames = { + /** Class names for `AppBarMenu` components (src/components/AppBar/AppBarMenu/). */ + menu: { ...appBarMenuClassNames }, +} as const; diff --git a/src/components/AppBar/elementIDs.ts b/src/components/AppBar/elementIDs.ts new file mode 100644 index 00000000..46a3bdf5 --- /dev/null +++ b/src/components/AppBar/elementIDs.ts @@ -0,0 +1,14 @@ +import { appBarMenuElementIDs } from "./AppBarMenu/elementIDs"; + +/** + * IDs of `AppBar` components (src/components/AppBar/). + */ +export const appBarElementIDs = { + root: "appbar-root", + fixedPositionOffset: "appbar-fixed-position-offset", + + /** IDs of `AppBarMenu` components (src/components/AppBar/AppBarMenu/). */ + appBarMenu: { + ...appBarMenuElementIDs, + }, +} as const; diff --git a/src/components/AppBar/helpers.ts b/src/components/AppBar/helpers.ts new file mode 100644 index 00000000..52572e93 --- /dev/null +++ b/src/components/AppBar/helpers.ts @@ -0,0 +1,17 @@ +import type { ThemeCustomAppVariables } from "@/app/ThemeProvider/themes"; + +/** + * Returns the height of the `AppBar` component, which is dependent on the + * `isMobilePageLayout` {@link ThemeCustomAppVariables|theme variable}. + * + * This is used by the `AppBar` and `RootPageLayout` components. + * + * @note This was previously implemented using a CSS variable set in the + * `RootPageLayout` parent component, but this approach is more straight-forward + * and allows the`AppBar's height to be defined alongside the `AppBar` itself. + */ +export const useAppBarHeight = ({ + isMobilePageLayout, +}: Pick) => { + return isMobilePageLayout ? "5rem" : "3.75rem"; +}; diff --git a/src/components/AppBar/index.ts b/src/components/AppBar/index.ts new file mode 100644 index 00000000..50d9e603 --- /dev/null +++ b/src/components/AppBar/index.ts @@ -0,0 +1,5 @@ +export * from "./AppBar"; + +export * from "./classNames"; +export * from "./elementIDs"; +export * from "./helpers"; diff --git a/src/hooks/useAuthRefresh.ts b/src/hooks/useAuthRefresh.ts new file mode 100644 index 00000000..740b62df --- /dev/null +++ b/src/hooks/useAuthRefresh.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { APP_PATHS } from "@/routes/appPaths"; +import { authService } from "@/services/authService"; +import { authTokenLocalStorage, isActiveAccountStore } from "@/stores"; + +export const useAuthRefresh = () => { + const nav = useNavigate(); + + // EFFECT: On initial load, nav to /home or /products if user is auth'd + useEffect(() => { + (async () => { + // See if the user already has an auth token + if (authTokenLocalStorage.get()) { + const { token: refreshedAuthToken } = await authService + .refreshAuthToken() + .catch(() => ({ token: null })); + // Check if auth token was refreshed and user is auth'd + if (refreshedAuthToken) { + // If account is active, nav to /home, else nav to /products + const isActivePaidAccount = isActiveAccountStore.get(); + if (isActivePaidAccount) { + toast.success("Welcome back!", { toastId: "refreshed-token" }); + nav(APP_PATHS.HOME); + } else { + toast.info("Welcome back! Please select a subscription.", { toastId: "select-a-sub" }); + nav(APP_PATHS.PRODUCTS); + } + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/src/layouts/PageContainer/AppBar/AppBarLogoBtn.tsx b/src/layouts/PageContainer/AppBar/AppBarLogoBtn.tsx deleted file mode 100644 index ec6dd194..00000000 --- a/src/layouts/PageContainer/AppBar/AppBarLogoBtn.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useNavigate, useLocation } from "react-router-dom"; -import { styled } from "@mui/material/styles"; -import Text, { typographyClasses } from "@mui/material/Typography"; -import { isAuthenticatedStore } from "@cache/isAuthenticatedStore"; -import { Logo, logoClassNames } from "@components/Branding/Logo"; - -export const AppBarLogoBtn = () => { - const nav = useNavigate(); - const isAuthenticated = isAuthenticatedStore.useSubToStore(); - const { pathname } = useLocation(); - - const goToLanding = () => nav(isAuthenticated ? "/home" : "/"); - - return ( - - - Fixit - - ); -}; - -const StyledDiv = styled("div")(({ theme: { variables } }) => ({ - display: "flex", - flexDirection: "row", - alignItems: "center", - - [`& .${logoClassNames.root}`]: { - imageRendering: "crisp-edges", - height: variables.isMobilePageLayout ? "3rem" : "2.5rem", - objectFit: "contain", - "&:hover": { - cursor: "pointer", - }, - }, - - [`& .${typographyClasses.root}`]: { - // Don't show the name in the logo on desktop - visibility: variables.isMobilePageLayout ? "visible" : "hidden", - margin: "0 auto 0 0.5rem", - fontSize: "1.5rem", - // LandingPage canvas-gradient-bg is light, so use dark text there (set by parent) - color: "inherit", - fontWeight: "inherit", - }, -})); diff --git a/src/layouts/PageContainer/AppBar/DarkModeSwitch.tsx b/src/layouts/PageContainer/AppBar/DarkModeSwitch.tsx deleted file mode 100644 index 856222f0..00000000 --- a/src/layouts/PageContainer/AppBar/DarkModeSwitch.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { styled } from "@mui/material/styles"; -import Switch, { switchClasses } from "@mui/material/Switch"; -import Tooltip from "@mui/material/Tooltip"; -import LightModeIcon from "@mui/icons-material/LightMode"; -import DarkModeIcon from "@mui/icons-material/ModeNightSharp"; -import { themeStore } from "@cache/themeStore"; - -export const DarkModeSwitch = () => { - const currentTheme = themeStore.useSubToStore(); - - const handleChange = () => themeStore.toggle(currentTheme); - - return ( - - } - icon={} - onChange={handleChange} - inputProps={{ "aria-label": "controlled" }} - /> - - ); -}; - -export const darkModeSwitchElementIDs = { - darkModeIcon: "dark-mode-icon", -}; - -const StyledSwitch = styled(Switch)(({ theme }) => ({ - width: "65px", - height: "34px", - padding: "7px", - - [`& > .${switchClasses.switchBase}`]: { - height: "33px", - width: "33px", - padding: "2.5px", - transform: "translateX(6px)", - borderWidth: "2px", - borderStyle: "solid", - borderColor: theme.palette.mode === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(255, 255, 255, 0.7)", - opacity: 1, - backgroundColor: theme.palette.primary.main, - "&:hover": { - opacity: 1, - backgroundColor: theme.palette.primary.dark, - }, - [`&.${switchClasses.checked}`]: { - opacity: 1, - transform: "translateX(30px)", - backgroundColor: theme.palette.primary.dark, - "&:hover": { - opacity: 1, - backgroundColor: theme.palette.primary.main, - }, - }, - - [`& > #${darkModeSwitchElementIDs.darkModeIcon}`]: { - position: "absolute", - top: "4px", - left: "1px", - height: "1.5rem", - width: "1.5rem", - color: theme.palette.text.primary, - transform: "rotateZ(140deg)", - }, - }, - - [`& .${switchClasses.track}`]: { - borderRadius: 10, - }, -})); diff --git a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/DesktopAppBarMenu.tsx b/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/DesktopAppBarMenu.tsx deleted file mode 100644 index b5c5b893..00000000 --- a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/DesktopAppBarMenu.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Link } from "react-router-dom"; -import { styled } from "@mui/material/styles"; -import { avatarClasses } from "@mui/material/Avatar"; -import Tooltip from "@mui/material/Tooltip"; -import { UserMenu } from "./UserMenu"; -import { DarkModeSwitch } from "../DarkModeSwitch"; -import { useAppBarMenuConfigs, MENU_OPTION_CONFIGS } from "../useAppBarMenuConfigs"; - -export const DesktopAppBarMenu = () => { - // prettier-ignore - const { - isUserAuthenticated, - isAccountActive, - menuOptionConfigs, - authOptionConfig - } = useAppBarMenuConfigs(); - - return ( - - {isUserAuthenticated !== true ? ( - <> -
- {MENU_OPTION_CONFIGS.LANDING_PAGE.map(({ label, path, tooltip }) => ( - - - {label} - - - ))} -
- - - ) : ( - <> - - - - )} -
- ); -}; - -export const desktopAppBarMenuClassNames = { - rrdLinksContainer: "desktop-appbar-menu-rrd-links-container", - rrdLink: "desktop-appbar-menu-rrd-link", -}; - -const StyledDiv = styled("div")(({ theme }) => ({ - position: "relative", // so descendents can abs-position from here - display: "flex", - flexDirection: "row", - alignItems: "center", - gap: "inherit", - paddingTop: "1px", - - [`& > .${desktopAppBarMenuClassNames.rrdLinksContainer}`]: { - display: "flex", - gap: "inherit", - - [`& .${desktopAppBarMenuClassNames.rrdLink}`]: { - padding: "0.8rem 0.75rem 0.75rem 0.75rem", - color: theme.palette.text.primary, - fontSize: "0.9rem", - fontWeight: "bold", - textDecoration: "none", - "&:hover": { - opacity: 0.6, - }, - }, - }, - - [`& .${avatarClasses.root}:hover`]: { - cursor: "pointer !important", - }, -})); diff --git a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/UserMenu.tsx b/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/UserMenu.tsx deleted file mode 100644 index 00c3aa07..00000000 --- a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/UserMenu.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useState } from "react"; -import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import { paperClasses } from "@mui/material/Paper"; -import { Avatar } from "@components/Avatar"; -import { UserAvatar } from "@components/Avatar/UserAvatar"; -import type { AppBarMenuConfigs } from "../useAppBarMenuConfigs"; - -export const UserMenu = ({ - isAccountActive, - menuOptionConfigs, - authOptionConfig, -}: UserMenuProps) => { - const [anchorEl, setAnchorEl] = useState(null); - - const handleOpenMenu = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleCloseMenu = () => { - setAnchorEl(null); - }; - - const isOpen = Boolean(anchorEl); - - return ( - <> - - {isAccountActive !== true ? : } - - {isOpen && ( - ({ - [`& > .${paperClasses.root}`]: { - overflow: "visible", // so the "arrow" pseudo-element below can be seen - borderWidth: "1px", - borderStyle: "solid", - borderColor: palette.mode === "dark" ? "#404048" : palette.grey.A200, - "&::before": { - content: '""', - position: "absolute", - top: "-6px", - right: "12px", - height: 0, - width: 0, - borderLeft: "7px solid transparent", - borderRight: "7px solid transparent", - borderBottomWidth: "5px", - borderBottomStyle: "solid", - borderBottomColor: "inherit", - }, - }, - })} - > - {[...menuOptionConfigs, authOptionConfig].map(({ label, handleSelectOption }) => ( - - {label} - - ))} - - )} - - ); -}; - -export const areaElementIDs = { - clickTarget: "desktop-appbar-menu-click-target", - menu: "desktop-appbar-menu-mui-menu", -}; - -export type UserMenuProps = Pick< - AppBarMenuConfigs, - "isAccountActive" | "menuOptionConfigs" | "authOptionConfig" ->; diff --git a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/index.ts b/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/index.ts deleted file mode 100644 index d33de0f7..00000000 --- a/src/layouts/PageContainer/AppBar/DesktopAppBarMenu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./DesktopAppBarMenu"; diff --git a/src/layouts/PageContainer/AppBar/MobileAppBarMenu.tsx b/src/layouts/PageContainer/AppBar/MobileAppBarMenu.tsx deleted file mode 100644 index 2bb112ed..00000000 --- a/src/layouts/PageContainer/AppBar/MobileAppBarMenu.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from "react"; -import { useLocation } from "react-router-dom"; -import { styled } from "@mui/material/styles"; -import Button, { buttonClasses } from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; -import { paperClasses } from "@mui/material/Paper"; -import Text from "@mui/material/Typography"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import CloseIcon from "@mui/icons-material/Close"; -import MenuIcon from "@mui/icons-material/Menu"; -import { UserAvatar } from "@components/Avatar/UserAvatar"; -import { MobileModalContentBox } from "@components/Modal/MobileModalContentBox"; -import { DarkModeSwitch } from "./DarkModeSwitch"; -import { useAppBarMenuConfigs, type AppBarMenuConfigs } from "./useAppBarMenuConfigs"; - -export const MobileAppBarMenu = () => { - const { pathname } = useLocation(); - const { isAccountActive, authOptionConfig, menuOptionConfigs } = useAppBarMenuConfigs(); - const [isModalOpen, setIsModalOpen] = useState(false); - - const handleOpen = () => setIsModalOpen(true); - const handleClose = () => setIsModalOpen(false); - - // handleClick: if a menu option navigates to a path, ensure the modal is closed first - - const menuAuthOption = getModalOptClickHandler(authOptionConfig, setIsModalOpen); - - const menuOptions = menuOptionConfigs.map((menuOpt) => - getModalOptClickHandler(menuOpt, setIsModalOpen) - ); - - return ( - <> - {isModalOpen ? ( - - - - ) : isAccountActive === true ? ( - - ) : ( - - - - )} - - {menuOptions.map(({ label, handleClick }) => ( -
- {label} - -
- ))} -
- Toggle Dark Mode - -
- -
- - ); -}; - -/** - * This fn provides menu options with a `handleClick` fn which first closes - * the modal window, if necessary. - * - * If a modal menu option contains property "path", the presence of that property - * indicates that `modalMenuOpt.handleSelectOption` is a fn which will navigate - * away to a different path from the current one. - */ -const getModalOptClickHandler = ( - modalMenuOpt: T, - setIsModalOpen: React.Dispatch> -): T & { handleClick: () => void | Promise } => ({ - ...modalMenuOpt, - handleClick: !modalMenuOpt?.path - ? modalMenuOpt.handleSelectOption - : () => { - setIsModalOpen(false); - modalMenuOpt.handleSelectOption(); - }, -}); - -export const mobileAppBarMenuClassNames = { - mobileMenuBtnBox: "mobile-menu-btn-box", -}; - -const StyledMobileModalContentBox = styled(MobileModalContentBox)(({ theme }) => ({ - [`& > .${paperClasses.root}`]: { - height: "85dvh", - width: "80dvw", - - [`& .${mobileAppBarMenuClassNames.mobileMenuBtnBox}`]: { - height: "3.25rem", - width: "100%", - padding: "2rem", - borderStyle: "solid", - borderWidth: "0 0 1px 0", - borderColor: theme.palette.divider, - display: "flex", - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - "&:hover": { - cursor: "pointer", - backgroundColor: theme.palette.action.hover, - }, - }, - - [`& .${buttonClasses.root}`]: { - width: "calc(100% - 4rem)", - alignSelf: "center", - margin: "auto 0 2rem 0", - }, - }, -})); diff --git a/src/layouts/PageContainer/AppBar/index.ts b/src/layouts/PageContainer/AppBar/index.ts deleted file mode 100644 index 75e51e08..00000000 --- a/src/layouts/PageContainer/AppBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./AppBar"; diff --git a/src/layouts/PageContainer/AppBar/useAppBarMenuConfigs.tsx b/src/layouts/PageContainer/AppBar/useAppBarMenuConfigs.tsx deleted file mode 100644 index 00cfbeee..00000000 --- a/src/layouts/PageContainer/AppBar/useAppBarMenuConfigs.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useNavigate } from "react-router-dom"; -import { toast } from "react-toastify"; -import LoginIcon from "@mui/icons-material/Login"; -import LogoutIcon from "@mui/icons-material/Logout"; -import { isActiveAccountStore } from "@cache/isActiveAccountStore"; -import { isAuthenticatedStore } from "@cache/isAuthenticatedStore"; -import { isConnectOnboardingNeededStore } from "@cache/isConnectOnboardingNeededStore"; -import { useAuthService } from "@hooks/useAuthService"; -import { useStripeService } from "@hooks/useStripeService"; - -/** - * This hook returns an object which contains menu options and user-state - * information which is used by both Desktop and Mobile app-bar menu - * components. The properties of the returned object are as follows: - * - * - `isAccountActive` - * - `isUserAuthenticated` - * - `isConnectOnboardingNeeded` - * - * - `authOptionConfig` - Either the login or logout menu option config, - * depending on the value of `isUserAuthenticated`. This is separated from - * the rest of the menu option configs to allow for special styling and - * whatnot (e.g., in MobileAppBarMenu the login/logout button is always - * positioned at the bottom of the modal menu and given "primary" coloration. - * - * - `menuOptionConfigs` - Non-auth-related menu option configs, which are - * determined by the value of the user-state properties listed above. - */ -export const useAppBarMenuConfigs = () => { - const isAccountActive = isActiveAccountStore.useSubToStore(); - const isUserAuthenticated = isAuthenticatedStore.useSubToStore() ?? false; - const isConnectOnboardingNeeded = isConnectOnboardingNeededStore.useSubToStore(); - - // prettier-ignore - const { getCustomerPortalLink, getConnectOnboardingLink, getConnectDashboardLink } = useStripeService(); - const { logout } = useAuthService(); - const nav = useNavigate(); - - return { - // user-state properties, returned for convenience - isAccountActive, - isUserAuthenticated, - isConnectOnboardingNeeded, - // menu option configs: - authOptionConfig: - isUserAuthenticated !== true - ? { - ...MENU_OPTION_CONFIGS.AUTH.LOGIN, - handleSelectOption: () => nav(MENU_OPTION_CONFIGS.AUTH.LOGIN.path), - } - : { - ...MENU_OPTION_CONFIGS.AUTH.LOGOUT, - handleSelectOption: async () => { - await logout().then(() => { - toast("👋 See ya later!", { toastId: "logout" }); - nav("/"); - }); - }, - }, - menuOptionConfigs: [ - ...(isUserAuthenticated !== true - ? MENU_OPTION_CONFIGS.LANDING_PAGE.map((menuConfig) => ({ - ...menuConfig, - handleSelectOption: () => nav(menuConfig.path), - })) - : [ - ...(isAccountActive !== true - ? [ - // Inactive subscription opts - { - label: "Select a Subscription", - path: "/products", - handleSelectOption: () => nav("/products"), - }, - ] - : [ - // Active subscription opts - { - label: "Account", - handleSelectOption: async () => await getCustomerPortalLink(), - }, - { - label: "Profile", - path: "/home/profile", - handleSelectOption: () => nav("/home/profile"), - }, - isConnectOnboardingNeeded === true - ? { - label: "Setup Stripe Payments", - handleSelectOption: async () => await getConnectOnboardingLink(), - } - : { - label: "Stripe Connect Dashboard", - handleSelectOption: async () => await getConnectDashboardLink(), - }, - ]), - ]), - ], - }; -}; - -const _MENU_AUTH_OPTION_CONFIGS = { - LOGIN: { - label: "Login", - icon: , - path: "/login", - } as Omit, "handleSelectOption">, - LOGOUT: { - label: "Logout", - icon: , - } as Omit, -} as const; - -export const MENU_OPTION_CONFIGS = { - AUTH: _MENU_AUTH_OPTION_CONFIGS, - LANDING_PAGE: [ - { - label: "Pricing", - path: "/products", - tooltip: "See pricing for Fixit products", - }, - { - label: "Privacy", - path: "/privacy", - tooltip: "View our privacy policy", - }, - { - ..._MENU_AUTH_OPTION_CONFIGS.LOGIN, - tooltip: "User login", - }, - { - label: "Create Account", - path: "/register", - tooltip: "Create an account", - }, - ], -} as const; - -interface MenuOptionBase { - label: string; - path?: string; - tooltip?: string; - handleSelectOption: () => void | Promise; -} - -export type AppBarMenuConfigs = { - isAccountActive: boolean; - isUserAuthenticated: boolean; - isConnectOnboardingNeeded: boolean; - authOptionConfig: MenuOptionBase & { icon: React.ReactNode }; - menuOptionConfigs: Array; -}; diff --git a/src/layouts/PageContainer/PageContainer.tsx b/src/layouts/PageContainer/PageContainer.tsx deleted file mode 100644 index bcc96fdd..00000000 --- a/src/layouts/PageContainer/PageContainer.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Outlet } from "react-router-dom"; -import { styled } from "@mui/material/styles"; -import { AppBar } from "./AppBar"; - -/** - * Responsive page-layout container with mobile/desktop AppBar. - */ -export const PageContainer = () => ( - - -
- -
-
-); - -export const pageContainerElementIDs = { - root: "page-container-root", - rrdOutletContainer: "page-container-rrd-outlet-container", -}; - -const StyledPageContainer = styled("div")(({ theme }) => { - const appBarHeight = theme.variables.isMobilePageLayout ? "5rem" : "3rem"; - const contentContainerHeight = `calc( 100% - ${appBarHeight} )`; - - return { - "--app-bar-height": appBarHeight, - - height: "100%", - maxHeight: "100dvh", - width: "100%", - maxWidth: "100dvw", - overflow: "hidden", - zIndex: 1, - backgroundColor: theme.palette.background.default, - - [`& > #${pageContainerElementIDs.rrdOutletContainer}`]: { - height: contentContainerHeight, - minHeight: contentContainerHeight, - maxHeight: contentContainerHeight, - width: "100%", - maxWidth: "100dvw", - overflow: "hidden", - }, - }; -}); diff --git a/src/layouts/PageContainer/index.ts b/src/layouts/PageContainer/index.ts deleted file mode 100644 index a40c7c89..00000000 --- a/src/layouts/PageContainer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./PageContainer"; diff --git a/src/layouts/RootAppLayout/RootAppLayout.tsx b/src/layouts/RootAppLayout/RootAppLayout.tsx new file mode 100644 index 00000000..afe5e870 --- /dev/null +++ b/src/layouts/RootAppLayout/RootAppLayout.tsx @@ -0,0 +1,52 @@ +import { Outlet } from "react-router-dom"; +import { styled } from "@mui/material/styles"; +import { globalClassNames } from "@/app/GlobalStyles"; +import { AppBar, useAppBarHeight } from "@/components/AppBar"; +import { useAuthRefresh } from "@/hooks/useAuthRefresh"; +import { rootAppLayoutElementIDs } from "./elementIDs"; + +/** + * Responsive page-layout container with mobile/desktop `AppBar`. + * + * - As the name implies, this is the root container for all app components. + * - This component is rendered by the app's `react-router-dom` router + * (see `src/routes/RootAppRouter`). + * - Child components are rendered via ``. + */ +export const RootAppLayout = () => { + useAuthRefresh(); + + return ( + + +
+ +
+
+ ); +}; + +const StyledDiv = styled("div")(({ theme: { palette, variables } }) => { + const appBarHeight = useAppBarHeight(variables); + const contentContainerHeight = `calc( 100% - ${appBarHeight} )`; + + return { + height: "100%", + maxHeight: "100dvh", + width: "100%", + maxWidth: "100dvw", + overflow: "hidden", + zIndex: 1, + backgroundColor: palette.background.default, + + [`& > #${rootAppLayoutElementIDs.rrdOutletContainer}`]: { + height: contentContainerHeight, + minHeight: contentContainerHeight, + maxHeight: contentContainerHeight, + width: "100%", + maxWidth: "100dvw", + overflowX: "hidden", + overflowY: "auto", + }, + }; +}); diff --git a/src/layouts/RootAppLayout/elementIDs.ts b/src/layouts/RootAppLayout/elementIDs.ts new file mode 100644 index 00000000..753e3d1b --- /dev/null +++ b/src/layouts/RootAppLayout/elementIDs.ts @@ -0,0 +1,7 @@ +/** + * `RootAppLayout` element IDs. + */ +export const rootAppLayoutElementIDs = { + root: "root-app-layout__root", + rrdOutletContainer: "root-app-layout__rrd-outlet-container", +} as const; diff --git a/src/layouts/RootAppLayout/index.ts b/src/layouts/RootAppLayout/index.ts new file mode 100644 index 00000000..25ce7253 --- /dev/null +++ b/src/layouts/RootAppLayout/index.ts @@ -0,0 +1,2 @@ +export * from "./RootAppLayout"; +export * from "./elementIDs";