diff --git a/apps/pyconkr/src/components/layout/Header/Mobile/HamburgerButton.tsx b/apps/pyconkr/src/components/layout/Header/Mobile/HamburgerButton.tsx new file mode 100644 index 0000000..c859d12 --- /dev/null +++ b/apps/pyconkr/src/components/layout/Header/Mobile/HamburgerButton.tsx @@ -0,0 +1,46 @@ +import { IconButton, styled } from "@mui/material"; +import * as React from "react"; + +interface HamburgerButtonProps { + isOpen: boolean; + onClick: () => void; + isMainPath?: boolean; +} + +export const HamburgerButton: React.FC = ({ isOpen, onClick, isMainPath = true }) => { + return ( + + + + + + + + ); +}; + +const StyledIconButton = styled(IconButton)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + padding: 0, + width: 26, + height: 18, + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, +})); + +const HamburgerIcon = styled("div")<{ isOpen: boolean; isMainPath: boolean }>(({ isOpen, theme, isMainPath }) => ({ + width: 26, + height: 18, + position: "relative", + cursor: "pointer", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + + "& span": { + display: "block", + height: isOpen ? 3 : 2, + width: "100%", + backgroundColor: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + borderRadius: 1, + transition: "height 0.3s ease", + }, +})); diff --git a/apps/pyconkr/src/components/layout/Header/Mobile/MobileHeader.tsx b/apps/pyconkr/src/components/layout/Header/Mobile/MobileHeader.tsx new file mode 100644 index 0000000..03b2f92 --- /dev/null +++ b/apps/pyconkr/src/components/layout/Header/Mobile/MobileHeader.tsx @@ -0,0 +1,90 @@ +import * as Common from "@frontend/common"; +import { Box, Stack, styled, Typography } from "@mui/material"; +import * as React from "react"; +import { Link, useLocation } from "react-router-dom"; + +import { HamburgerButton } from "./HamburgerButton"; +import { MobileLanguageToggle } from "./MobileLanguageToggle"; +import { MobileNavigation } from "./MobileNavigation"; +import { useAppContext } from "../../../../contexts/app_context"; + +interface MobileHeaderProps { + isNavigationOpen?: boolean; + onToggleNavigation?: () => void; +} + +export const MobileHeader: React.FC = ({ isNavigationOpen = false, onToggleNavigation }) => { + const { siteMapNode } = useAppContext(); + const location = useLocation(); + const [internalNavigationOpen, setInternalNavigationOpen] = React.useState(false); + + const navigationOpen = onToggleNavigation ? isNavigationOpen : internalNavigationOpen; + const toggleNavigation = onToggleNavigation || (() => setInternalNavigationOpen(!internalNavigationOpen)); + + const isMainPath = location.pathname === "/"; + + return ( + <> + + + + + + + + + 파이콘 한국 2025 + + + + + + + + + + toggleNavigation()} siteMapNode={siteMapNode} /> + + ); +}; + +const MobileHeaderContainer = styled("header")<{ isOpen: boolean; isMainPath: boolean }>(({ theme, isOpen, isMainPath }) => ({ + position: isMainPath ? "fixed" : "sticky", + top: 0, + left: 0, + right: 0, + + display: isOpen ? "none" : "flex", + alignItems: "center", + justifyContent: "space-between", + + width: "100%", + height: 60, + + padding: "15px 23px", + + backgroundColor: isMainPath ? "rgba(182, 216, 215, 0.1)" : "#B6D8D7", + backdropFilter: isMainPath ? "blur(8px)" : "none", + WebkitBackdropFilter: isMainPath ? "blur(8px)" : "none", + color: isMainPath ? "white" : "rgba(18, 109, 127, 0.6)", + + zIndex: isMainPath ? theme.zIndex.appBar + 100000 : theme.zIndex.appBar, +})); + +const LeftContent = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 17, +}); + +const LogoAndTextContainer = styled(Box)({ + display: "flex", + alignItems: "center", +}); diff --git a/apps/pyconkr/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx b/apps/pyconkr/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx new file mode 100644 index 0000000..f38217f --- /dev/null +++ b/apps/pyconkr/src/components/layout/Header/Mobile/MobileLanguageToggle.tsx @@ -0,0 +1,76 @@ +import { ButtonBase, styled } from "@mui/material"; +import * as React from "react"; + +import { LOCAL_STORAGE_LANGUAGE_KEY } from "../../../../consts/local_stroage"; +import { useAppContext } from "../../../../contexts/app_context"; + +interface MobileLanguageToggleProps { + isMainPath?: boolean; +} + +export const MobileLanguageToggle: React.FC = ({ isMainPath = true }) => { + const { language, setAppContext } = useAppContext(); + + const handleLanguageChange = (newLanguage: "ko" | "en") => { + localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, newLanguage); + setAppContext((ps) => ({ ...ps, language: newLanguage })); + }; + return ( + + handleLanguageChange("ko")}> + KO + + handleLanguageChange("en")}> + EN + + + ); +}; + +const ToggleContainer = styled("div")<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + display: "flex", + width: 94, + height: 29, + border: "1px solid white", + borderRadius: 15, + padding: 2, + gap: 2, + backgroundColor: isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.background + : theme.palette.mobileNavigation.sub.languageToggle.background, +})); + +const LanguageButton = styled(ButtonBase)<{ isActive: boolean; isMainPath: boolean }>(({ theme, isActive, isMainPath }) => ({ + flex: 1, + height: "100%", + borderRadius: 13, + fontSize: 12, + fontWeight: 400, + transition: "all 0.2s ease", + + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + backgroundColor: "transparent", + + ...(isActive && { + backgroundColor: isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.active.background + : theme.palette.mobileNavigation.sub.languageToggle.active.background, + color: isMainPath ? theme.palette.mobileHeader.main.activeLanguage : theme.palette.mobileHeader.sub.activeLanguage, + fontWeight: 600, + }), + + "&:hover": { + backgroundColor: isActive + ? isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.active.hover + : theme.palette.mobileNavigation.sub.languageToggle.active.hover + : isMainPath + ? theme.palette.mobileNavigation.main.languageToggle.inactive.hover + : theme.palette.mobileNavigation.sub.languageToggle.inactive.hover, + }, + + WebkitFontSmoothing: "antialiased", + MozOsxFontSmoothing: "grayscale", + textRendering: "optimizeLegibility", + WebkitTextStroke: "0.5px transparent", +})); diff --git a/apps/pyconkr/src/components/layout/Header/Mobile/MobileNavigation.tsx b/apps/pyconkr/src/components/layout/Header/Mobile/MobileNavigation.tsx new file mode 100644 index 0000000..860f474 --- /dev/null +++ b/apps/pyconkr/src/components/layout/Header/Mobile/MobileNavigation.tsx @@ -0,0 +1,344 @@ +import * as Common from "@frontend/common"; +import BackendAPISchemas from "@frontend/common/src/schemas/backendAPI"; +import { ArrowBack, ArrowForward } from "@mui/icons-material"; +import { Box, Button, Chip, Drawer, IconButton, Stack, styled, Typography } from "@mui/material"; +import * as React from "react"; +import { Link, useLocation } from "react-router-dom"; +import * as R from "remeda"; + +import { HamburgerButton } from "./HamburgerButton"; +import { MobileLanguageToggle } from "./MobileLanguageToggle"; +import { SignInButton } from "../../SignInButton"; + +type MenuType = BackendAPISchemas.NestedSiteMapSchema; + +interface MobileNavigationProps { + isOpen: boolean; + onClose: () => void; + siteMapNode?: MenuType; +} + +type NavigationLevel = "depth1" | "depth2" | "depth3"; + +interface NavigationState { + level: NavigationLevel; + depth1?: MenuType; + depth2?: MenuType; + breadcrumbs: { name: string; level: NavigationLevel }[]; +} + +export const MobileNavigation: React.FC = ({ isOpen, onClose, siteMapNode }) => { + const location = useLocation(); + const [navState, setNavState] = React.useState({ + level: "depth1", + breadcrumbs: [], + }); + + const isMainPath = location.pathname === "/"; + + const resetNavigation = () => { + setNavState({ + level: "depth1", + breadcrumbs: [], + }); + }; + + const navigateToDepth2 = (depth1: MenuType) => { + setNavState({ + level: "depth2", + depth1, + breadcrumbs: [{ name: depth1.name, level: "depth1" }], + }); + }; + + const navigateToDepth3 = (depth2: MenuType) => { + setNavState((prev) => ({ + ...prev, + level: "depth3", + depth2, + breadcrumbs: [...prev.breadcrumbs, { name: depth2.name, level: "depth2" }], + })); + }; + + const goBack = () => { + if (navState.level === "depth3") { + setNavState((prev) => ({ + ...prev, + level: "depth2", + depth2: undefined, + breadcrumbs: prev.breadcrumbs.slice(0, -1), + })); + } else if (navState.level === "depth2") { + resetNavigation(); + } + }; + + const handleClose = () => { + onClose(); + resetNavigation(); + }; + + const renderDepth1Menu = () => { + if (!siteMapNode) return null; + + return ( + + {Object.values(siteMapNode.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + {menu.name} + + {!R.isEmpty(menu.children) && ( + navigateToDepth2(menu)}> + + + )} + + ))} + + ); + }; + + const renderDepth2Menu = () => { + if (!navState.depth1) return null; + + return ( + + + + + + {navState.depth1.name} + + + + + + {Object.values(navState.depth1.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + + {!R.isEmpty(menu.children) && ( + navigateToDepth3(menu)}> + + + )} + + ))} + + + ); + }; + + const renderDepth3Menu = () => { + if (!navState.depth2) return null; + + return ( + + + + + + {navState.depth2.name} + + + + + + {Object.values(navState.depth2.children) + .filter((s) => !s.hide) + .map((menu) => ( + + + + ))} + + + ); + }; + + return ( + + + + + + + + + + 파이콘 한국 2025 + + + + + + + {navState.level === "depth1" && renderDepth1Menu()} + {navState.level === "depth2" && renderDepth2Menu()} + {navState.level === "depth3" && renderDepth3Menu()} + + + + + + + + + + + ); +}; + +const StyledDrawer = styled(Drawer)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + "& .MuiDrawer-paper": { + width: "70vw", + background: isMainPath ? theme.palette.mobileNavigation.main.background : theme.palette.mobileNavigation.sub.background, + backdropFilter: isMainPath ? "blur(10px)" : "none", + WebkitBackdropFilter: isMainPath ? "blur(10px)" : "none", + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + borderTopRightRadius: 15, + borderBottomRightRadius: 15, + }, +})); + +const DrawerContent = styled(Box)({ + height: "100%", + display: "flex", + flexDirection: "column", +}); + +const NavigationHeader = styled(Box)<{ isMainPath: boolean }>({ + display: "flex", + alignItems: "center", + padding: "23px 23px 10px 23px", + position: "relative", + gap: 17, +}); + +const NavigationContent = styled(Box)({ + flex: 1, + overflow: "auto", +}); + +const MenuContainer = styled(Stack)({ + padding: "20px 0", + gap: "25px", +}); + +const MenuItem = styled(Box)<{ isMainPath?: boolean }>({ + display: "flex", + alignItems: "center", + padding: "0 23px", + gap: 23, +}); + +const MenuLink = styled(Link)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + textDecoration: "none", + fontSize: "20px", + fontWeight: 600, +})); + +const MenuArrowButton = styled(IconButton)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + padding: 8, +})); + +const BackButton = styled(Button)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + display: "flex", + alignItems: "center", + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + textTransform: "none", + padding: "0 15px 0 0", + minWidth: "auto", + minHeight: "auto", +})); + +const MenuChip = styled(Chip)<{ isMainPath?: boolean }>(({ theme, isMainPath = true }) => ({ + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.chip.background : theme.palette.mobileNavigation.sub.chip.background, + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + height: 40, + borderRadius: 15, + padding: "10px 13px", + fontSize: "16px", + fontWeight: 600, + + "& .MuiChip-label": { + padding: 0, + }, + + "&:hover": { + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.chip.hover : theme.palette.mobileNavigation.sub.chip.hover, + }, +})); + +const BottomActions = styled(Stack)<{ isMainPath: boolean }>({ + padding: "20px 23px", + gap: 50, + alignItems: "center", +}); + +const HeaderTitle = styled(Typography)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + color: isMainPath ? theme.palette.mobileHeader.main.text : theme.palette.mobileHeader.sub.text, + fontSize: 18, + fontWeight: 600, +})); + +const LogoAndTextContainer = styled(Box)({ + display: "flex", + alignItems: "center", +}); + +const NavigationMenuSection = styled(Box)({ + padding: "20px 23px", +}); + +const Depth2Header = styled(Box)<{ isMainPath: boolean }>({ + display: "flex", + alignItems: "center", + height: "auto", + marginBottom: 10, +}); + +const Depth2Title = styled(Typography)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + color: isMainPath ? theme.palette.mobileNavigation.main.text : theme.palette.mobileNavigation.sub.text, + fontSize: 20, + fontWeight: 800, +})); + +const Depth2Divider = styled(Box)<{ isMainPath: boolean }>(({ theme, isMainPath }) => ({ + height: 1, + backgroundColor: isMainPath ? theme.palette.mobileNavigation.main.divider : theme.palette.mobileNavigation.sub.divider, + marginBottom: 21, +})); + +const Depth2MenuList = styled(Stack)({ + gap: 15, +}); + +const Depth2MenuItem = styled(Box)({ + display: "flex", + alignItems: "center", + gap: 10, +}); + +const Depth3MenuGrid = styled(Box)({ + height: 260, + display: "flex", + flexDirection: "column", + flexWrap: "wrap", + alignContent: "flex-start", + gap: 15, + overflow: "hidden", +}); diff --git a/apps/pyconkr/src/components/layout/Header/index.tsx b/apps/pyconkr/src/components/layout/Header/index.tsx index 6219cf2..87dcfc1 100644 --- a/apps/pyconkr/src/components/layout/Header/index.tsx +++ b/apps/pyconkr/src/components/layout/Header/index.tsx @@ -1,6 +1,6 @@ import * as Common from "@frontend/common"; import { ArrowForwardIos } from "@mui/icons-material"; -import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography } from "@mui/material"; +import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; import { MUIStyledCommonProps } from "@mui/system"; import * as React from "react"; import { Link } from "react-router-dom"; @@ -11,6 +11,7 @@ import { useAppContext } from "../../../contexts/app_context"; import { CartBadgeButton } from "../CartBadgeButton"; import LanguageSelector from "../LanguageSelector"; import { SignInButton } from "../SignInButton"; +import { MobileHeader } from "./Mobile/MobileHeader"; type MenuType = BackendAPISchemas.NestedSiteMapSchema; type MenuOrUndefinedType = MenuType | undefined; @@ -26,6 +27,8 @@ const BreadCrumbHeight: React.CSSProperties["height"] = "4.5rem"; const Header: React.FC = () => { const { title, language, siteMapNode, currentSiteMapDepth, shouldShowTitleBanner } = useAppContext(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); const [navState, setNavState] = React.useState({}); const resetDepths = () => setNavState({}); @@ -38,6 +41,10 @@ const Header: React.FC = () => { React.useEffect(resetDepths, [language]); + if (isMobile) { + return ; + } + let breadCrumbRoute = ""; let breadCrumbArray = currentSiteMapDepth.slice(1, -1); if (R.isEmpty(breadCrumbArray)) breadCrumbArray = currentSiteMapDepth.slice(0, -1); diff --git a/apps/pyconkr/src/components/layout/SignInButton/index.tsx b/apps/pyconkr/src/components/layout/SignInButton/index.tsx index 112a0a3..490bc1f 100644 --- a/apps/pyconkr/src/components/layout/SignInButton/index.tsx +++ b/apps/pyconkr/src/components/layout/SignInButton/index.tsx @@ -1,5 +1,6 @@ import * as Shop from "@frontend/shop"; -import { Button } from "@mui/material"; +import { Login, Logout } from "@mui/icons-material"; +import { Button, Stack } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import { useNavigate } from "react-router-dom"; @@ -9,15 +10,62 @@ type InnerSignInButtonImplPropType = { loading?: boolean; signedIn?: boolean; onSignOut?: () => void; + isMobile?: boolean; + isMainPath?: boolean; + onClose?: () => void; }; -const InnerSignInButtonImpl: React.FC = ({ loading, signedIn, onSignOut }) => { +const InnerSignInButtonImpl: React.FC = ({ + loading, + signedIn, + onSignOut, + isMobile = false, + isMainPath = true, + onClose, +}) => { const navigate = useNavigate(); const { language } = useAppContext(); const signInBtnStr = language === "ko" ? "로그인" : "Sign In"; const signOutBtnStr = language === "ko" ? "로그아웃" : "Sign Out"; + const handleClick = () => { + if (signedIn) { + onSignOut?.(); + } else { + onClose?.(); + navigate("/account/sign-in"); + } + }; + + if (isMobile) { + return ( + + ); + } + return (