base commit

This commit is contained in:
@kirill.komarov
2026-04-28 21:47:43 +05:00
parent 2148fd7a12
commit d40edf97e7
7 changed files with 407 additions and 192 deletions
+326
View File
@@ -0,0 +1,326 @@
import { useState } from 'react'
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import FormControl from '@mui/material/FormControl'
import IconButton from '@mui/material/IconButton'
import InputLabel from '@mui/material/InputLabel'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import { useTheme } from '@mui/material/styles'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useUnit } from 'effector-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo'
type NavItem = { label: string; to: string }
const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' },
{ label: 'Админка', to: '/admin' },
]
function ThemeControlsDesktop(props: {
scheme: string
mode: string
resolvedMode: 'light' | 'dark'
onSchemeChange: (e: SelectChangeEvent<string>) => void
onModeChange: (e: SelectChangeEvent<string>) => void
onCycleMode: () => void
}) {
const { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode } = props
return (
<>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 160,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="scheme-label">Тема</InputLabel>
<Select
labelId="scheme-label"
value={scheme}
label="Тема"
onChange={onSchemeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
<MenuItem value="berry">Ягоды</MenuItem>
</Select>
</FormControl>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 150,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="mode-label">Режим</InputLabel>
<Select
labelId="mode-label"
value={mode}
label="Режим"
onChange={onModeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
</Select>
</FormControl>
<IconButton
color="inherit"
onClick={onCycleMode}
sx={{ ml: 1 }}
aria-label="Переключить режим темы (авто/светлая/тёмная)"
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
>
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
</IconButton>
</>
)
}
function ThemeControlsMobile(props: {
scheme: string
mode: string
resolvedMode: 'light' | 'dark'
onSchemeChange: (e: SelectChangeEvent<string>) => void
onModeChange: (e: SelectChangeEvent<string>) => void
onCycleMode: () => void
}) {
const { scheme, mode, resolvedMode, onSchemeChange, onModeChange, onCycleMode } = props
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small" fullWidth>
<InputLabel id="scheme-label-mobile">Тема</InputLabel>
<Select labelId="scheme-label-mobile" value={scheme} label="Тема" onChange={onSchemeChange}>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
<MenuItem value="berry">Ягоды</MenuItem>
</Select>
</FormControl>
<FormControl size="small" fullWidth>
<InputLabel id="mode-label-mobile">Режим</InputLabel>
<Select labelId="mode-label-mobile" value={mode} label="Режим" onChange={onModeChange}>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
</Select>
</FormControl>
<Button variant="outlined" onClick={onCycleMode}>
Быстро переключить: {mode === 'system' ? `авто (${resolvedMode})` : mode}
</Button>
</Box>
)
}
export function AppHeader() {
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl)
const [mobileOpen, setMobileOpen] = useState(false)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const onSchemeChange = (e: SelectChangeEvent<string>) => {
setScheme(e.target.value as ColorScheme)
}
const onModeChange = (e: SelectChangeEvent<string>) => {
const v = e.target.value
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
}
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => setUserAnchorEl(e.currentTarget)
const closeUserMenu = () => setUserAnchorEl(null)
const openMobile = () => setMobileOpen(true)
const closeMobile = () => setMobileOpen(false)
const go = (to: string) => {
closeMobile()
closeUserMenu()
navigate(to)
}
const onLogout = () => {
tokenSet(null)
logout()
closeMobile()
closeUserMenu()
navigate('/')
}
return (
<>
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Toolbar>
{isMobile && (
<IconButton color="inherit" onClick={openMobile} aria-label="Открыть меню" edge="start" sx={{ mr: 1 }}>
<MenuOutlinedIcon />
</IconButton>
)}
<Box
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
textDecoration: 'none',
color: 'inherit',
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<BearLogo sx={{ fontSize: 28 }} />
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{STORE_NAME}
</Typography>
</Box>
{!isMobile &&
navItems.map((i) => (
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
{i.label}
</Button>
))}
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge
variant="dot"
color="success"
overlap="circular"
invisible={!user}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<AccountCircleOutlinedIcon />
</Badge>
</IconButton>
<Menu
anchorEl={userAnchorEl}
open={userMenuOpen}
onClose={closeUserMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{user ? (
<>
<MenuItem onClick={() => go('/me')}>
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
</MenuItem>
<MenuItem onClick={onLogout}>Выход</MenuItem>
</>
) : (
<MenuItem onClick={() => go('/auth')}>Войти / регистрация</MenuItem>
)}
</Menu>
{!isMobile && (
<ThemeControlsDesktop
scheme={scheme}
mode={mode}
resolvedMode={resolvedMode}
onSchemeChange={onSchemeChange}
onModeChange={onModeChange}
onCycleMode={cycleMode}
/>
)}
</Toolbar>
</AppBar>
<Drawer
open={mobileOpen}
onClose={closeMobile}
slotProps={{ paper: { sx: { width: 320, maxWidth: '85vw' } } }}
ModalProps={{ keepMounted: true }}
>
<Box sx={{ p: 2 }}>
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<BearLogo sx={{ fontSize: 28 }} />
<Typography variant="h6">{STORE_NAME}</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{navItems.map((i) => (
<Button key={i.to} variant="text" onClick={() => go(i.to)} sx={{ justifyContent: 'flex-start' }}>
{i.label}
</Button>
))}
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'}
</Button>
{user && (
<Button variant="text" color="error" onClick={onLogout} sx={{ justifyContent: 'flex-start' }}>
Выход
</Button>
)}
</Box>
<Divider sx={{ my: 2 }} />
<ThemeControlsMobile
scheme={scheme}
mode={mode}
resolvedMode={resolvedMode}
onSchemeChange={onSchemeChange}
onModeChange={onModeChange}
onCycleMode={cycleMode}
/>
</Box>
</Drawer>
</>
)
}
+3 -175
View File
@@ -1,186 +1,14 @@
import { type PropsWithChildren, useState } from 'react' import { type PropsWithChildren } from 'react'
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import FormControl from '@mui/material/FormControl'
import IconButton from '@mui/material/IconButton'
import InputLabel from '@mui/material/InputLabel'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { Link as RouterLink, useNavigate } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader'
import { useUnit } from 'effector-react'
import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
export function MainLayout({ children }: PropsWithChildren) { export function MainLayout({ children }: PropsWithChildren) {
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const menuOpen = Boolean(anchorEl)
const onSchemeChange = (e: SelectChangeEvent<string>) => {
setScheme(e.target.value as ColorScheme)
}
const onModeChange = (e: SelectChangeEvent<string>) => {
const v = e.target.value
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
}
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
const closeUserMenu = () => setAnchorEl(null)
const onLogout = () => {
tokenSet(null)
logout()
closeUserMenu()
navigate('/')
}
const goToAuth = () => {
closeUserMenu()
navigate('/auth')
}
const goToMe = () => {
closeUserMenu()
navigate('/me')
}
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}> <AppHeader />
<Toolbar>
<Typography
component={RouterLink}
to="/"
variant="h6"
sx={{ flexGrow: 1, textDecoration: 'none', color: 'inherit' }}
>
{STORE_NAME}
</Typography>
<Button component={RouterLink} to="/" color="inherit">
Каталог
</Button>
<Button component={RouterLink} to="/admin" color="inherit">
Админка
</Button>
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge
variant="dot"
color="success"
overlap="circular"
invisible={!user}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<AccountCircleOutlinedIcon />
</Badge>
</IconButton>
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={closeUserMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{user ? (
<>
<MenuItem onClick={goToMe}>
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
</MenuItem>
<MenuItem onClick={onLogout}>Выход</MenuItem>
</>
) : (
<MenuItem onClick={goToAuth}>Войти / регистрация</MenuItem>
)}
</Menu>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 160,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="scheme-label">Тема</InputLabel>
<Select
labelId="scheme-label"
value={scheme}
label="Тема"
onChange={onSchemeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
<MenuItem value="berry">Ягоды</MenuItem>
</Select>
</FormControl>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 150,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="mode-label">Режим</InputLabel>
<Select
labelId="mode-label"
value={mode}
label="Режим"
onChange={onModeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
</Select>
</FormControl>
<IconButton
color="inherit"
onClick={cycleMode}
sx={{ ml: 1 }}
aria-label="Переключить режим темы (авто/светлая/тёмная)"
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
>
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
</IconButton>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flex: 1, py: 3 }}> <Box component="main" sx={{ flex: 1, py: 3 }}>
<Container maxWidth="lg">{children}</Container> <Container maxWidth="lg">{children}</Container>
</Box> </Box>
+27 -2
View File
@@ -11,14 +11,39 @@ type Props = { product: Product }
export function ProductCard({ product }: Props) { export function ProductCard({ product }: Props) {
return ( return (
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}> <Card
variant="outlined"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transition: 'transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: 6,
borderColor: 'divider',
},
'&:hover .product-card__media': { transform: 'scale(1.03)' },
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:hover': { transform: 'none' },
'&:hover .product-card__media': { transform: 'none' },
},
}}
>
{product.imageUrl ? ( {product.imageUrl ? (
<CardMedia <CardMedia
component="img" component="img"
height="200" height="200"
image={product.imageUrl} image={product.imageUrl}
alt={product.title} alt={product.title}
sx={{ objectFit: 'cover' }} sx={{
objectFit: 'cover',
transition: 'transform 240ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
className="product-card__media"
/> />
) : ( ) : (
<CardMedia <CardMedia
-1
View File
@@ -1,2 +1 @@
export { MePage } from './ui/MePage' export { MePage } from './ui/MePage'
+10 -11
View File
@@ -18,6 +18,13 @@ import {
updateProfileFx, updateProfileFx,
verifyEmailChangeFx, verifyEmailChangeFx,
} from '@/shared/model/auth' } from '@/shared/model/auth'
import type { AxiosError } from 'axios'
function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }>
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
export function MePage() { export function MePage() {
const user = useUnit($user) const user = useUnit($user)
@@ -45,16 +52,9 @@ export function MePage() {
mode: 'onChange', mode: 'onChange',
}) })
const passwordErrorMsg = const passwordErrorMsg = getApiErrorMessage(errorPassword)
(errorPassword as any)?.response?.data?.error ? String((errorPassword as any).response.data.error) : null const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const emailErrorMsg = const profileErrorMsg = getApiErrorMessage(errorProfile)
(errorEmailReq as any)?.response?.data?.error
? String((errorEmailReq as any).response.data.error)
: (errorEmailVerify as any)?.response?.data?.error
? String((errorEmailVerify as any).response.data.error)
: null
const profileErrorMsg =
(errorProfile as any)?.response?.data?.error ? String((errorProfile as any).response.data.error) : null
if (!user) { if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert> return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
@@ -175,4 +175,3 @@ export function MePage() {
</Box> </Box>
) )
} }
+41
View File
@@ -0,0 +1,41 @@
import SvgIcon from '@mui/material/SvgIcon'
import type { SvgIconProps } from '@mui/material/SvgIcon'
export function BearLogo(props: SvgIconProps) {
return (
<SvgIcon viewBox="0 0 64 64" {...props}>
<path
d="M18 24c-3.9 0-7-3.1-7-7s3.1-7 7-7c2.8 0 5.3 1.7 6.4 4.1C26.5 12.8 29.1 11 32 11s5.5 1.8 7.6 4.1C40.7 11.7 43.2 10 46 10c3.9 0 7 3.1 7 7s-3.1 7-7 7"
fill="currentColor"
opacity="0.35"
/>
<path
d="M32 18c-12.1 0-22 9.4-22 21 0 12.2 10.6 20 22 20s22-7.8 22-20c0-11.6-9.9-21-22-21Z"
fill="currentColor"
/>
<path
d="M23 39c0 2.2-1.6 4-3.5 4S16 41.2 16 39s1.6-4 3.5-4S23 36.8 23 39Zm25 0c0 2.2-1.6 4-3.5 4S41 41.2 41 39s1.6-4 3.5-4S48 36.8 48 39Z"
fill="#111"
opacity="0.85"
/>
<path
d="M32 33c-6.2 0-11.2 4.6-11.2 10.2 0 5.5 5 9.8 11.2 9.8s11.2-4.3 11.2-9.8C43.2 37.6 38.2 33 32 33Z"
fill="#fff"
opacity="0.9"
/>
<path
d="M32 40.2c-1.9 0-3.4-1.2-3.4-2.7s1.5-2.7 3.4-2.7 3.4 1.2 3.4 2.7-1.5 2.7-3.4 2.7Z"
fill="#111"
opacity="0.9"
/>
<path
d="M27.8 44.8c1.2 1.5 2.8 2.2 4.2 2.2s3-.7 4.2-2.2"
fill="none"
stroke="#111"
strokeWidth="2.6"
strokeLinecap="round"
opacity="0.85"
/>
</SvgIcon>
)
}
-3
View File
@@ -19,9 +19,6 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* TS 7: baseUrl удалят; временно допускаем TS 6 */
"ignoreDeprecations": "6.0",
/* Linting */ /* Linting */
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,