Merge branch 'perfomance'
This commit is contained in:
+6
-2
@@ -2,7 +2,11 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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="description"
|
||||
@@ -13,7 +17,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" 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" />
|
||||
<link rel="canonical" href="https://любимыйкреатив.рф/" />
|
||||
</head>
|
||||
|
||||
Generated
+2422
-2752
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 |
@@ -1,7 +1,9 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AppProviders } from '@/app/providers/AppProviders'
|
||||
import { AppRoutes } from '@/app/routes'
|
||||
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
||||
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -10,6 +12,8 @@ export function App() {
|
||||
<ErrorBoundary>
|
||||
<AppRoutes />
|
||||
</ErrorBoundary>
|
||||
<CartSnackbar />
|
||||
<NoiseOverlay />
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Badge from '@mui/material/Badge'
|
||||
@@ -30,7 +31,7 @@ type NavItem = { label: string; to: string }
|
||||
|
||||
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
|
||||
|
||||
export function AppHeader() {
|
||||
export const AppHeader = React.memo(function AppHeader() {
|
||||
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
|
||||
const user = useUnit($user)
|
||||
const navigate = useNavigate()
|
||||
@@ -117,7 +118,7 @@ export function AppHeader() {
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<BearLogo sx={{ fontSize: 28 }} />
|
||||
<BearLogo sx={{ width: 28, height: 28 }} />
|
||||
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{STORE_NAME}
|
||||
</Typography>
|
||||
@@ -188,4 +189,4 @@ export function AppHeader() {
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,8 +23,10 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
<ScrollToTop />
|
||||
<AppHeader />
|
||||
|
||||
<Box component="main" sx={{ flex: 1, py: { xs: 4, md: 6 } }}>
|
||||
<Container maxWidth="lg">{children}</Container>
|
||||
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
|
||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -33,24 +35,19 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
mt: 'auto',
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.default',
|
||||
py: { xs: 4, md: 6 },
|
||||
bgcolor: 'background.paper',
|
||||
py: { xs: 5, md: 7 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Grid container spacing={4}>
|
||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||
<Grid container spacing={5}>
|
||||
<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>
|
||||
<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 size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}>
|
||||
@@ -111,7 +108,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Divider sx={{ my: 4 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
|
||||
© {year} {STORE_NAME}
|
||||
|
||||
@@ -7,12 +7,12 @@ import { SseProvider } from './SseProvider'
|
||||
|
||||
function AppThemeInner({ children }: PropsWithChildren) {
|
||||
const controller = useThemeController()
|
||||
const isDark = controller.resolvedMode === 'dark'
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: (() => {
|
||||
const isDark = controller.resolvedMode === 'dark'
|
||||
const common = { mode: controller.resolvedMode }
|
||||
|
||||
const text = isDark
|
||||
@@ -92,13 +92,16 @@ function AppThemeInner({ children }: PropsWithChildren) {
|
||||
shape: { borderRadius: 12 },
|
||||
typography: {
|
||||
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
|
||||
h4: { fontWeight: 700, letterSpacing: '-0.5px' },
|
||||
h5: { fontWeight: 600, letterSpacing: '-0.25px' },
|
||||
h6: { fontWeight: 600 },
|
||||
h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
|
||||
h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
|
||||
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 },
|
||||
subtitle2: { fontWeight: 500 },
|
||||
body1: { fontSize: '0.875rem' },
|
||||
body2: { fontSize: '0.75rem' },
|
||||
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||
body2: { fontSize: '0.75rem', lineHeight: 1.5 },
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
@@ -109,30 +112,34 @@ function AppThemeInner({ children }: PropsWithChildren) {
|
||||
borderRadius: 12,
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.15)',
|
||||
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
|
||||
'&: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)',
|
||||
},
|
||||
'&:active': {
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.15)',
|
||||
transform: 'translateY(0)',
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
|
||||
transform: 'translateY(0) scale(0.98)',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
border: '1px solid',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.1)',
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
|
||||
},
|
||||
'&:active': {
|
||||
boxShadow: 'none',
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
'&:hover': {
|
||||
boxShadow: '0 1px 3px 0 rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
'&:active': {
|
||||
@@ -147,12 +154,171 @@ function AppThemeInner({ children }: PropsWithChildren) {
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
transform: 'scale(1.1)',
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'action.selected',
|
||||
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
@@ -1,29 +1,38 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
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 { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
||||
|
||||
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
|
||||
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() {
|
||||
usePageTitleReset()
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
@@ -32,14 +41,70 @@ export function AppRoutes() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/info" element={<InfoPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route
|
||||
path="/auth"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<AuthPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<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
|
||||
path="/me/*"
|
||||
element={
|
||||
@@ -48,8 +113,22 @@ export function AppRoutes() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/products/:id" element={<ProductPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
<Route
|
||||
path="/products/:id"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<ProductPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<NotFoundPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -37,4 +40,6 @@ body,
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import Box from '@mui/material/Box'
|
||||
@@ -8,7 +9,6 @@ import Chip from '@mui/material/Chip'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Autoplay } from 'swiper/modules'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
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 }
|
||||
|
||||
export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
const ProductCardInner = ({ product, mediaHeight = 200, actions }: Props) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
@@ -58,14 +58,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '16px 16px 12px 12px',
|
||||
border: 'none',
|
||||
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': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.10)',
|
||||
transform: 'translateY(-6px)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
|
||||
},
|
||||
'&:hover .product-card__media': { transform: 'scale(1.06)' },
|
||||
'&:hover .product-card__title': { color: 'primary.main' },
|
||||
@@ -80,13 +80,13 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
{imageUrls.length ? (
|
||||
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
|
||||
<Swiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={0}
|
||||
allowTouchMove={!isMobile}
|
||||
onSwiper={(s) => {
|
||||
swiperRef.current = s
|
||||
}}
|
||||
modules={isMobile ? [Autoplay] : undefined}
|
||||
autoplay={isMobile ? { delay: 3000, disableOnInteraction: false, pauseOnMouseEnter: true } : undefined}
|
||||
allowTouchMove={!isMobile}
|
||||
style={{ width: '100%', height: mediaHeight }}
|
||||
style={{ width: '100%', height: mediaHeight, overflow: 'hidden' }}
|
||||
>
|
||||
{imageUrls.map((url) => (
|
||||
<SwiperSlide key={url}>
|
||||
@@ -238,7 +238,11 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
</Stack>
|
||||
|
||||
<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)}
|
||||
</Typography>
|
||||
{actions}
|
||||
@@ -247,3 +251,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
</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', {
|
||||
itemKey,
|
||||
passed,
|
||||
comment: passed ? null : comment ?? null,
|
||||
comment: passed ? null : (comment ?? null),
|
||||
})
|
||||
return data.result
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
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 { MapPickerMap } from './MapPickerMap'
|
||||
import type { LatLng, NominatimItem } from '../model/types'
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
@@ -21,10 +17,8 @@ export function AddressMapPicker(props: {
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
}) {
|
||||
const { value, onChange } = props
|
||||
const mapRef = useRef<MapRef | null>(null)
|
||||
const [q, setQ] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [locating, setLocating] = useState(false)
|
||||
const [results, setResults] = useState<NominatimItem[]>([])
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
@@ -36,7 +30,7 @@ export function AddressMapPicker(props: {
|
||||
|
||||
const center = useMemo(() => {
|
||||
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])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
@@ -60,7 +54,6 @@ export function AddressMapPicker(props: {
|
||||
}
|
||||
|
||||
const t = window.setTimeout(async () => {
|
||||
// throttle: не чаще 1 запроса в 900ms
|
||||
const now = Date.now()
|
||||
if (now - lastRequestAtRef.current < 900) return
|
||||
if (s === lastQueryRef.current) return
|
||||
@@ -128,7 +121,6 @@ export function AddressMapPicker(props: {
|
||||
const lat = Number(r.lat)
|
||||
const lng = Number(r.lon)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 })
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
@@ -138,103 +130,7 @@ export function AddressMapPicker(props: {
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Box
|
||||
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>
|
||||
<MapPickerMap value={value} onChange={onChange} center={center} />
|
||||
|
||||
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{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 { addToCart } from '@/entities/cart/api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { cartAdded } from '@/shared/model/cart-notifications'
|
||||
|
||||
type Props = {
|
||||
productId: string
|
||||
@@ -18,7 +19,10 @@ export function AddToCartButton(props: Props) {
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
cartAdded()
|
||||
},
|
||||
})
|
||||
|
||||
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 { 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
|
||||
@@ -28,7 +29,10 @@ export function ToggleCartIcon(props: {
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
cartAdded()
|
||||
},
|
||||
})
|
||||
|
||||
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 { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
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'
|
||||
|
||||
type Message = {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
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 { 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 { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||
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'
|
||||
|
||||
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import Rating from '@mui/material/Rating'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import axios from 'axios'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
|
||||
type Props = {
|
||||
productTitle: string | null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import PersonIcon from '@mui/icons-material/Person'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Menu from '@mui/material/Menu'
|
||||
@@ -35,19 +34,11 @@ export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props)
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={openMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
color="success"
|
||||
overlap="circular"
|
||||
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>
|
||||
{user ? (
|
||||
<UserAvatar userId={user.id} avatarUrl={user.avatar} avatarStyle={user.avatarStyle} size={28} />
|
||||
) : (
|
||||
<PersonIcon sx={{ fontSize: 28 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
|
||||
@@ -2,7 +2,6 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from '@/app/App'
|
||||
import '@/app/styles/global.css'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { readStoredToken, tokenSet } from '@/shared/model/auth'
|
||||
|
||||
tokenSet(readStoredToken())
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,24 +3,10 @@ import Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
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 { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
|
||||
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||
|
||||
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' }],
|
||||
}
|
||||
import { AboutMap } from './AboutMap'
|
||||
|
||||
export function AboutPage() {
|
||||
usePageTitle('О нас')
|
||||
@@ -56,50 +42,14 @@ export function AboutPage() {
|
||||
ВКонтакте
|
||||
</Link>
|
||||
</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 color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
|
||||
Перед визитом согласуйте время — чтобы заказ точно был готов к выдаче.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
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>
|
||||
<AboutMap lat={lat} lng={lng} />
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
|
||||
|
||||
export function AdminReviewsPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
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 type { UpdateProfileParams } from '@/shared/model/auth'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
@@ -165,7 +165,7 @@ export function AdminSettingsPage() {
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<InputLabel>Стиль</InputLabel>
|
||||
<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}>
|
||||
{s.label}
|
||||
</MenuItem>
|
||||
@@ -174,10 +174,10 @@ export function AdminSettingsPage() {
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const seed = `${String(user.id)}_${Date.now()}`
|
||||
const styleDef = getStyleById(selectedStyle)
|
||||
const avatar = createAvatar(styleDef.style, { seed })
|
||||
const style = await loadAvatarStyle(selectedStyle)
|
||||
const avatar = createAvatar(style, { seed })
|
||||
setPreviewSrc(avatar.toDataUri())
|
||||
setPreviewStyle(selectedStyle)
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 AccordionDetails from '@mui/material/AccordionDetails'
|
||||
import AccordionSummary from '@mui/material/AccordionSummary'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
@@ -21,11 +24,8 @@ import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchTestChecklistResults,
|
||||
resetTestChecklist,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function AuthPage() {
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<BearLogo sx={{ fontSize: 72 }} />
|
||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||
@@ -88,7 +88,7 @@ export function AuthPage() {
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 440 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<BearLogo sx={{ fontSize: 72 }} />
|
||||
<BearLogo sx={{ width: 72, height: 72 }} />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, textAlign: 'center' }} gutterBottom>
|
||||
|
||||
@@ -59,7 +59,19 @@ export function CartPage() {
|
||||
|
||||
{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 && (
|
||||
<Stack spacing={2}>
|
||||
|
||||
@@ -70,10 +70,10 @@ export function HomePage() {
|
||||
<Box>
|
||||
<CatalogSlider />
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, maxWidth: 560 }}>
|
||||
Игрушки, сувениры и другие изделия ручной работы.
|
||||
</Typography>
|
||||
|
||||
@@ -102,9 +102,14 @@ export function HomePage() {
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length === 0 && (
|
||||
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||
Пока нет опубликованных товаров.
|
||||
</Typography>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Пока нет опубликованных товаров
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Загляните позже — мы регулярно обновляем каталог.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length > 0 && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { createAvatar } from '@dicebear/core'
|
||||
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 { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AvatarSection() {
|
||||
label="Стиль"
|
||||
onChange={(e) => setSelectedStyle(e.target.value)}
|
||||
>
|
||||
{AVATAR_STYLES.map((s) => (
|
||||
{AVATAR_STYLE_LOADERS.map((s) => (
|
||||
<MenuItem key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</MenuItem>
|
||||
@@ -81,10 +81,10 @@ export function AvatarSection() {
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const seed = `${user.id}_${Date.now()}`
|
||||
const styleDef = getStyleById(selectedStyle)
|
||||
const avatar = createAvatar(styleDef.style, { seed })
|
||||
const style = await loadAvatarStyle(selectedStyle)
|
||||
const avatar = createAvatar(style, { seed })
|
||||
setPreviewSrc(avatar.toDataUri())
|
||||
setPreviewStyle(selectedStyle)
|
||||
}}
|
||||
|
||||
@@ -20,8 +20,8 @@ import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
export function MessagesPage() {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
minHeight: '100dvh',
|
||||
bgcolor: 'background.default',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -15,28 +17,44 @@ export function NotFoundPage() {
|
||||
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' }}>
|
||||
<Box sx={{ fontSize: 96, lineHeight: 1 }}>404</Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
<Typography
|
||||
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 variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
|
||||
Извините, но запрашиваемая страница не существует или была удалена.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
window.location.href = '/'
|
||||
}}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
Вернуться на главную
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mt: 1, width: '100%' }}>
|
||||
<Button variant="contained" size="large" onClick={() => navigate('/')} sx={{ flexGrow: 1, px: 4 }}>
|
||||
На главную
|
||||
</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>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import {
|
||||
STORE_EMAIL,
|
||||
STORE_OP_NAME,
|
||||
STORE_OP_TYPE,
|
||||
STORE_OP_INN,
|
||||
STORE_OP_ADDR,
|
||||
STORE_PUBLIC_SITE_URL,
|
||||
} from '@/shared/config'
|
||||
import { STORE_EMAIL, STORE_OP_NAME, STORE_OP_TYPE, STORE_OP_INN, STORE_OP_ADDR } from '@/shared/config'
|
||||
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})`
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useParams } from 'react-router-dom'
|
||||
import { Navigation } from 'swiper/modules'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { ProductReviewsList } from '@/features/product-review'
|
||||
@@ -73,14 +72,14 @@ export function ProductPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{imageUrls.length > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderRadius: '20px 20px 12px 12px',
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
@@ -151,10 +150,10 @@ export function ProductPage() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="h4" component="h1">
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="h5" color="primary">
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatPriceRub(p.priceCents)}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import {
|
||||
STORE_EMAIL,
|
||||
STORE_PHONE,
|
||||
STORE_PUBLIC_SITE_URL,
|
||||
STORE_OP_NAME,
|
||||
STORE_OP_TYPE,
|
||||
STORE_OP_INN,
|
||||
STORE_OP_ADDR,
|
||||
} from '@/shared/config'
|
||||
import { STORE_EMAIL, STORE_PHONE, STORE_OP_NAME, STORE_OP_TYPE, STORE_OP_INN, STORE_OP_ADDR } from '@/shared/config'
|
||||
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})`
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
type StyleDef = {
|
||||
id: string
|
||||
label: string
|
||||
style: Style<any>
|
||||
loader: () => Promise<{ create: any; meta: any; schema: any }>
|
||||
}
|
||||
|
||||
export const AVATAR_STYLES: StyleDef[] = [
|
||||
{ id: 'bottts', label: 'Роботы', style: { create: botttsCreate, meta: botttsMeta, schema: botttsSchema } },
|
||||
{
|
||||
id: 'identicon',
|
||||
label: 'Узоры',
|
||||
style: { create: identiconCreate, meta: identiconMeta, schema: identiconSchema },
|
||||
},
|
||||
{
|
||||
id: 'avataaars',
|
||||
label: 'Персонажи',
|
||||
style: { create: avataaarsCreate, meta: avataaarsMeta, schema: avataaarsSchema },
|
||||
},
|
||||
{
|
||||
id: 'notionists',
|
||||
label: 'Notion',
|
||||
style: { create: notionistsCreate, meta: notionistsMeta, schema: notionistsSchema },
|
||||
},
|
||||
{ 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 AVATAR_STYLE_LOADERS: StyleDef[] = [
|
||||
{ id: 'bottts', label: 'Роботы', loader: () => import('@dicebear/bottts') },
|
||||
{ id: 'identicon', label: 'Узоры', loader: () => import('@dicebear/identicon') },
|
||||
{ id: 'avataaars', label: 'Персонажи', loader: () => import('@dicebear/avataaars') },
|
||||
{ id: 'notionists', label: 'Notion', loader: () => import('@dicebear/notionists') },
|
||||
{ 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: 'pixel-art', label: 'Пиксели', loader: () => import('@dicebear/pixel-art') },
|
||||
{ id: 'rings', label: 'Кольца', loader: () => import('@dicebear/rings') },
|
||||
{ id: 'shapes', label: 'Фигуры', loader: () => import('@dicebear/shapes') },
|
||||
{ id: 'initials', label: 'Инициалы', loader: () => import('@dicebear/initials') },
|
||||
{ id: 'adventurer', label: 'Adventurer', loader: () => import('@dicebear/adventurer') },
|
||||
{ id: 'big-ears', label: 'Big Ears', loader: () => import('@dicebear/big-ears') },
|
||||
{ id: 'big-smile', label: 'Big Smile', loader: () => import('@dicebear/big-smile') },
|
||||
{ id: 'croodles', label: 'Croodles', loader: () => import('@dicebear/croodles') },
|
||||
{ id: 'fun-emoji', label: 'Fun Emoji', loader: () => import('@dicebear/fun-emoji') },
|
||||
]
|
||||
|
||||
export const DEFAULT_STYLE_ID = 'avataaars'
|
||||
export const DEFAULT_STYLE_ID = 'initials'
|
||||
|
||||
export function getStyleById(id: string | null | undefined): StyleDef {
|
||||
return AVATAR_STYLES.find((s) => s.id === id) ?? AVATAR_STYLES[0]
|
||||
const styleCache = new Map<string, Style<any>>()
|
||||
|
||||
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)
|
||||
@@ -1,41 +1,21 @@
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
import type { SvgIconProps } from '@mui/material/SvgIcon'
|
||||
import Box from '@mui/material/Box'
|
||||
import type { SxProps, Theme } from '@mui/material/styles'
|
||||
|
||||
export function BearLogo(props: SvgIconProps) {
|
||||
type BearLogoProps = {
|
||||
sx?: SxProps<Theme>
|
||||
}
|
||||
|
||||
export function BearLogo({ sx }: BearLogoProps) {
|
||||
return (
|
||||
<SvgIcon viewBox="0 0 64 64" {...props}>
|
||||
<path
|
||||
d="M18 24c-3.9 0-7-3.1-7-7s3.1-7 7-7c2.8 0 5.3 1.7 6.4 4.1C26.5 12.8 29.1 11 32 11s5.5 1.8 7.6 4.1C40.7 11.7 43.2 10 46 10c3.9 0 7 3.1 7 7s-3.1 7-7 7"
|
||||
fill="currentColor"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M32 18c-12.1 0-22 9.4-22 21 0 12.2 10.6 20 22 20s22-7.8 22-20c0-11.6-9.9-21-22-21Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M23 39c0 2.2-1.6 4-3.5 4S16 41.2 16 39s1.6-4 3.5-4S23 36.8 23 39Zm25 0c0 2.2-1.6 4-3.5 4S41 41.2 41 39s1.6-4 3.5-4S48 36.8 48 39Z"
|
||||
fill="#111"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<path
|
||||
d="M32 33c-6.2 0-11.2 4.6-11.2 10.2 0 5.5 5 9.8 11.2 9.8s11.2-4.3 11.2-9.8C43.2 37.6 38.2 33 32 33Z"
|
||||
fill="#fff"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M32 40.2c-1.9 0-3.4-1.2-3.4-2.7s1.5-2.7 3.4-2.7 3.4 1.2 3.4 2.7-1.5 2.7-3.4 2.7Z"
|
||||
fill="#111"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M27.8 44.8c1.2 1.5 2.8 2.2 4.2 2.2s3-.7 4.2-2.2"
|
||||
fill="none"
|
||||
stroke="#111"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
opacity="0.85"
|
||||
/>
|
||||
</SvgIcon>
|
||||
<Box
|
||||
component="img"
|
||||
src="/logo.webp"
|
||||
alt="Любимый Креатив"
|
||||
sx={{
|
||||
objectFit: 'contain',
|
||||
transform: 'scale(1.25)',
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
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}`
|
||||
}
|
||||
|
||||
export function OptimizedImage({
|
||||
export const OptimizedImage = React.memo(function OptimizedImage({
|
||||
src,
|
||||
alt,
|
||||
widths = DEFAULT_WIDTHS,
|
||||
@@ -67,4 +68,4 @@ export function OptimizedImage({
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent.lazy'
|
||||
|
||||
type Props = {
|
||||
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,
|
||||
editable: !disabled,
|
||||
onUpdate: ({ editor: tiptap }) => {
|
||||
if (tiptap.isDestroyed) return
|
||||
const plainText = tiptap.getText().trim()
|
||||
onChange(plainText ? tiptap.getHTML() : '')
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor || editor.isDestroyed) return
|
||||
editor.setEditable(!disabled)
|
||||
}, [disabled, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!editor || editor.isDestroyed) return
|
||||
const normalizedValue = value.trim() ? value : '<p></p>'
|
||||
if (editor.getHTML() === normalizedValue) return
|
||||
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' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
color={editor?.isActive('bold') ? 'primary' : 'default'}
|
||||
onClick={() => editor?.chain()?.focus().toggleBold().run()}
|
||||
color={editor && !editor.isDestroyed && editor.isActive('bold') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Жирный"
|
||||
>
|
||||
@@ -61,8 +62,8 @@ export function RichTextMessageEditor({
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
color={editor?.isActive('italic') ? 'primary' : 'default'}
|
||||
onClick={() => editor?.chain()?.focus().toggleItalic().run()}
|
||||
color={editor && !editor.isDestroyed && editor.isActive('italic') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Курсив"
|
||||
>
|
||||
@@ -70,8 +71,8 @@ export function RichTextMessageEditor({
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
color={editor?.isActive('bulletList') ? 'primary' : 'default'}
|
||||
onClick={() => editor?.chain()?.focus().toggleBulletList().run()}
|
||||
color={editor && !editor.isDestroyed && editor.isActive('bulletList') ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
aria-label="Список"
|
||||
>
|
||||
|
||||
@@ -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 type { SxProps, Theme } from '@mui/material/styles'
|
||||
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 = {
|
||||
userId: string
|
||||
@@ -12,18 +13,38 @@ type UserAvatarProps = {
|
||||
sx?: SxProps<Theme>
|
||||
}
|
||||
|
||||
export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) {
|
||||
const generatedSrc = useMemo(() => {
|
||||
const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID)
|
||||
const avatar = createAvatar(styleDef.style, { seed: userId })
|
||||
return avatar.toDataUri()
|
||||
}, [userId, avatarStyle])
|
||||
export const UserAvatar = React.memo(function UserAvatar({
|
||||
userId,
|
||||
avatarUrl,
|
||||
avatarStyle,
|
||||
size = 40,
|
||||
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 (
|
||||
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
|
||||
?
|
||||
{!src && '?'}
|
||||
</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={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BearLogo sx={{ fontSize: 28 }} />
|
||||
<BearLogo sx={{ width: 28, height: 28 }} />
|
||||
<Typography variant="h6">{STORE_NAME}</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api'
|
||||
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'
|
||||
|
||||
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-мутации
|
||||
Generated
+362
-282
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"start": "node src/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
|
||||
Binary file not shown.
+57
-35
@@ -141,10 +141,24 @@ await registerUserNotificationRoutes(fastify)
|
||||
await registerOAuthSocialRoutes(fastify)
|
||||
await registerYookassaWebhookRoute(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()
|
||||
|
||||
const {
|
||||
@@ -158,9 +172,40 @@ const {
|
||||
} = NOTIFICATION_EVENTS
|
||||
|
||||
async function dispatchNotification(eventType, payload) {
|
||||
if (eventType === AUTH_CODE_REQUESTED) {
|
||||
const targets = await resolveAuthCodeTargets(eventType, payload)
|
||||
for (const target of targets.filter((t) => t.channel === 'telegram')) {
|
||||
try {
|
||||
if (eventType === AUTH_CODE_REQUESTED) {
|
||||
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({
|
||||
data: {
|
||||
eventType,
|
||||
@@ -171,35 +216,8 @@ async function dispatchNotification(eventType, 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({
|
||||
data: {
|
||||
eventType,
|
||||
channel: target.channel,
|
||||
status: 'pending',
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
})
|
||||
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
|
||||
} catch (err) {
|
||||
console.error(`[notification] Error dispatching ${eventType}:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +239,10 @@ async function shutdown() {
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[process] Unhandled rejection:', reason?.message || reason)
|
||||
})
|
||||
|
||||
try {
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
|
||||
+16
-8
@@ -9,6 +9,9 @@ function createTransporter() {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
connectionTimeout: 5000,
|
||||
greetingTimeout: 5000,
|
||||
socketTimeout: 5000,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
@@ -22,15 +25,20 @@ export async function sendLoginCodeEmail({ to, code }) {
|
||||
return
|
||||
}
|
||||
|
||||
const transporter = createTransporter()
|
||||
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||
try {
|
||||
const transporter = createTransporter()
|
||||
const from = process.env.MAIL_FROM || process.env.SMTP_USER
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Код входа',
|
||||
text: `Ваш код: ${code}\n\nЕсли это были не вы — просто проигнорируйте письмо.`,
|
||||
})
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Код входа',
|
||||
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 }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { avataaars } from '@dicebear/collection'
|
||||
import { initials } from '@dicebear/collection'
|
||||
import { createAvatar } from '@dicebear/core'
|
||||
|
||||
const DEFAULT_STYLE = avataaars
|
||||
const DEFAULT_STYLE = initials
|
||||
|
||||
export async function generateAvatar(seed) {
|
||||
const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
|
||||
|
||||
@@ -49,15 +49,28 @@ export async function getOrCreateResized(uuid, width, format, subdir = '') {
|
||||
|
||||
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
|
||||
|
||||
const sharp = (await import('sharp')).default
|
||||
let pipeline = sharp(originalPath)
|
||||
|
||||
if (width) {
|
||||
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
|
||||
let sharpModule
|
||||
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 options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||
await pipeline[format](options).toFile(cachePath)
|
||||
let pipeline
|
||||
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 }
|
||||
}
|
||||
@@ -75,17 +88,29 @@ export async function generateAllSizes(uuid, subdir, originalPath) {
|
||||
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
|
||||
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||
|
||||
const sharp = (await import('sharp')).default
|
||||
const source = sharp(originalPath)
|
||||
let sharpModule
|
||||
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 format of SUPPORTED_FORMATS) {
|
||||
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||
|
||||
const pipeline = source.clone().resize(width, null, { withoutEnlargement: true })
|
||||
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||
await pipeline[format](options).toFile(cachePath)
|
||||
try {
|
||||
const pipeline = source.clone().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 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({
|
||||
where: { itemKey },
|
||||
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) => {
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
passwordHash,
|
||||
displayName: displayName || null,
|
||||
avatar: avatarUri,
|
||||
avatarStyle: 'avataaars',
|
||||
avatarStyle: 'initials',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
|
||||
email,
|
||||
displayName: norm ? norm.split('@')[0] : 'Пользователь',
|
||||
avatar: await generateAvatar(email),
|
||||
avatarStyle: 'avataaars',
|
||||
avatarStyle: 'initials',
|
||||
},
|
||||
})
|
||||
await prisma.oAuthAccount.create({
|
||||
|
||||
@@ -105,6 +105,15 @@ export async function registerSseRoutes(fastify) {
|
||||
})
|
||||
|
||||
let closed = false
|
||||
let heartbitTimer
|
||||
let removeListeners
|
||||
|
||||
function cleanUp() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(heartbitTimer)
|
||||
removeListeners()
|
||||
}
|
||||
|
||||
function safeWrite(chunk) {
|
||||
if (closed) return
|
||||
@@ -121,18 +130,11 @@ export async function registerSseRoutes(fastify) {
|
||||
|
||||
safeWrite(formatHeartbit())
|
||||
|
||||
const heartbitTimer = setInterval(() => {
|
||||
heartbitTimer = setInterval(() => {
|
||||
safeWrite(formatHeartbit())
|
||||
}, 30_000)
|
||||
|
||||
const removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite)
|
||||
|
||||
function cleanUp() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
clearInterval(heartbitTimer)
|
||||
removeListeners()
|
||||
}
|
||||
removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite)
|
||||
|
||||
request.raw.on('close', cleanUp)
|
||||
request.raw.on('error', cleanUp)
|
||||
|
||||
@@ -11,62 +11,71 @@ const CACHE_CONTROL_SHORT = 'public, max-age=86400'
|
||||
*/
|
||||
export function registerUploadsResized(fastify) {
|
||||
fastify.get('/uploads-resized/*', async (request, reply) => {
|
||||
const rawPath = request.params['*']
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
const widthParam = url.searchParams.get('w')
|
||||
|
||||
// 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(', ')}` })
|
||||
try {
|
||||
const rawPath = request.params['*']
|
||||
if (typeof rawPath !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid request: missing file path' })
|
||||
}
|
||||
width = w
|
||||
}
|
||||
|
||||
// If no width requested, serve original with short cache
|
||||
if (!width) {
|
||||
const originalPath = await findOriginalFile(uuid, subdir || undefined)
|
||||
if (!originalPath) {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
const widthParam = url.searchParams.get('w')
|
||||
|
||||
// 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' })
|
||||
}
|
||||
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
|
||||
|
||||
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user