Merge branch 'perfomance'

This commit is contained in:
Kirill
2026-05-26 10:32:46 +05:00
73 changed files with 6029 additions and 3617 deletions
+6 -2
View File
@@ -2,7 +2,11 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/x-icon" href="/favicon-32.ico" sizes="32x32" />
<link rel="icon" type="image/x-icon" href="/favicon-48.ico" sizes="48x48" />
<link rel="icon" type="image/x-icon" href="/favicon-64.ico" sizes="64x64" />
<link rel="icon" type="image/x-icon" href="/favicon-128.ico" />
<link rel="apple-touch-icon" href="/favicon-128.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
@@ -13,7 +17,7 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" /> <meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." /> <meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
<meta property="og:image" content="/favicon.svg" /> <meta property="og:image" content="/favicon-128.ico" />
<meta property="og:locale" content="ru_RU" /> <meta property="og:locale" content="ru_RU" />
<link rel="canonical" href="https://любимыйкреатив.рф/" /> <link rel="canonical" href="https://любимыйкреатив.рф/" />
</head> </head>
+2422 -2752
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+4
View File
@@ -1,7 +1,9 @@
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { AppProviders } from '@/app/providers/AppProviders' import { AppProviders } from '@/app/providers/AppProviders'
import { AppRoutes } from '@/app/routes' import { AppRoutes } from '@/app/routes'
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary' import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
export function App() { export function App() {
return ( return (
@@ -10,6 +12,8 @@ export function App() {
<ErrorBoundary> <ErrorBoundary>
<AppRoutes /> <AppRoutes />
</ErrorBoundary> </ErrorBoundary>
<CartSnackbar />
<NoiseOverlay />
</BrowserRouter> </BrowserRouter>
</AppProviders> </AppProviders>
) )
+4 -3
View File
@@ -1,3 +1,4 @@
import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge' import Badge from '@mui/material/Badge'
@@ -30,7 +31,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }] const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
export function AppHeader() { export const AppHeader = React.memo(function AppHeader() {
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController() const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
const user = useUnit($user) const user = useUnit($user)
const navigate = useNavigate() const navigate = useNavigate()
@@ -117,7 +118,7 @@ export function AppHeader() {
gap: 1, gap: 1,
}} }}
> >
<BearLogo sx={{ fontSize: 28 }} /> <BearLogo sx={{ width: 28, height: 28 }} />
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{STORE_NAME} {STORE_NAME}
</Typography> </Typography>
@@ -188,4 +189,4 @@ export function AppHeader() {
/> />
</> </>
) )
} })
+14 -17
View File
@@ -23,8 +23,10 @@ export function MainLayout({ children }: PropsWithChildren) {
<ScrollToTop /> <ScrollToTop />
<AppHeader /> <AppHeader />
<Box component="main" sx={{ flex: 1, py: { xs: 4, md: 6 } }}> <Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
<Container maxWidth="lg">{children}</Container> <Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
{children}
</Container>
</Box> </Box>
<Box <Box
@@ -33,24 +35,19 @@ export function MainLayout({ children }: PropsWithChildren) {
mt: 'auto', mt: 'auto',
borderTop: 1, borderTop: 1,
borderColor: 'divider', borderColor: 'divider',
bgcolor: 'background.default', bgcolor: 'background.paper',
py: { xs: 4, md: 6 }, py: { xs: 5, md: 7 },
}} }}
> >
<Container maxWidth="lg"> <Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
<Grid container spacing={4}> <Grid container spacing={5}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}>
Магазин {STORE_NAME}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
Изделия ручной работы: вещи с характером и вниманием к деталям.
</Typography> </Typography>
<Stack spacing={1.5}>
<Link component={RouterLink} to="/" color="inherit" underline="hover" variant="body2">
Каталог
</Link>
<Typography variant="body2" color="text.secondary">
Изделия ручной работы: вещи с характером и вниманием к деталям.
</Typography>
</Stack>
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
@@ -111,7 +108,7 @@ export function MainLayout({ children }: PropsWithChildren) {
</Stack> </Stack>
</Grid> </Grid>
</Grid> </Grid>
<Divider sx={{ my: 3 }} /> <Divider sx={{ my: 4 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
© {year} {STORE_NAME} © {year} {STORE_NAME}
+179 -13
View File
@@ -7,12 +7,12 @@ import { SseProvider } from './SseProvider'
function AppThemeInner({ children }: PropsWithChildren) { function AppThemeInner({ children }: PropsWithChildren) {
const controller = useThemeController() const controller = useThemeController()
const isDark = controller.resolvedMode === 'dark'
const theme = useMemo( const theme = useMemo(
() => () =>
createTheme({ createTheme({
palette: (() => { palette: (() => {
const isDark = controller.resolvedMode === 'dark'
const common = { mode: controller.resolvedMode } const common = { mode: controller.resolvedMode }
const text = isDark const text = isDark
@@ -92,13 +92,16 @@ function AppThemeInner({ children }: PropsWithChildren) {
shape: { borderRadius: 12 }, shape: { borderRadius: 12 },
typography: { typography: {
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif', fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
h4: { fontWeight: 700, letterSpacing: '-0.5px' }, h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
h5: { fontWeight: 600, letterSpacing: '-0.25px' }, h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
h6: { fontWeight: 600 }, h3: { fontWeight: 700, letterSpacing: '-0.5px', lineHeight: 1.2, textWrap: 'balance' },
h4: { fontWeight: 700, letterSpacing: '-0.5px', textWrap: 'balance' },
h5: { fontWeight: 600, letterSpacing: '-0.25px', textWrap: 'balance' },
h6: { fontWeight: 600, textWrap: 'balance' },
subtitle1: { fontWeight: 600 }, subtitle1: { fontWeight: 600 },
subtitle2: { fontWeight: 500 }, subtitle2: { fontWeight: 500 },
body1: { fontSize: '0.875rem' }, body1: { fontSize: '0.875rem', lineHeight: 1.6 },
body2: { fontSize: '0.75rem' }, body2: { fontSize: '0.75rem', lineHeight: 1.5 },
button: { textTransform: 'none', fontWeight: 600 }, button: { textTransform: 'none', fontWeight: 600 },
}, },
components: { components: {
@@ -109,30 +112,34 @@ function AppThemeInner({ children }: PropsWithChildren) {
borderRadius: 12, borderRadius: 12,
fontWeight: 600, fontWeight: 600,
transition: 'all 0.2s ease-in-out', transition: 'all 0.2s ease-in-out',
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
}, },
contained: { contained: {
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.15)', boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
'&:hover': { '&:hover': {
boxShadow: '0 6px 20px 0 rgba(0,0,0,0.25)', boxShadow: '0 6px 20px 0 rgba(0,0,0,0.18)',
transform: 'translateY(-2px)', transform: 'translateY(-2px)',
}, },
'&:active': { '&:active': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.15)', boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
transform: 'translateY(0)', transform: 'translateY(0) scale(0.98)',
}, },
}, },
outlined: { outlined: {
border: '1px solid', border: '1px solid',
'&:hover': { '&:hover': {
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.1)', boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
}, },
'&:active': { '&:active': {
boxShadow: 'none', boxShadow: 'none',
transform: 'scale(0.98)',
}, },
}, },
text: { text: {
'&:hover': { '&:hover': {
boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1)',
backgroundColor: 'action.hover', backgroundColor: 'action.hover',
}, },
'&:active': { '&:active': {
@@ -147,12 +154,171 @@ function AppThemeInner({ children }: PropsWithChildren) {
transition: 'all 0.2s ease-in-out', transition: 'all 0.2s ease-in-out',
'&:hover': { '&:hover': {
backgroundColor: 'action.hover', backgroundColor: 'action.hover',
transform: 'scale(1.1)', transform: 'scale(1.08)',
}, },
'&:active': { '&:active': {
backgroundColor: 'action.selected', backgroundColor: 'action.selected',
transform: 'scale(0.95)', transform: 'scale(0.95)',
}, },
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
},
},
},
},
MuiLink: {
styleOverrides: {
root: {
'&:focus-visible': {
outline: '2px solid currentColor',
outlineOffset: 2,
borderRadius: 2,
},
},
},
},
MuiInputBase: {
styleOverrides: {
root: {
'&.Mui-focused': {
'& .MuiOutlinedInput-notchedOutline': {
borderWidth: 2,
},
},
},
},
},
MuiAlert: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
boxShadow: 'none',
fontWeight: 500,
alignItems: 'center',
padding: '8px 12px',
'& .MuiAlert-icon': {
padding: 0,
marginRight: 12,
display: 'flex',
alignItems: 'center',
},
'& .MuiAlert-message': {
padding: 0,
},
'& .MuiAlert-action': {
padding: 0,
marginRight: 0,
marginLeft: 8,
},
},
colorSuccess: {
bgcolor: isDark ? 'rgba(102,187,106,0.08)' : '#EDF3EC',
borderColor: isDark ? 'rgba(102,187,106,0.2)' : '#C5DFC2',
color: isDark ? '#A5D6A7' : '#346538',
'& .MuiAlert-icon': {
color: isDark ? '#A5D6A7' : '#346538',
},
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(102,187,106,0.3)' : '#C5DFC2',
color: isDark ? '#A5D6A7' : '#346538',
'& .MuiAlert-icon': {
color: isDark ? '#A5D6A7' : '#346538',
},
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(102,187,106,0.15)' : '#346538',
borderColor: 'transparent',
color: isDark ? '#E8F5E9' : '#FFFFFF',
},
},
colorError: {
bgcolor: isDark ? 'rgba(239,83,80,0.08)' : '#FDEBEC',
borderColor: isDark ? 'rgba(239,83,80,0.2)' : '#F5C6C7',
color: isDark ? '#EF9A9A' : '#9F2F2D',
'& .MuiAlert-icon': {
color: isDark ? '#EF9A9A' : '#9F2F2D',
},
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(239,83,80,0.3)' : '#F5C6C7',
color: isDark ? '#EF9A9A' : '#9F2F2D',
'& .MuiAlert-icon': {
color: isDark ? '#EF9A9A' : '#9F2F2D',
},
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(239,83,80,0.15)' : '#9F2F2D',
borderColor: 'transparent',
color: isDark ? '#FFEBEE' : '#FFFFFF',
},
},
colorWarning: {
bgcolor: isDark ? 'rgba(255,183,77,0.08)' : '#FBF3DB',
borderColor: isDark ? 'rgba(255,183,77,0.2)' : '#F0DCA0',
color: isDark ? '#FFD54F' : '#956400',
'& .MuiAlert-icon': {
color: isDark ? '#FFD54F' : '#956400',
},
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(255,183,77,0.3)' : '#F0DCA0',
color: isDark ? '#FFD54F' : '#956400',
'& .MuiAlert-icon': {
color: isDark ? '#FFD54F' : '#956400',
},
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(255,183,77,0.15)' : '#956400',
borderColor: 'transparent',
color: isDark ? '#FFF8E1' : '#FFFFFF',
},
},
colorInfo: {
bgcolor: isDark ? 'rgba(121,134,203,0.08)' : '#E1F3FE',
borderColor: isDark ? 'rgba(121,134,203,0.2)' : '#B8D8F0',
color: isDark ? '#9FA8DA' : '#1F6C9F',
'& .MuiAlert-icon': {
color: isDark ? '#9FA8DA' : '#1F6C9F',
},
'&.MuiAlert-outlined': {
bgcolor: 'transparent',
borderColor: isDark ? 'rgba(121,134,203,0.3)' : '#B8D8F0',
color: isDark ? '#9FA8DA' : '#1F6C9F',
'& .MuiAlert-icon': {
color: isDark ? '#9FA8DA' : '#1F6C9F',
},
},
'&.MuiAlert-filled': {
bgcolor: isDark ? 'rgba(121,134,203,0.15)' : '#1F6C9F',
borderColor: 'transparent',
color: isDark ? '#E8EAF6' : '#FFFFFF',
},
},
},
},
MuiSnackbarContent: {
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid',
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
bgcolor: isDark ? '#1E1E1E' : '#FFFFFF',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
color: isDark ? '#F2F2F2' : '#1F1B16',
fontWeight: 500,
}, },
}, },
}, },
+100 -21
View File
@@ -1,29 +1,38 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout' import { MainLayout } from '@/app/layout/MainLayout'
import { AboutPage } from '@/pages/about'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
import { InfoPage } from '@/pages/info'
import { NotFoundPage } from '@/pages/not-found'
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
import { ProductPage } from '@/pages/product'
import { TermsPage } from '@/pages/terms'
import { usePageTitleReset } from '@/shared/lib/use-page-title' import { usePageTitleReset } from '@/shared/lib/use-page-title'
import { SkeletonPage } from '@/shared/ui/SkeletonPage' import { SkeletonPage } from '@/shared/ui/SkeletonPage'
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage }))) const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage }))) const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage })))
const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage })))
const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage })))
const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage })))
const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage })))
const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage })))
const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage })))
const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage })))
const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage })))
const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage })))
export function AppRoutes() { export function AppRoutes() {
usePageTitleReset() usePageTitleReset()
return ( return (
<MainLayout> <MainLayout>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route
path="/"
element={
<Suspense fallback={<SkeletonPage />}>
<HomePage />
</Suspense>
}
/>
<Route <Route
path="/admin/*" path="/admin/*"
element={ element={
@@ -32,14 +41,70 @@ export function AppRoutes() {
</Suspense> </Suspense>
} }
/> />
<Route path="/auth" element={<AuthPage />} /> <Route
<Route path="/auth/callback" element={<AuthCallbackPage />} /> path="/auth"
<Route path="/cart" element={<CartPage />} /> element={
<Route path="/checkout" element={<CheckoutPage />} /> <Suspense fallback={<SkeletonPage />}>
<Route path="/about" element={<AboutPage />} /> <AuthPage />
<Route path="/info" element={<InfoPage />} /> </Suspense>
<Route path="/privacy" element={<PrivacyPolicyPage />} /> }
<Route path="/terms" element={<TermsPage />} /> />
<Route
path="/auth/callback"
element={
<Suspense fallback={<SkeletonPage />}>
<AuthCallbackPage />
</Suspense>
}
/>
<Route
path="/cart"
element={
<Suspense fallback={<SkeletonPage />}>
<CartPage />
</Suspense>
}
/>
<Route
path="/checkout"
element={
<Suspense fallback={<SkeletonPage />}>
<CheckoutPage />
</Suspense>
}
/>
<Route
path="/about"
element={
<Suspense fallback={<SkeletonPage />}>
<AboutPage />
</Suspense>
}
/>
<Route
path="/info"
element={
<Suspense fallback={<SkeletonPage />}>
<InfoPage />
</Suspense>
}
/>
<Route
path="/privacy"
element={
<Suspense fallback={<SkeletonPage />}>
<PrivacyPolicyPage />
</Suspense>
}
/>
<Route
path="/terms"
element={
<Suspense fallback={<SkeletonPage />}>
<TermsPage />
</Suspense>
}
/>
<Route <Route
path="/me/*" path="/me/*"
element={ element={
@@ -48,8 +113,22 @@ export function AppRoutes() {
</Suspense> </Suspense>
} }
/> />
<Route path="/products/:id" element={<ProductPage />} /> <Route
<Route path="*" element={<NotFoundPage />} /> path="/products/:id"
element={
<Suspense fallback={<SkeletonPage />}>
<ProductPage />
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense fallback={<SkeletonPage />}>
<NotFoundPage />
</Suspense>
}
/>
</Routes> </Routes>
</MainLayout> </MainLayout>
) )
+5
View File
@@ -30,6 +30,9 @@
:root { :root {
color-scheme: light; color-scheme: light;
} }
html {
scroll-behavior: smooth;
}
html, html,
body, body,
#root { #root {
@@ -37,4 +40,6 @@ body,
} }
body { body {
margin: 0; margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
+21 -13
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import * as React from 'react'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -8,7 +9,6 @@ import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Autoplay } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
import type { Product } from '@/entities/product/model/types' import type { Product } from '@/entities/product/model/types'
@@ -18,7 +18,7 @@ import type { Swiper as SwiperType } from 'swiper/types'
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode } type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
export function ProductCard({ product, mediaHeight = 200, actions }: Props) { const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
const navigate = useNavigate() const navigate = useNavigate()
const isMobile = useMediaQuery('(max-width:600px)') const isMobile = useMediaQuery('(max-width:600px)')
const swiperRef = useRef<SwiperType | null>(null) const swiperRef = useRef<SwiperType | null>(null)
@@ -58,14 +58,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
borderRadius: 3, borderRadius: '16px 16px 12px 12px',
border: '1px solid', border: 'none',
borderColor: 'divider',
bgcolor: 'background.paper', bgcolor: 'background.paper',
transition: 'transform 200ms ease, box-shadow 250ms ease', boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
transition: 'transform 250ms ease, box-shadow 300ms ease',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)', transform: 'translateY(-6px)',
boxShadow: '0 8px 30px rgba(0,0,0,0.10)', boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
}, },
'&:hover .product-card__media': { transform: 'scale(1.06)' }, '&:hover .product-card__media': { transform: 'scale(1.06)' },
'&:hover .product-card__title': { color: 'primary.main' }, '&:hover .product-card__title': { color: 'primary.main' },
@@ -80,13 +80,13 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
{imageUrls.length ? ( {imageUrls.length ? (
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}> <Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
<Swiper <Swiper
slidesPerView={1}
spaceBetween={0}
allowTouchMove={!isMobile}
onSwiper={(s) => { onSwiper={(s) => {
swiperRef.current = s swiperRef.current = s
}} }}
modules={isMobile ? [Autoplay] : undefined} style={{ width: '100%', height: mediaHeight, overflow: 'hidden' }}
autoplay={isMobile ? { delay: 3000, disableOnInteraction: false, pauseOnMouseEnter: true } : undefined}
allowTouchMove={!isMobile}
style={{ width: '100%', height: mediaHeight }}
> >
{imageUrls.map((url) => ( {imageUrls.map((url) => (
<SwiperSlide key={url}> <SwiperSlide key={url}>
@@ -238,7 +238,11 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
</Stack> </Stack>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
<Typography variant="h6" color="primary" sx={{ fontWeight: 700, fontSize: '1.1rem' }}> <Typography
variant="h6"
color="primary"
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
>
{formatPriceRub(product.priceCents)} {formatPriceRub(product.priceCents)}
</Typography> </Typography>
{actions} {actions}
@@ -247,3 +251,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
</Card> </Card>
) )
} }
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
})
@@ -30,7 +30,7 @@ export async function updateTestChecklistItem(
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', { const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
itemKey, itemKey,
passed, passed,
comment: passed ? null : comment ?? null, comment: passed ? null : (comment ?? null),
}) })
return data.result return data.result
} }
@@ -1,19 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress' import CircularProgress from '@mui/material/CircularProgress'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton' import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import * as maplibregl from 'maplibre-gl'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import { reverseGeocode, searchPlaces } from '../api/map-geocoding' import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
import { MapPickerMap } from './MapPickerMap'
import type { LatLng, NominatimItem } from '../model/types' import type { LatLng, NominatimItem } from '../model/types'
export function AddressMapPicker(props: { export function AddressMapPicker(props: {
@@ -21,10 +17,8 @@ export function AddressMapPicker(props: {
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) { }) {
const { value, onChange } = props const { value, onChange } = props
const mapRef = useRef<MapRef | null>(null)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [locating, setLocating] = useState(false)
const [results, setResults] = useState<NominatimItem[]>([]) const [results, setResults] = useState<NominatimItem[]>([])
const [hint, setHint] = useState<string | null>(null) const [hint, setHint] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
@@ -36,7 +30,7 @@ export function AddressMapPicker(props: {
const center = useMemo(() => { const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng } if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 } // Москва (fallback) return { lat: 55.751244, lng: 37.618423 }
}, [value]) }, [value])
const pick = async (pos: LatLng) => { const pick = async (pos: LatLng) => {
@@ -60,7 +54,6 @@ export function AddressMapPicker(props: {
} }
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
// throttle: не чаще 1 запроса в 900ms
const now = Date.now() const now = Date.now()
if (now - lastRequestAtRef.current < 900) return if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return if (s === lastQueryRef.current) return
@@ -128,7 +121,6 @@ export function AddressMapPicker(props: {
const lat = Number(r.lat) const lat = Number(r.lat)
const lng = Number(r.lon) const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 })
void pick({ lat, lng }) void pick({ lat, lng })
}} }}
> >
@@ -138,103 +130,7 @@ export function AddressMapPicker(props: {
</List> </List>
)} )}
<Box <MapPickerMap value={value} onChange={onChange} center={center} />
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibregl}
ref={mapRef}
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
style={{ width: '100%', height: 280 }}
mapStyle={{
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
}}
onClick={(e: MapMouseEvent) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
>
{value && (
<Marker
longitude={value.lng}
latitude={value.lat}
draggable
onDragEnd={(e) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
anchor="bottom"
>
<Box
sx={{
width: 18,
height: 18,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
)}
</Map>
<Tooltip title="Моё местоположение">
<span>
<IconButton
size="small"
disabled={locating || !('geolocation' in navigator)}
onClick={() => {
if (!('geolocation' in navigator)) return
setLocating(true)
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
setLocating(false)
},
() => {
setLocating(false)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{hint && ( {hint && (
@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from 'react'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import { reverseGeocode } from '../api/map-geocoding'
import type { LatLng } from '../model/types'
import type * as maplibregl from 'maplibre-gl'
let maplibreglPromise: Promise<typeof maplibregl> | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
([mod]) => mod,
)
}
return maplibreglPromise
}
type MapPickerMapProps = {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
center: { lat: number; lng: number }
}
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
const mapRef = useRef<MapRef | null>(null)
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
const [loading, setLoading] = useState(true)
const [locating, setLocating] = useState(false)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) {
setMaplibre(mod)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [])
const pick = async (pos: LatLng) => {
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
}
}
if (loading || !maplibre) {
return (
<Box
sx={{
height: 280,
borderRadius: 2,
border: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibre}
ref={mapRef}
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
style={{ width: '100%', height: 280 }}
mapStyle={{
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
}}
onClick={(e: MapMouseEvent) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
>
{value && (
<Marker
longitude={value.lng}
latitude={value.lat}
draggable
onDragEnd={(e) => {
const { lng, lat } = e.lngLat
void pick({ lat, lng })
}}
anchor="bottom"
>
<Box
sx={{
width: 18,
height: 18,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
)}
</Map>
<Tooltip title="Моё местоположение">
<span>
<IconButton
size="small"
disabled={locating || !('geolocation' in navigator)}
onClick={() => {
if (!('geolocation' in navigator)) return
setLocating(true)
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
setLocating(false)
},
() => {
setLocating(false)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
)
}
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api' import { addToCart } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
type Props = { type Props = {
productId: string productId: string
@@ -18,7 +19,10 @@ export function AddToCartButton(props: Props) {
const addMut = useMutation({ const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }), mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
}) })
return ( return (
@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as notifications from '@/shared/model/cart-notifications'
import { AddToCartButton } from '../AddToCartButton'
vi.mock('@/entities/cart/api/cart-api', () => ({
addToCart: vi.fn(() => Promise.resolve()),
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
})
describe('AddToCartButton', () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
beforeEach(() => {
vi.clearAllMocks()
qc.clear()
})
it('calls cartAdded after successful add', async () => {
const spy = vi.spyOn(notifications, 'cartAdded')
render(
<QueryClientProvider client={qc}>
<AddToCartButton productId="test-product" />
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalled()
})
})
})
@@ -6,6 +6,7 @@ import { ShoppingCart } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api' import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
export function ToggleCartIcon(props: { export function ToggleCartIcon(props: {
productId: string productId: string
@@ -28,7 +29,10 @@ export function ToggleCartIcon(props: {
const addMut = useMutation({ const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }), mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
}) })
const removeMut = useMutation({ const removeMut = useMutation({
@@ -0,0 +1,69 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as api from '@/entities/cart/api/cart-api'
import * as notifications from '@/shared/model/cart-notifications'
import { ToggleCartIcon } from '../ToggleCartIcon'
vi.mock('@/entities/cart/api/cart-api', () => ({
addToCart: vi.fn(() => Promise.resolve()),
fetchMyCart: vi.fn(() => Promise.resolve({ items: [] })),
removeCartItem: vi.fn(() => Promise.resolve()),
}))
vi.mock('effector-react', async () => {
const actual = await vi.importActual('effector-react')
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
})
describe('ToggleCartIcon', () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
beforeEach(() => {
vi.clearAllMocks()
qc.clear()
})
it('calls cartAdded after successful add', async () => {
const spy = vi.spyOn(notifications, 'cartAdded')
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ToggleCartIcon productId="test-product" />
</MemoryRouter>
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
await vi.waitFor(() => {
expect(spy).toHaveBeenCalled()
})
})
it('does not call cartAdded on remove', async () => {
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
})
const spy = vi.spyOn(notifications, 'cartAdded')
render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ToggleCartIcon productId="test-product" />
</MemoryRouter>
</QueryClientProvider>,
)
await vi.waitFor(() => {
expect(screen.getByRole('button', { name: /убрать из корзины/i })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /убрать из корзины/i }))
await vi.waitFor(() => {
expect(spy).not.toHaveBeenCalled()
})
})
})
@@ -9,7 +9,7 @@ import { fetchAdminAvatar } from '@/entities/user/api/user-api'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
type Message = { type Message = {
@@ -21,7 +21,7 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm' import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
@@ -10,7 +10,7 @@ import { fetchPublicProductReviews } from '@/entities/review/api/reviews-api'
import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api' import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
@@ -10,7 +10,7 @@ import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import axios from 'axios' import axios from 'axios'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
type Props = { type Props = {
productTitle: string | null productTitle: string | null
@@ -1,6 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import PersonIcon from '@mui/icons-material/Person' import PersonIcon from '@mui/icons-material/Person'
import Badge from '@mui/material/Badge'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu' import Menu from '@mui/material/Menu'
@@ -35,19 +34,11 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props)
return ( return (
<> <>
<IconButton color="inherit" onClick={openMenu} sx={{ ml: 1 }} aria-label="Пользователь"> <IconButton color="inherit" onClick={openMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge {user ? (
variant="dot" <UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
color="success" ) : (
overlap="circular" <PersonIcon sx={{ fontSize: 28 }} />
invisible={!user} )}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
{user ? (
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
) : (
<PersonIcon sx={{ fontSize: 28 }} />
)}
</Badge>
</IconButton> </IconButton>
<Menu <Menu
-1
View File
@@ -2,7 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from '@/app/App' import { App } from '@/app/App'
import '@/app/styles/global.css' import '@/app/styles/global.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth' import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken()) tokenSet(readStoredToken())
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Map, { Marker } from 'react-map-gl/maplibre'
import type * as maplibregl from 'maplibre-gl'
let maplibreglPromise: Promise<typeof maplibregl> | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
([mod]) => mod,
)
}
return maplibreglPromise
}
const rasterStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}
type AboutMapProps = {
lat: number
lng: number
}
export function AboutMap({ lat, lng }: AboutMapProps) {
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) setMaplibre(mod)
})
return () => {
cancelled = true
}
}, [])
if (!maplibre) {
return (
<Box
sx={{
height: 380,
borderRadius: 2,
border: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box
sx={{
height: 380,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibre}
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
style={{ width: '100%', height: 380 }}
mapStyle={rasterStyle}
scrollZoom={false}
dragRotate={false}
dragPan={false}
doubleClickZoom={false}
keyboard={false}
touchZoomRotate={false}
>
<Marker longitude={lng} latitude={lat} anchor="bottom">
<Box
sx={{
width: 20,
height: 20,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
</Map>
</Box>
)
}
+4 -54
View File
@@ -3,24 +3,10 @@ import Link from '@mui/material/Link'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import * as maplibregl from 'maplibre-gl'
import Map, { Marker } from 'react-map-gl/maplibre'
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config' import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point' import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
import { usePageTitle } from '@/shared/lib/use-page-title' import { usePageTitle } from '@/shared/lib/use-page-title'
import { AboutMap } from './AboutMap'
const rasterStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
}
export function AboutPage() { export function AboutPage() {
usePageTitle('О нас') usePageTitle('О нас')
@@ -56,50 +42,14 @@ export function AboutPage() {
ВКонтакте ВКонтакте
</Link> </Link>
</Typography> </Typography>
<Typography sx={{ mb: 1, mt: 2 }}>Забрать заказ можно по адресу самовывоза:</Typography> <Typography sx={{ mb: 1, mt: 2 }}>Забрать заказ можно по адресу:</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography> <Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}> <Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Перед визитом мы свяжемся с вами и согласуем время чтобы заказ точно был готов к выдаче. Перед визитом согласуйте время чтобы заказ точно был готов к выдаче.
</Typography> </Typography>
</Paper> </Paper>
<Box <AboutMap lat={lat} lng={lng} />
sx={{
height: 380,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibregl}
initialViewState={{ latitude: lat, longitude: lng, zoom: 15 }}
style={{ width: '100%', height: 380 }}
mapStyle={rasterStyle}
scrollZoom={false}
dragRotate={false}
dragPan={false}
doubleClickZoom={false}
keyboard={false}
touchZoomRotate={false}
>
<Marker longitude={lng} latitude={lat} anchor="bottom">
<Box
sx={{
width: 20,
height: 20,
bgcolor: 'primary.main',
borderRadius: '50%',
border: 2,
borderColor: 'background.paper',
boxShadow: 3,
}}
/>
</Marker>
</Map>
</Box>
</Stack> </Stack>
</Box> </Box>
) )
@@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api' import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { getErrorMessage } from '@/shared/lib/get-error-message' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
export function AdminReviewsPage() { export function AdminReviewsPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles'
import { $user, updateProfileFx } from '@/shared/model/auth' import { $user, updateProfileFx } from '@/shared/model/auth'
import type { UpdateProfileParams } from '@/shared/model/auth' import type { UpdateProfileParams } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
@@ -165,7 +165,7 @@ export function AdminSettingsPage() {
<FormControl size="small" sx={{ minWidth: 140 }}> <FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Стиль</InputLabel> <InputLabel>Стиль</InputLabel>
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}> <Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
{AVATAR_STYLES.map((s) => ( {AVATAR_STYLE_LOADERS.map((s) => (
<MenuItem key={s.id} value={s.id}> <MenuItem key={s.id} value={s.id}>
{s.label} {s.label}
</MenuItem> </MenuItem>
@@ -174,10 +174,10 @@ export function AdminSettingsPage() {
</FormControl> </FormControl>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={async () => {
const seed = `${String(user.id)}_${Date.now()}` const seed = `${String(user.id)}_${Date.now()}`
const styleDef = getStyleById(selectedStyle) const style = await loadAvatarStyle(selectedStyle)
const avatar = createAvatar(styleDef.style, { seed }) const avatar = createAvatar(style, { seed })
setPreviewSrc(avatar.toDataUri()) setPreviewSrc(avatar.toDataUri())
setPreviewStyle(selectedStyle) setPreviewStyle(selectedStyle)
}} }}
@@ -1,8 +1,11 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert' import CancelIcon from '@mui/icons-material/Cancel'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import Accordion from '@mui/material/Accordion' import Accordion from '@mui/material/Accordion'
import AccordionDetails from '@mui/material/AccordionDetails' import AccordionDetails from '@mui/material/AccordionDetails'
import AccordionSummary from '@mui/material/AccordionSummary' import AccordionSummary from '@mui/material/AccordionSummary'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress' import CircularProgress from '@mui/material/CircularProgress'
@@ -21,11 +24,8 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items' import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
fetchTestChecklistResults, fetchTestChecklistResults,
resetTestChecklist, resetTestChecklist,
+2 -2
View File
@@ -54,7 +54,7 @@ export function AuthPage() {
> >
<Box sx={{ width: '100%', maxWidth: 440 }}> <Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ fontSize: 72 }} /> <BearLogo sx={{ width: 72, height: 72 }} />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom> <Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
@@ -88,7 +88,7 @@ export function AuthPage() {
> >
<Box sx={{ width: '100%', maxWidth: 440 }}> <Box sx={{ width: '100%', maxWidth: 440 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<BearLogo sx={{ fontSize: 72 }} /> <BearLogo sx={{ width: 72, height: 72 }} />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom> <Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
+13 -1
View File
@@ -59,7 +59,19 @@ export function CartPage() {
{cartQuery.isError && <Alert severity="error">Не удалось загрузить корзину.</Alert>} {cartQuery.isError && <Alert severity="error">Не удалось загрузить корзину.</Alert>}
{cartQuery.isSuccess && items.length === 0 && <Alert severity="info">Корзина пуста.</Alert>} {cartQuery.isSuccess && items.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
Корзина пуста
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Добавьте что-нибудь из каталога, чтобы оформить заказ.
</Typography>
<Button component={RouterLink} to="/" variant="contained">
Перейти в каталог
</Button>
</Box>
)}
{items.length > 0 && ( {items.length > 0 && (
<Stack spacing={2}> <Stack spacing={2}>
+10 -5
View File
@@ -70,10 +70,10 @@ export function HomePage() {
<Box> <Box>
<CatalogSlider /> <CatalogSlider />
<Typography variant="h4" component="h1" gutterBottom> <Typography variant="h4" component="h1" sx={{ mb: 1 }}>
{title} {title}
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body1" color="text.secondary" sx={{ mb: 3, maxWidth: 560 }}>
Игрушки, сувениры и другие изделия ручной работы. Игрушки, сувениры и другие изделия ручной работы.
</Typography> </Typography>
@@ -102,9 +102,14 @@ export function HomePage() {
)} )}
{productsQuery.isSuccess && products.length === 0 && ( {productsQuery.isSuccess && products.length === 0 && (
<Typography color="text.secondary" sx={{ mt: 2 }}> <Box sx={{ textAlign: 'center', py: 8 }}>
Пока нет опубликованных товаров. <Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
</Typography> Пока нет опубликованных товаров
</Typography>
<Typography variant="body2" color="text.secondary">
Загляните позже мы регулярно обновляем каталог.
</Typography>
</Box>
)} )}
{productsQuery.isSuccess && products.length > 0 && ( {productsQuery.isSuccess && products.length > 0 && (
@@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { createAvatar } from '@dicebear/core' import { createAvatar } from '@dicebear/core'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { AVATAR_STYLE_LOADERS, DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles'
import { $user, updateProfileFx } from '@/shared/model/auth' import { $user, updateProfileFx } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
@@ -72,7 +72,7 @@ export function AvatarSection() {
label="Стиль" label="Стиль"
onChange={(e) => setSelectedStyle(e.target.value)} onChange={(e) => setSelectedStyle(e.target.value)}
> >
{AVATAR_STYLES.map((s) => ( {AVATAR_STYLE_LOADERS.map((s) => (
<MenuItem key={s.id} value={s.id}> <MenuItem key={s.id} value={s.id}>
{s.label} {s.label}
</MenuItem> </MenuItem>
@@ -81,10 +81,10 @@ export function AvatarSection() {
</FormControl> </FormControl>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={async () => {
const seed = `${user.id}_${Date.now()}` const seed = `${user.id}_${Date.now()}`
const styleDef = getStyleById(selectedStyle) const style = await loadAvatarStyle(selectedStyle)
const avatar = createAvatar(styleDef.style, { seed }) const avatar = createAvatar(style, { seed })
setPreviewSrc(avatar.toDataUri()) setPreviewSrc(avatar.toDataUri())
setPreviewStyle(selectedStyle) setPreviewStyle(selectedStyle)
}} }}
@@ -20,8 +20,8 @@ import { usePageTitle } from '@/shared/lib/use-page-title'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
export function MessagesPage() { export function MessagesPage() {
+36 -18
View File
@@ -1,13 +1,15 @@
import { Box, Typography, Button, Stack, Paper } from '@mui/material' import { Box, Typography, Button, Stack, Paper } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink, useNavigate } from 'react-router-dom'
export function NotFoundPage() { export function NotFoundPage() {
const navigate = useNavigate()
return ( return (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
minHeight: '100vh', minHeight: '100dvh',
bgcolor: 'background.default', bgcolor: 'background.default',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -15,28 +17,44 @@ export function NotFoundPage() {
px: 2, px: 2,
}} }}
> >
<Paper sx={{ p: 4, borderRadius: 3, bgcolor: 'background.paper' }}> <Paper
elevation={0}
sx={{
p: { xs: 4, md: 6 },
borderRadius: 4,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
maxWidth: 480,
width: '100%',
}}
>
<Stack spacing={3} sx={{ alignItems: 'center', textAlign: 'center' }}> <Stack spacing={3} sx={{ alignItems: 'center', textAlign: 'center' }}>
<Box sx={{ fontSize: 96, lineHeight: 1 }}>404</Box> <Typography
<Typography variant="h4" component="h1" gutterBottom> variant="h1"
sx={{
fontSize: { xs: 72, md: 96 },
fontWeight: 700,
lineHeight: 1,
color: 'primary.main',
opacity: 0.15,
letterSpacing: '-4px',
}}
>
404
</Typography>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700, mt: -1 }}>
Страница не найдена Страница не найдена
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}> <Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
Извините, но запрашиваемая страница не существует или была удалена. Извините, но запрашиваемая страница не существует или была удалена.
</Typography> </Typography>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mt: 1, width: '100%' }}>
<Button <Button variant="contained" size="large" onClick={() => navigate('/')} sx={{ flexGrow: 1, px: 4 }}>
variant="contained" На главную
size="large"
onClick={() => {
window.location.href = '/'
}}
sx={{ px: 4 }}
>
Вернуться на главную
</Button> </Button>
<Button variant="outlined" size="large" component={RouterLink} to="/" sx={{ px: 4 }}> <Button variant="outlined" size="large" component={RouterLink} to="/" sx={{ flexGrow: 1, px: 4 }}>
Посмотреть каталог Каталог
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
@@ -1,17 +1,10 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { import { STORE_EMAIL, STORE_OP_NAME, STORE_OP_TYPE, STORE_OP_INN, STORE_OP_ADDR } from '@/shared/config'
STORE_EMAIL,
STORE_OP_NAME,
STORE_OP_TYPE,
STORE_OP_INN,
STORE_OP_ADDR,
STORE_PUBLIC_SITE_URL,
} from '@/shared/config'
import { usePageTitle } from '@/shared/lib/use-page-title' import { usePageTitle } from '@/shared/lib/use-page-title'
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') const SITE_URL = 'https://любимыйкреатив.рф'
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})` const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
+6 -7
View File
@@ -16,7 +16,6 @@ import { useParams } from 'react-router-dom'
import { Navigation } from 'swiper/modules' import { Navigation } from 'swiper/modules'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api' import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon' import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ProductReviewsList } from '@/features/product-review' import { ProductReviewsList } from '@/features/product-review'
@@ -73,14 +72,14 @@ export function ProductPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{imageUrls.length > 0 ? ( {imageUrls.length > 0 ? (
<Box <Box
sx={{ sx={{
borderRadius: 2, borderRadius: '20px 20px 12px 12px',
overflow: 'hidden', overflow: 'hidden',
border: 1, border: 'none',
borderColor: 'divider', boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
bgcolor: 'background.paper', bgcolor: 'background.paper',
}} }}
> >
@@ -151,10 +150,10 @@ export function ProductPage() {
</Box> </Box>
)} )}
<Typography variant="h4" component="h1"> <Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
{p.title} {p.title}
</Typography> </Typography>
<Typography variant="h5" color="primary"> <Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{formatPriceRub(p.priceCents)} {formatPriceRub(p.priceCents)}
</Typography> </Typography>
+2 -10
View File
@@ -1,18 +1,10 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { import { STORE_EMAIL, STORE_PHONE, STORE_OP_NAME, STORE_OP_TYPE, STORE_OP_INN, STORE_OP_ADDR } from '@/shared/config'
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
STORE_OP_NAME,
STORE_OP_TYPE,
STORE_OP_INN,
STORE_OP_ADDR,
} from '@/shared/config'
import { usePageTitle } from '@/shared/lib/use-page-title' import { usePageTitle } from '@/shared/lib/use-page-title'
const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') const SITE_URL = 'https://любимыйкреатив.рф'
const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})` const OP_FULL = `${STORE_OP_NAME} (${STORE_OP_TYPE})`
+43 -77
View File
@@ -1,88 +1,54 @@
import { create as adventurerCreate, meta as adventurerMeta, schema as adventurerSchema } from '@dicebear/adventurer'
import { create as avataaarsCreate, meta as avataaarsMeta, schema as avataaarsSchema } from '@dicebear/avataaars'
import { create as bigEarsCreate, meta as bigEarsMeta, schema as bigEarsSchema } from '@dicebear/big-ears'
import { create as bigSmileCreate, meta as bigSmileMeta, schema as bigSmileSchema } from '@dicebear/big-smile'
import { create as botttsCreate, meta as botttsMeta, schema as botttsSchema } from '@dicebear/bottts'
import { create as croodlesCreate, meta as croodlesMeta, schema as croodlesSchema } from '@dicebear/croodles'
import { create as funEmojiCreate, meta as funEmojiMeta, schema as funEmojiSchema } from '@dicebear/fun-emoji'
import { create as identiconCreate, meta as identiconMeta, schema as identiconSchema } from '@dicebear/identicon'
import { create as initialsCreate, meta as initialsMeta, schema as initialsSchema } from '@dicebear/initials'
import { create as loreleiCreate, meta as loreleiMeta, schema as loreleiSchema } from '@dicebear/lorelei'
import { create as micahCreate, meta as micahMeta, schema as micahSchema } from '@dicebear/micah'
import { create as notionistsCreate, meta as notionistsMeta, schema as notionistsSchema } from '@dicebear/notionists'
import { create as pixelArtCreate, meta as pixelArtMeta, schema as pixelArtSchema } from '@dicebear/pixel-art'
import { create as ringsCreate, meta as ringsMeta, schema as ringsSchema } from '@dicebear/rings'
import { create as shapesCreate, meta as shapesMeta, schema as shapesSchema } from '@dicebear/shapes'
import { create as thumbsCreate, meta as thumbsMeta, schema as thumbsSchema } from '@dicebear/thumbs'
import type { Style } from '@dicebear/core' import type { Style } from '@dicebear/core'
type StyleDef = { type StyleDef = {
id: string id: string
label: string label: string
style: Style<any> loader: () => Promise<{ create: any; meta: any; schema: any }>
} }
export const AVATAR_STYLES: StyleDef[] = [ export const AVATAR_STYLE_LOADERS: StyleDef[] = [
{ id: 'bottts', label: 'Роботы', style: { create: botttsCreate, meta: botttsMeta, schema: botttsSchema } }, { id: 'bottts', label: 'Роботы', loader: () => import('@dicebear/bottts') },
{ { id: 'identicon', label: 'Узоры', loader: () => import('@dicebear/identicon') },
id: 'identicon', { id: 'avataaars', label: 'Персонажи', loader: () => import('@dicebear/avataaars') },
label: 'Узоры', { id: 'notionists', label: 'Notion', loader: () => import('@dicebear/notionists') },
style: { create: identiconCreate, meta: identiconMeta, schema: identiconSchema }, { id: 'thumbs', label: 'Thumbs', loader: () => import('@dicebear/thumbs') },
}, { id: 'lorelei', label: 'Lorelei', loader: () => import('@dicebear/lorelei') },
{ { id: 'micah', label: 'Micah', loader: () => import('@dicebear/micah') },
id: 'avataaars', { id: 'pixel-art', label: 'Пиксели', loader: () => import('@dicebear/pixel-art') },
label: 'Персонажи', { id: 'rings', label: 'Кольца', loader: () => import('@dicebear/rings') },
style: { create: avataaarsCreate, meta: avataaarsMeta, schema: avataaarsSchema }, { id: 'shapes', label: 'Фигуры', loader: () => import('@dicebear/shapes') },
}, { id: 'initials', label: 'Инициалы', loader: () => import('@dicebear/initials') },
{ { id: 'adventurer', label: 'Adventurer', loader: () => import('@dicebear/adventurer') },
id: 'notionists', { id: 'big-ears', label: 'Big Ears', loader: () => import('@dicebear/big-ears') },
label: 'Notion', { id: 'big-smile', label: 'Big Smile', loader: () => import('@dicebear/big-smile') },
style: { create: notionistsCreate, meta: notionistsMeta, schema: notionistsSchema }, { id: 'croodles', label: 'Croodles', loader: () => import('@dicebear/croodles') },
}, { id: 'fun-emoji', label: 'Fun Emoji', loader: () => import('@dicebear/fun-emoji') },
{ id: 'thumbs', label: 'Thumbs', style: { create: thumbsCreate, meta: thumbsMeta, schema: thumbsSchema } },
{ id: 'lorelei', label: 'Lorelei', style: { create: loreleiCreate, meta: loreleiMeta, schema: loreleiSchema } },
{ id: 'micah', label: 'Micah', style: { create: micahCreate, meta: micahMeta, schema: micahSchema } },
{
id: 'pixel-art',
label: 'Пиксели',
style: { create: pixelArtCreate, meta: pixelArtMeta, schema: pixelArtSchema },
},
{ id: 'rings', label: 'Кольца', style: { create: ringsCreate, meta: ringsMeta, schema: ringsSchema } },
{ id: 'shapes', label: 'Фигуры', style: { create: shapesCreate, meta: shapesMeta, schema: shapesSchema } },
{
id: 'initials',
label: 'Инициалы',
style: { create: initialsCreate, meta: initialsMeta, schema: initialsSchema },
},
{
id: 'adventurer',
label: 'Adventurer',
style: { create: adventurerCreate, meta: adventurerMeta, schema: adventurerSchema },
},
{
id: 'big-ears',
label: 'Big Ears',
style: { create: bigEarsCreate, meta: bigEarsMeta, schema: bigEarsSchema },
},
{
id: 'big-smile',
label: 'Big Smile',
style: { create: bigSmileCreate, meta: bigSmileMeta, schema: bigSmileSchema },
},
{
id: 'croodles',
label: 'Croodles',
style: { create: croodlesCreate, meta: croodlesMeta, schema: croodlesSchema },
},
{
id: 'fun-emoji',
label: 'Fun Emoji',
style: { create: funEmojiCreate, meta: funEmojiMeta, schema: funEmojiSchema },
},
] ]
export const DEFAULT_STYLE_ID = 'avataaars' export const DEFAULT_STYLE_ID = 'initials'
export function getStyleById(id: string | null | undefined): StyleDef { const styleCache = new Map<string, Style<any>>()
return AVATAR_STYLES.find((s) => s.id === id) ?? AVATAR_STYLES[0]
export async function loadAvatarStyle(id: string): Promise<Style<any>> {
if (styleCache.has(id)) {
return styleCache.get(id)!
}
const loader = AVATAR_STYLE_LOADERS.find((s) => s.id === id)
if (!loader) {
const fallback = AVATAR_STYLE_LOADERS.find((s) => s.id === DEFAULT_STYLE_ID)!
const mod = await fallback.loader()
const style = { create: mod.create, meta: mod.meta, schema: mod.schema }
styleCache.set(DEFAULT_STYLE_ID, style)
return style
}
const mod = await loader.loader()
const style = { create: mod.create, meta: mod.meta, schema: mod.schema }
styleCache.set(id, style)
return style
}
export function getStyleLabel(id: string): string {
return AVATAR_STYLE_LOADERS.find((s) => s.id === id)?.label ?? id
} }
@@ -0,0 +1,23 @@
import { allSettled, fork } from 'effector'
import { describe, it, expect } from 'vitest'
import { $cartSnackOpen, cartAdded, cartDismissed } from '../cart-notifications'
describe('cart-notifications store', () => {
it('opens on cartAdded', async () => {
const scope = fork()
await allSettled(cartAdded, { scope })
expect(scope.getState($cartSnackOpen)).toBe(true)
})
it('closes on cartDismissed', async () => {
const scope = fork()
await allSettled(cartAdded, { scope })
await allSettled(cartDismissed, { scope })
expect(scope.getState($cartSnackOpen)).toBe(false)
})
it('starts closed by default', () => {
const scope = fork()
expect(scope.getState($cartSnackOpen)).toBe(false)
})
})
@@ -0,0 +1,8 @@
import { createEvent, createStore } from 'effector'
export const cartAdded = createEvent()
export const cartDismissed = createEvent()
export const $cartSnackOpen = createStore(false)
.on(cartAdded, () => true)
.on(cartDismissed, () => false)
+17 -37
View File
@@ -1,41 +1,21 @@
import SvgIcon from '@mui/material/SvgIcon' import Box from '@mui/material/Box'
import type { SvgIconProps } from '@mui/material/SvgIcon' import type { SxProps, Theme } from '@mui/material/styles'
export function BearLogo(props: SvgIconProps) { type BearLogoProps = {
sx?: SxProps<Theme>
}
export function BearLogo({ sx }: BearLogoProps) {
return ( return (
<SvgIcon viewBox="0 0 64 64" {...props}> <Box
<path component="img"
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" src="/logo.webp"
fill="currentColor" alt="Любимый Креатив"
opacity="0.35" sx={{
/> objectFit: 'contain',
<path transform: 'scale(1.25)',
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" ...sx,
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>
) )
} }
+90
View File
@@ -0,0 +1,90 @@
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Snackbar from '@mui/material/Snackbar'
import { useUnit } from 'effector-react'
import { useNavigate } from 'react-router-dom'
import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications'
export function CartSnackbar() {
const open = useUnit($cartSnackOpen)
const navigate = useNavigate()
const handleClose = (_event: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return
cartDismissed()
}
const handleGoToCart = () => {
cartDismissed()
navigate('/cart')
}
return (
<Snackbar
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
autoHideDuration={4000}
sx={{
'& .MuiSnackbarContent-root': {
borderRadius: 12,
border: '1px solid',
borderColor: 'rgba(0,0,0,0.06)',
bgcolor: 'background.paper',
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
},
}}
>
<Alert
severity="success"
variant="standard"
onClose={handleClose}
sx={{
bgcolor: 'transparent',
border: 'none',
boxShadow: 'none',
p: 1.5,
alignItems: 'center',
'& .MuiAlert-icon': {
padding: 0,
mr: 1.5,
display: 'flex',
alignItems: 'center',
},
'& .MuiAlert-message': {
padding: 0,
fontSize: '0.875rem',
fontWeight: 600,
},
'& .MuiAlert-action': {
padding: 0,
mr: 0,
ml: 1,
},
}}
action={
<Button
size="small"
variant="text"
onClick={handleGoToCart}
sx={{
fontWeight: 600,
fontSize: '0.8125rem',
px: 1.5,
py: 0.5,
borderRadius: 8,
color: 'success.main',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
Перейти в корзину
</Button>
}
>
Товар добавлен в корзину
</Alert>
</Snackbar>
)
}
+25
View File
@@ -0,0 +1,25 @@
import Box from '@mui/material/Box'
import type { SxProps, Theme } from '@mui/material/styles'
type Props = {
opacity?: number
sx?: SxProps<Theme>
}
export function NoiseOverlay({ opacity = 0.03, sx }: Props) {
return (
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9999,
opacity,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
backgroundRepeat: 'repeat',
backgroundSize: '256px 256px',
...sx,
}}
/>
)
}
+3 -2
View File
@@ -1,3 +1,4 @@
import * as React from 'react'
import { useMemo } from 'react' import { useMemo } from 'react'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import type { SxProps, Theme } from '@mui/material/styles' import type { SxProps, Theme } from '@mui/material/styles'
@@ -35,7 +36,7 @@ function buildFallbackSrc(src: string, width: number): string {
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}` return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
} }
export function OptimizedImage({ export const OptimizedImage = React.memo(function OptimizedImage({
src, src,
alt, alt,
widths = DEFAULT_WIDTHS, widths = DEFAULT_WIDTHS,
@@ -67,4 +68,4 @@ export function OptimizedImage({
/> />
</Box> </Box>
) )
} })
+1 -1
View File
@@ -1,5 +1,5 @@
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
type Props = { type Props = {
text: string text: string
@@ -0,0 +1,26 @@
import { lazy, Suspense } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
const RichTextMessageContentImpl = lazy(() =>
import('./RichTextMessageContent').then((m) => ({ default: m.RichTextMessageContent })),
)
type RichTextMessageContentProps = {
value: string
tone?: 'default' | 'review' | 'chat'
}
export function RichTextMessageContent(props: RichTextMessageContentProps) {
return (
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', p: 1 }}>
<CircularProgress size={16} />
</Box>
}
>
<RichTextMessageContentImpl {...props} />
</Suspense>
)
}
@@ -0,0 +1,28 @@
import { lazy, Suspense } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
const RichTextMessageEditorImpl = lazy(() =>
import('./RichTextMessageEditor').then((m) => ({ default: m.RichTextMessageEditor })),
)
type RichTextMessageEditorProps = {
value: string
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
}
export function RichTextMessageEditor(props: RichTextMessageEditorProps) {
return (
<Suspense
fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<CircularProgress size={20} />
</Box>
}
>
<RichTextMessageEditorImpl {...props} />
</Suspense>
)
}
@@ -30,18 +30,19 @@ export function RichTextMessageEditor({
content: initialContent, content: initialContent,
editable: !disabled, editable: !disabled,
onUpdate: ({ editor: tiptap }) => { onUpdate: ({ editor: tiptap }) => {
if (tiptap.isDestroyed) return
const plainText = tiptap.getText().trim() const plainText = tiptap.getText().trim()
onChange(plainText ? tiptap.getHTML() : '') onChange(plainText ? tiptap.getHTML() : '')
}, },
}) })
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor || editor.isDestroyed) return
editor.setEditable(!disabled) editor.setEditable(!disabled)
}, [disabled, editor]) }, [disabled, editor])
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor || editor.isDestroyed) return
const normalizedValue = value.trim() ? value : '<p></p>' const normalizedValue = value.trim() ? value : '<p></p>'
if (editor.getHTML() === normalizedValue) return if (editor.getHTML() === normalizedValue) return
editor.commands.setContent(normalizedValue, { emitUpdate: false }) editor.commands.setContent(normalizedValue, { emitUpdate: false })
@@ -52,8 +53,8 @@ export function RichTextMessageEditor({
<Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}> <Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}>
<IconButton <IconButton
size="small" size="small"
onClick={() => editor?.chain().focus().toggleBold().run()} onClick={() => editor?.chain()?.focus().toggleBold().run()}
color={editor?.isActive('bold') ? 'primary' : 'default'} color={editor && !editor.isDestroyed && editor.isActive('bold') ? 'primary' : 'default'}
disabled={disabled} disabled={disabled}
aria-label="Жирный" aria-label="Жирный"
> >
@@ -61,8 +62,8 @@ export function RichTextMessageEditor({
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
onClick={() => editor?.chain().focus().toggleItalic().run()} onClick={() => editor?.chain()?.focus().toggleItalic().run()}
color={editor?.isActive('italic') ? 'primary' : 'default'} color={editor && !editor.isDestroyed && editor.isActive('italic') ? 'primary' : 'default'}
disabled={disabled} disabled={disabled}
aria-label="Курсив" aria-label="Курсив"
> >
@@ -70,8 +71,8 @@ export function RichTextMessageEditor({
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
onClick={() => editor?.chain().focus().toggleBulletList().run()} onClick={() => editor?.chain()?.focus().toggleBulletList().run()}
color={editor?.isActive('bulletList') ? 'primary' : 'default'} color={editor && !editor.isDestroyed && editor.isActive('bulletList') ? 'primary' : 'default'}
disabled={disabled} disabled={disabled}
aria-label="Список" aria-label="Список"
> >
+32 -11
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react' import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import Avatar from '@mui/material/Avatar' import Avatar from '@mui/material/Avatar'
import type { SxProps, Theme } from '@mui/material/styles' import type { SxProps, Theme } from '@mui/material/styles'
import { createAvatar } from '@dicebear/core' import { createAvatar } from '@dicebear/core'
import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { DEFAULT_STYLE_ID, loadAvatarStyle } from '@/shared/lib/avatar-styles'
type UserAvatarProps = { type UserAvatarProps = {
userId: string userId: string
@@ -12,18 +13,38 @@ type UserAvatarProps = {
sx?: SxProps<Theme> sx?: SxProps<Theme>
} }
export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { export const UserAvatar = React.memo(function UserAvatar({
const generatedSrc = useMemo(() => { userId,
const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) avatarUrl,
const avatar = createAvatar(styleDef.style, { seed: userId }) avatarStyle,
return avatar.toDataUri() size = 40,
}, [userId, avatarStyle]) sx,
}: UserAvatarProps) {
const [generatedSrc, setGeneratedSrc] = useState<string | null>(null)
const styleId = avatarStyle || DEFAULT_STYLE_ID
const styleIdRef = useRef(styleId)
const src = avatarUrl || generatedSrc useEffect(() => {
let cancelled = false
styleIdRef.current = styleId
loadAvatarStyle(styleId).then((style) => {
if (!cancelled && styleIdRef.current === styleId) {
const avatar = createAvatar(style, { seed: userId })
setGeneratedSrc(avatar.toDataUri())
}
})
return () => {
cancelled = true
}
}, [userId, styleId])
const src = avatarUrl || generatedSrc || ''
return ( return (
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}> <Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
? {!src && '?'}
</Avatar> </Avatar>
) )
} })
@@ -0,0 +1,69 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { cartAdded, cartDismissed } from '@/shared/model/cart-notifications'
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const mod = await importOriginal<typeof import('react-router-dom')>()
return { ...mod, useNavigate: () => navigateMock }
})
beforeEach(() => {
navigateMock.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
function renderWithRouter() {
render(
<MemoryRouter initialEntries={['/']}>
<CartSnackbar />
</MemoryRouter>,
)
}
describe('CartSnackbar', () => {
it('is hidden when store is false', () => {
cartDismissed()
renderWithRouter()
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('shows snackbar when cartAdded is fired', () => {
renderWithRouter()
cartAdded()
expect(screen.getByText(/товар добавлен/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /перейти в корзину/i })).toBeInTheDocument()
})
it('closes on dismiss button click', () => {
renderWithRouter()
cartAdded()
const closeBtn = screen.getByLabelText(/закрыть/i)
fireEvent.click(closeBtn)
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('auto-closes after 4 seconds', () => {
vi.useFakeTimers()
renderWithRouter()
cartAdded()
act(() => {
vi.advanceTimersByTime(4000)
})
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('navigates to /cart and closes on "Перейти в корзину" click', () => {
renderWithRouter()
cartAdded()
fireEvent.click(screen.getByRole('button', { name: /перейти в корзину/i }))
expect(navigateMock).toHaveBeenCalledWith('/cart')
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
})
@@ -53,7 +53,7 @@ export function NavigationDrawer({
> >
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<BearLogo sx={{ fontSize: 28 }} /> <BearLogo sx={{ width: 28, height: 28 }} />
<Typography variant="h6">{STORE_NAME}</Typography> <Typography variant="h6">{STORE_NAME}</Typography>
</Box> </Box>
@@ -10,7 +10,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api' import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
import { UserAvatar } from '@/shared/ui/UserAvatar' import { UserAvatar } from '@/shared/ui/UserAvatar'
function formatReviewDate(iso: string): string { function formatReviewDate(iso: string): string {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
# Cart Added Snackbar Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show a global Snackbar notification with a "Перейти в корзину" button when a product is added to cart.
**Architecture:** Effector store (`$cartSnackOpen`) as single source of truth. `CartSnackbar` component subscribes via `useUnit` and renders MUI Snackbar. `AddToCartButton` and `ToggleCartIcon` fire `cartAdded()` event on mutation success.
**Tech Stack:** effector/effector-react, @mui/material (Snackbar, Alert), react-router-dom (useNavigate), vitest + testing-library
---
### Task 1: Effector store for cart notification
**Files:**
- Create: `client/src/shared/model/cart-notifications.ts`
- [ ] **Step 1: Write the store**
```ts
import { createEvent, createStore } from 'effector'
export const cartAdded = createEvent()
export const cartDismissed = createEvent()
export const $cartSnackOpen = createStore(false)
.on(cartAdded, () => true)
.on(cartDismissed, () => false)
```
- [ ] **Step 2: Commit**
```bash
git add client/src/shared/model/cart-notifications.ts
git commit -m "feat: add cart notification effector store"
```
---
### Task 2: CartSnackbar component
**Files:**
- Create: `client/src/shared/ui/CartSnackbar.tsx`
- Test: `client/src/shared/ui/__tests__/CartSnackbar.test.tsx`
- [ ] **Step 1: Write the failing test**
```tsx
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { cartAdded, cartDismissed, $cartSnackOpen } from '@/shared/model/cart-notifications'
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
function renderWithRouter() {
render(
<MemoryRouter>
<CartSnackbar />
</MemoryRouter>,
)
}
describe('CartSnackbar', () => {
it('is hidden when store is false', () => {
cartDismissed()
renderWithRouter()
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('shows snackbar when cartAdded is fired', () => {
renderWithRouter()
cartAdded()
expect(screen.getByText(/товар добавлен/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /перейти в корзину/i })).toBeInTheDocument()
})
it('closes on dismiss button click', () => {
renderWithRouter()
cartAdded()
const closeBtn = screen.getByLabelText(/закрыть/i)
fireEvent.click(closeBtn)
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
it('auto-closes after 4 seconds', () => {
vi.useFakeTimers()
renderWithRouter()
cartAdded()
act(() => {
vi.advanceTimersByTime(4000)
})
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
vi.useRealTimers()
})
it('navigates to /cart and closes on "Перейти в корзину" click', () => {
renderWithRouter()
cartAdded()
const goBtn = screen.getByRole('button', { name: /перейти в корзину/i })
fireEvent.click(goBtn)
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run test to verify it fails**
```bash
cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx
```
Expected: FAIL — module `@/shared/model/cart-notifications` not found or component not exported.
- [ ] **Step 3: Write the component**
```tsx
import { useEffect } from 'react'
import Alert from '@mui/material/Alert'
import Button from '@mui/material/Button'
import Snackbar from '@mui/material/Snackbar'
import { useNavigate } from 'react-router-dom'
import { useUnit } from 'effector-react'
import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications'
export function CartSnackbar() {
const open = useUnit($cartSnackOpen)
const navigate = useNavigate()
useEffect(() => {
if (!open) return
const timer = setTimeout(() => cartDismissed(), 4000)
return () => clearTimeout(timer)
}, [open])
const handleClose = () => cartDismissed()
const handleGoToCart = () => {
cartDismissed()
navigate('/cart')
}
return (
<Snackbar
open={open}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
autoHideDuration={4000}
>
<Alert severity="success" onClose={handleClose} action={
<Button color="success" size="small" onClick={handleGoToCart}>
Перейти в корзину
</Button>
}>
Товар добавлен в корзину
</Alert>
</Snackbar>
)
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx
```
Expected: All 5 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add client/src/shared/ui/CartSnackbar.tsx client/src/shared/ui/__tests__/CartSnackbar.test.tsx
git commit -m "feat: add CartSnackbar component with tests"
```
---
### Task 3: Mount CartSnackbar in AppProviders
**Files:**
- Modify: `client/src/app/providers/AppProviders.tsx`
- [ ] **Step 1: Add CartSnackbar to AppProviders**
Add import at the top (after existing imports):
```tsx
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
```
Add `<CartSnackbar />` inside `QueryClientProvider`, after `<SseProvider />`:
```tsx
return (
<QueryClientProvider client={queryClient}>
<SseProvider />
<CartSnackbar />
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
)
```
- [ ] **Step 2: Verify no lint errors**
```bash
cd client && npx eslint src/app/providers/AppProviders.tsx
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add client/src/app/providers/AppProviders.tsx
git commit -m "feat: mount CartSnackbar in AppProviders"
```
---
### Task 4: Integrate cartAdded into AddToCartButton
**Files:**
- Modify: `client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx`
- [ ] **Step 1: Add cartAdded call to onSuccess**
Add import:
```tsx
import { cartAdded } from '@/shared/model/cart-notifications'
```
Change the `onSuccess` in `addMut`:
```tsx
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
```
Full file after change:
```tsx
import Button from '@mui/material/Button'
import type { ButtonProps } from '@mui/material/Button'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { addToCart } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
type Props = {
productId: string
qty?: number
loggedOutLabel?: string
} & Omit<ButtonProps, 'onClick'>
export function AddToCartButton(props: Props) {
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
const qc = useQueryClient()
const user = useUnit($user)
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
return (
<Button
{...rest}
disabled={Boolean(disabled) || !user || addMut.isPending}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
addMut.mutate()
}}
>
{user ? (children ?? 'В корзину') : loggedOutLabel}
</Button>
)
}
```
- [ ] **Step 2: Verify no lint errors**
```bash
cd client && npx eslint src/features/cart/add-to-cart/ui/AddToCartButton.tsx
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx
git commit -m "feat: fire cartAdded event in AddToCartButton"
```
---
### Task 5: Integrate cartAdded into ToggleCartIcon
**Files:**
- Modify: `client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx`
- [ ] **Step 1: Add cartAdded call to add-mutation onSuccess only**
Add import:
```tsx
import { cartAdded } from '@/shared/model/cart-notifications'
```
Change the `onSuccess` in `addMut` (NOT `removeMut`):
```tsx
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
```
Full file after change:
```tsx
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { ShoppingCart } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
import { cartAdded } from '@/shared/model/cart-notifications'
export function ToggleCartIcon(props: {
productId: string
size?: 'small' | 'medium'
disabledReason?: string | null
}) {
const { productId, size = 'small', disabledReason = null } = props
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
const inCart = Boolean(existing)
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
const removeMut = useMutation({
mutationFn: () => removeCartItem(existing!.id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const disabled = Boolean(disabledReason)
const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabledReason) return
if (!user) {
navigate('/auth')
return
}
if (inCart) removeMut.mutate()
else addMut.mutate()
}
const tooltip = disabledReason
? disabledReason
: !user
? 'Авторизуйтесь для совершения покупок'
: inCart
? 'Убрать из корзины'
: 'В корзину'
return (
<Tooltip title={tooltip}>
<span>
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
{user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
</IconButton>
</span>
</Tooltip>
)
}
```
- [ ] **Step 2: Verify no lint errors**
```bash
cd client && npx eslint src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
git commit -m "feat: fire cartAdded event in ToggleCartIcon add mutation"
```
---
### Task 6: Final verification
- [ ] **Step 1: Run full client lint**
```bash
cd client && npm run lint
```
Expected: 0 errors (warnings OK).
- [ ] **Step 2: Run full client test suite**
```bash
cd client && npm test
```
Expected: All tests PASS including new CartSnackbar tests.
- [ ] **Step 3: Run Prettier check**
```bash
cd client && npm run format:check
```
Expected: All files match formatting.
- [ ] **Step 4: Final commit if any changes**
```bash
git add -A
git commit -m "chore: lint and format fixes for cart snackbar"
```
@@ -0,0 +1,129 @@
# Frontend Performance Optimization — Design Spec
**Date:** 2026-05-24
**Author:** opencode
**Status:** Draft
## Problem
Lighthouse performance score: **0.51**
| Metric | Value | Score |
|--------|-------|-------|
| FCP | 5.5s | 0 |
| LCP | 10.8s | 0 |
| TBT | 10ms | 1 |
| CLS | 0.122 | 0.84 |
**Root causes:**
- 9.9 MB total transfer size (dev mode, 368 requests)
- maplibre-gl (1.3 MB) loaded on every page, used only in About + AddressMapPicker
- 18 @dicebear packages (~2 MB) statically imported, never code-split
- TipTap (116 KB) always loaded, used only in Messages/Reviews
- Only 2 of 14+ routes lazy-loaded
- Zero React.memo usage — uncontrolled re-renders
## Architecture
### 1. Code-split maplibre-gl
**Files affected:**
- `client/src/main.tsx` — remove global `import 'maplibre-gl/dist/maplibre-gl.css'`
- `client/src/features/address-map-picker/ui/AddressMapPicker.tsx` — dynamic import
- `client/src/pages/about/ui/AboutPage.tsx` — dynamic import
**Implementation:**
- Remove CSS import from `main.tsx`
- In components that use map, load CSS + JS via `import()` inside `useEffect` or `React.lazy`
- Show loading skeleton while map loads
### 2. Code-split TipTap
**Files affected:**
- `client/src/shared/ui/RichTextMessageContent.tsx` — lazy wrapper
- `client/src/shared/ui/RichTextMessageEditor.tsx` — lazy wrapper
- `client/src/features/order-chat/` — add Suspense boundary
- `client/src/widgets/reviews-block/` — add Suspense boundary
**Implementation:**
- Create lazy exports in `shared/ui/index.ts`
- Wrap TipTap-dependent components in `<Suspense fallback={<Skeleton>}>`
### 3. Code-split dicebear avatar styles
**Files affected:**
- `client/src/shared/lib/avatar-styles.ts` — dynamic import map
**Implementation:**
- Replace static imports with factory map: `{ styleName: () => import('@dicebear/style') }`
- Cache loaded styles in `Map<string, Style>`
- Load style on first use, not at module initialization
### 4. Lazy-load all public routes
**Files affected:**
- `client/src/app/routes/index.tsx` — convert all routes to lazy + Suspense
**Routes to lazy-load:**
- `/` (HomePage)
- `/auth` (AuthPage)
- `/auth/callback` (AuthCallbackPage)
- `/cart` (CartPage)
- `/checkout` (CheckoutPage)
- `/about` (AboutPage)
- `/info/*` (InfoPage)
- `/privacy` (PrivacyPolicyPage)
- `/terms` (TermsPage)
- `/products/:id` (ProductPage)
**Exception:** 404 route stays synchronous (lightweight).
**Fallback:** `SkeletonPage` for consistency with existing admin/me lazy routes.
### 5. React.memo on key components
**Files affected:**
- `client/src/entities/product/ui/ProductCard.tsx`
- `client/src/shared/ui/OptimizedImage.tsx`
- `client/src/shared/ui/UserAvatar.tsx`
- `client/src/app/layout/AppHeader.tsx`
**Implementation:**
- `ProductCard`: memo with custom comparator comparing `product.id` and `onClick` reference
- `OptimizedImage`: memo comparing `src`, `alt`, `priority`
- `UserAvatar`: memo comparing `userId`, `style`
- `AppHeader`: memo (default shallow comparison sufficient)
## Data Flow
```
User navigates → Router loads lazy route chunk → Component mounts
Dynamic imports (maplibre/TipTap) fire
Suspense shows fallback → Content renders
```
## Error Handling
- All `React.lazy` components wrapped in `<Suspense>` with fallback
- Dynamic imports wrapped in try/catch with error boundary fallback
- Dicebear style loading: fallback to default style if dynamic import fails
## Testing
- Verify routes still work after lazy conversion (manual navigation)
- Verify map loads correctly in AboutPage and AddressMapPicker
- Verify TipTap renders in Messages and Reviews
- Verify avatars render with correct styles
- Run `npm run lint` and `npm test` after changes
- Run `npm run build` to verify production bundle
## Success Criteria
- Initial bundle size reduced by ~3-4 MB (dev mode)
- FCP improved from 5.5s to < 2.5s
- LCP improved from 10.8s to < 4.0s
- No regressions in functionality
- All tests pass
- Lint passes
@@ -0,0 +1,91 @@
# Cart Added Snackbar — Design Spec
**Date:** 2026-05-25
## Goal
При добавлении товара в корзину показывать глобальное уведомление (Snackbar) с кнопкой «Перейти в корзину».
## Scope
- `AddToCartButton` (каталог, карточки товаров)
- `ToggleCartIcon` (страница товара, toggle add/remove)
- Глобальный Snackbar, рендерится один раз
## Architecture
### 1. Effector store — `shared/model/cart-notifications.ts`
Минимальный стор: только булев флаг открытия.
```ts
import { createEvent, createStore } from 'effector'
export const cartAdded = createEvent()
export const cartDismissed = createEvent()
export const $cartSnackOpen = createStore(false)
.on(cartAdded, () => true)
.on(cartDismissed, () => false)
```
### 2. UI Component — `shared/ui/CartSnackbar.tsx`
Компонент подписывается на `$cartSnackOpen` через `useUnit`.
- **Текст:** «Товар добавлен в корзину»
- **Кнопка действия:** «Перейти в корзину» → `navigate('/cart')` + `cartDismissed()`
- **Закрытие (крестик):** `cartDismissed()`
- **Авто-закрытие:** 4 секунды через `setTimeout`
- **Позиция:** `anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}`
- **Стиль:** MUI `<Snackbar>` + `<Alert severity="success">`
Если пользователь быстро добавит несколько товаров — каждый `cartAdded()` перезапускает таймер. Очередь не нужна.
### 3. Интеграция в `AddToCartButton`
В `onSuccess` мутации добавляется `cartAdded()`:
```ts
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
```
`productTitle` не передаётся — текст уведомления универсальный.
### 4. Интеграция в `ToggleCartIcon`
Только add-мутация:
```ts
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
cartAdded()
},
})
```
Remove-мутация без уведомления.
### 5. Mount point — `app/AppProviders.tsx`
`<CartSnackbar />` рендерится один раз в `AppProviders.tsx` (или `App.tsx`), чтобы быть доступным во всём приложении.
## Dependencies
- effector / effector-react (уже используется)
- @mui/material — Snackbar, Alert (уже используется)
- react-router-dom — useNavigate (уже используется)
## Testing
- Unit-тест `CartSnackbar`: открывается по `cartAdded()`, закрывается по `cartDismissed()`
- Unit-тест `AddToCartButton`: вызывает `cartAdded()` в `onSuccess`
- Unit-тест `ToggleCartIcon`: вызывает `cartAdded()` только для add-мутации
+362 -282
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node --env-file=.env --watch src/index.js", "dev": "node --env-file=.env --unhandled-rejections=warn --watch src/index.js",
"dev:classic": "node --watch src/index.js", "dev:classic": "node --watch src/index.js",
"start": "node src/index.js", "start": "node src/index.js",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
Binary file not shown.
+57 -35
View File
@@ -141,10 +141,24 @@ await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify)
await registerYookassaWebhookRoute(fastify) await registerYookassaWebhookRoute(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
await notificationQueue.flushPendingOnStartup() try {
await ensureAdminUser()
} catch (err) {
fastify.log.error({ err }, 'ensureAdminUser failed — continuing startup')
}
try {
await getOrCreateUnspecifiedCategory()
} catch (err) {
fastify.log.error({ err }, 'getOrCreateUnspecifiedCategory failed — continuing startup')
}
try {
await notificationQueue.flushPendingOnStartup()
} catch (err) {
fastify.log.error({ err }, 'notificationQueue.flushPendingOnStartup failed')
}
notificationQueue.start() notificationQueue.start()
const { const {
@@ -158,9 +172,40 @@ const {
} = NOTIFICATION_EVENTS } = NOTIFICATION_EVENTS
async function dispatchNotification(eventType, payload) { async function dispatchNotification(eventType, payload) {
if (eventType === AUTH_CODE_REQUESTED) { try {
const targets = await resolveAuthCodeTargets(eventType, payload) if (eventType === AUTH_CODE_REQUESTED) {
for (const target of targets.filter((t) => t.channel === 'telegram')) { const targets = await resolveAuthCodeTargets(eventType, payload)
for (const target of targets.filter((t) => t.channel === 'telegram')) {
const log = await prisma.notificationLog.create({
data: {
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
}
return
}
const userTargets = await resolveUserNotificationTargets(eventType, payload)
for (const target of userTargets) {
const log = await prisma.notificationLog.create({
data: {
userId: payload.userId,
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
}
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
for (const target of adminTargets) {
const log = await prisma.notificationLog.create({ const log = await prisma.notificationLog.create({
data: { data: {
eventType, eventType,
@@ -171,35 +216,8 @@ async function dispatchNotification(eventType, payload) {
}) })
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id }) notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
} }
return } catch (err) {
} console.error(`[notification] Error dispatching ${eventType}:`, err.message)
const userTargets = await resolveUserNotificationTargets(eventType, payload)
for (const target of userTargets) {
const log = await prisma.notificationLog.create({
data: {
userId: payload.userId,
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
}
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
for (const target of adminTargets) {
const log = await prisma.notificationLog.create({
data: {
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
} }
} }
@@ -221,6 +239,10 @@ async function shutdown() {
process.on('SIGINT', shutdown) process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown) process.on('SIGTERM', shutdown)
process.on('unhandledRejection', (reason) => {
console.error('[process] Unhandled rejection:', reason?.message || reason)
})
try { try {
await fastify.listen({ port, host: '0.0.0.0' }) await fastify.listen({ port, host: '0.0.0.0' })
} catch (err) { } catch (err) {
+16 -8
View File
@@ -9,6 +9,9 @@ function createTransporter() {
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
connectionTimeout: 5000,
greetingTimeout: 5000,
socketTimeout: 5000,
auth: { auth: {
user: process.env.SMTP_USER, user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, pass: process.env.SMTP_PASS,
@@ -22,15 +25,20 @@ export async function sendLoginCodeEmail({ to, code }) {
return return
} }
const transporter = createTransporter() try {
const from = process.env.MAIL_FROM || process.env.SMTP_USER const transporter = createTransporter()
const from = process.env.MAIL_FROM || process.env.SMTP_USER
await transporter.sendMail({ await transporter.sendMail({
from, from,
to, to,
subject: 'Код входа', subject: 'Код входа',
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`, text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
}) })
} catch (err) {
console.error(`[email] Failed to send login code to ${to}: ${err.message}`)
console.info(`[DEV] login code for ${to}: ${code}`)
}
} }
export async function sendNotificationEmail({ to, subject, html }) { export async function sendNotificationEmail({ to, subject, html }) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { avataaars } from '@dicebear/collection' import { initials } from '@dicebear/collection'
import { createAvatar } from '@dicebear/core' import { createAvatar } from '@dicebear/core'
const DEFAULT_STYLE = avataaars const DEFAULT_STYLE = initials
export async function generateAvatar(seed) { export async function generateAvatar(seed) {
const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) }) const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
+37 -12
View File
@@ -49,15 +49,28 @@ export async function getOrCreateResized(uuid, width, format, subdir = '') {
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }) await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
const sharp = (await import('sharp')).default let sharpModule
let pipeline = sharp(originalPath) try {
sharpModule = (await import('sharp')).default
if (width) { } catch (err) {
pipeline = pipeline.resize(width, null, { withoutEnlargement: true }) const msg = `Failed to load sharp image processing library: ${err.message}`
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
} }
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } let pipeline
await pipeline[format](options).toFile(cachePath) try {
pipeline = sharpModule(originalPath)
if (width) {
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
}
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
} catch (err) {
const msg = `Failed to resize image ${originalPath} to ${width}w ${format}: ${err.message}`
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
}
return { path: cachePath, isNew: true } return { path: cachePath, isNew: true }
} }
@@ -75,17 +88,29 @@ export async function generateAllSizes(uuid, subdir, originalPath) {
const cacheDir = path.join(CACHE_DIR, cacheSubdir) const cacheDir = path.join(CACHE_DIR, cacheSubdir)
await fs.promises.mkdir(cacheDir, { recursive: true }) await fs.promises.mkdir(cacheDir, { recursive: true })
const sharp = (await import('sharp')).default let sharpModule
const source = sharp(originalPath) try {
sharpModule = (await import('sharp')).default
} catch (err) {
const msg = `Failed to load sharp image processing library: ${err.message}`
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' })
}
const source = sharpModule(originalPath)
for (const width of VALID_WIDTHS) { for (const width of VALID_WIDTHS) {
for (const format of SUPPORTED_FORMATS) { for (const format of SUPPORTED_FORMATS) {
const cacheFileName = `${uuid}_w${width}.${format}` const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
const pipeline = source.clone().resize(width, null, { withoutEnlargement: true }) try {
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } const pipeline = source.clone().resize(width, null, { withoutEnlargement: true })
await pipeline[format](options).toFile(cachePath) const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
} catch (err) {
const msg = `Failed to generate ${width}w ${format} for ${originalPath}: ${err.message}`
throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' })
}
} }
} }
} }
@@ -25,10 +25,17 @@ export async function registerAdminTestChecklistRoutes(fastify) {
const result = await prisma.checklistResult.upsert({ const result = await prisma.checklistResult.upsert({
where: { itemKey }, where: { itemKey },
create: { itemKey, passed, comment: passed ? null : comment || null }, create: { itemKey, passed, comment: passed ? null : comment || null },
update: { passed, comment: passed ? null : comment ?? undefined, checkedAt: new Date() }, update: { passed, comment: passed ? null : (comment ?? undefined), checkedAt: new Date() },
}) })
return { result: { itemKey: result.itemKey, passed: result.passed, comment: result.comment, checkedAt: result.checkedAt.toISOString() } } return {
result: {
itemKey: result.itemKey,
passed: result.passed,
comment: result.comment,
checkedAt: result.checkedAt.toISOString(),
},
}
}) })
fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
+1 -1
View File
@@ -112,7 +112,7 @@ export async function registerAuthRoutes(fastify) {
passwordHash, passwordHash,
displayName: displayName || null, displayName: displayName || null,
avatar: avatarUri, avatar: avatarUri,
avatarStyle: 'avataaars', avatarStyle: 'initials',
}, },
}) })
+1 -1
View File
@@ -90,7 +90,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
email, email,
displayName: norm ? norm.split('@')[0] : 'Пользователь', displayName: norm ? norm.split('@')[0] : 'Пользователь',
avatar: await generateAvatar(email), avatar: await generateAvatar(email),
avatarStyle: 'avataaars', avatarStyle: 'initials',
}, },
}) })
await prisma.oAuthAccount.create({ await prisma.oAuthAccount.create({
+11 -9
View File
@@ -105,6 +105,15 @@ export async function registerSseRoutes(fastify) {
}) })
let closed = false let closed = false
let heartbitTimer
let removeListeners
function cleanUp() {
if (closed) return
closed = true
clearInterval(heartbitTimer)
removeListeners()
}
function safeWrite(chunk) { function safeWrite(chunk) {
if (closed) return if (closed) return
@@ -121,18 +130,11 @@ export async function registerSseRoutes(fastify) {
safeWrite(formatHeartbit()) safeWrite(formatHeartbit())
const heartbitTimer = setInterval(() => { heartbitTimer = setInterval(() => {
safeWrite(formatHeartbit()) safeWrite(formatHeartbit())
}, 30_000) }, 30_000)
const removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite) removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite)
function cleanUp() {
if (closed) return
closed = true
clearInterval(heartbitTimer)
removeListeners()
}
request.raw.on('close', cleanUp) request.raw.on('close', cleanUp)
request.raw.on('error', cleanUp) request.raw.on('error', cleanUp)
+60 -51
View File
@@ -11,62 +11,71 @@ const CACHE_CONTROL_SHORT = 'public, max-age=86400'
*/ */
export function registerUploadsResized(fastify) { export function registerUploadsResized(fastify) {
fastify.get('/uploads-resized/*', async (request, reply) => { fastify.get('/uploads-resized/*', async (request, reply) => {
const rawPath = request.params['*'] try {
const url = new URL(request.url, 'http://localhost') const rawPath = request.params['*']
const widthParam = url.searchParams.get('w') if (typeof rawPath !== 'string') {
return reply.code(400).send({ error: 'Invalid request: missing file path' })
// Parse: [subdir/]filename.format
const parts = rawPath.split('/')
let filename,
subdir = ''
if (parts.length > 1) {
subdir = parts.slice(0, -1).join('/') + '/'
filename = parts[parts.length - 1]
} else {
filename = parts[0]
}
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) {
return reply.code(400).send({ error: 'Invalid request: no format specified' })
}
const uuid = filename.slice(0, dotIdx)
const format = filename.slice(dotIdx + 1).toLowerCase()
if (!SUPPORTED_FORMATS.has(format)) {
return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` })
}
// Validate width
let width = null
if (widthParam) {
const w = parseInt(widthParam, 10)
if (!VALID_WIDTHS.includes(w)) {
return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` })
} }
width = w
}
// If no width requested, serve original with short cache const url = new URL(request.url, 'http://localhost')
if (!width) { const widthParam = url.searchParams.get('w')
const originalPath = await findOriginalFile(uuid, subdir || undefined)
if (!originalPath) { // Parse: [subdir/]filename.format
const parts = rawPath.split('/')
let filename,
subdir = ''
if (parts.length > 1) {
subdir = parts.slice(0, -1).join('/') + '/'
filename = parts[parts.length - 1]
} else {
filename = parts[0]
}
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) {
return reply.code(400).send({ error: 'Invalid request: no format specified' })
}
const uuid = filename.slice(0, dotIdx)
const format = filename.slice(dotIdx + 1).toLowerCase()
if (!SUPPORTED_FORMATS.has(format)) {
return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` })
}
// Validate width
let width = null
if (widthParam) {
const w = parseInt(widthParam, 10)
if (!VALID_WIDTHS.includes(w)) {
return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` })
}
width = w
}
// If no width requested, serve original with short cache
if (!width) {
const originalPath = await findOriginalFile(uuid, subdir || undefined)
if (!originalPath) {
return reply.code(404).send({ error: 'Image not found' })
}
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
return reply.send(fs.createReadStream(originalPath))
}
const result = await getOrCreateResized(uuid, width, format, subdir || undefined)
if (!result) {
return reply.code(404).send({ error: 'Image not found' }) return reply.code(404).send({ error: 'Image not found' })
} }
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
return reply.send(fs.createReadStream(originalPath)) return reply.send(fs.createReadStream(result.path))
} catch (error) {
request.log.error({ err: error, url: request.url }, 'uploads-resized route error')
return reply.code(500).send({ error: error.message || 'Image resize failed' })
} }
const result = await getOrCreateResized(uuid, width, format, subdir || undefined)
if (!result) {
return reply.code(404).send({ error: 'Image not found' })
}
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
return reply.send(fs.createReadStream(result.path))
}) })
} }