Merge branch 'final'
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://любимыйкреатив.рф/sitemap.xml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/</loc>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/info</loc>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/about</loc>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/privacy</loc>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/terms</loc>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -11,12 +11,15 @@ import { NotFoundPage } from '@/pages/not-found'
|
|||||||
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
||||||
import { ProductPage } from '@/pages/product'
|
import { ProductPage } from '@/pages/product'
|
||||||
import { TermsPage } from '@/pages/terms'
|
import { TermsPage } from '@/pages/terms'
|
||||||
|
import { usePageTitleReset } from '@/shared/lib/use-page-title'
|
||||||
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
||||||
|
|
||||||
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
|
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
|
||||||
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
|
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
|
usePageTitleReset()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { useMediaQuery } from '@mui/material'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import CardContent from '@mui/material/CardContent'
|
|
||||||
import CardMedia from '@mui/material/CardMedia'
|
import CardMedia from '@mui/material/CardMedia'
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Autoplay } from 'swiper/modules'
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
import 'swiper/css'
|
import 'swiper/css'
|
||||||
import type { Product } from '@/entities/product/model/types'
|
import type { Product } from '@/entities/product/model/types'
|
||||||
@@ -19,6 +20,7 @@ type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
|||||||
|
|
||||||
export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const isMobile = useMediaQuery('(max-width:600px)')
|
||||||
const swiperRef = useRef<SwiperType | null>(null)
|
const swiperRef = useRef<SwiperType | null>(null)
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
const fromImages = (product.images ?? [])
|
const fromImages = (product.images ?? [])
|
||||||
@@ -76,12 +78,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
|||||||
>
|
>
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
{imageUrls.length ? (
|
{imageUrls.length ? (
|
||||||
<Box onMouseMove={onMouseMove} sx={{ height: mediaHeight, overflow: 'hidden' }}>
|
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
|
||||||
<Swiper
|
<Swiper
|
||||||
onSwiper={(s) => {
|
onSwiper={(s) => {
|
||||||
swiperRef.current = s
|
swiperRef.current = s
|
||||||
}}
|
}}
|
||||||
allowTouchMove={false}
|
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 }}
|
||||||
>
|
>
|
||||||
{imageUrls.map((url) => (
|
{imageUrls.map((url) => (
|
||||||
@@ -150,8 +154,8 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<CardContent sx={{ flexGrow: 1, p: 2, '&:last-child': { pb: 2 } }}>
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
|
||||||
<Stack spacing={1.25}>
|
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
|
||||||
{product.category && (
|
{product.category && (
|
||||||
<Chip
|
<Chip
|
||||||
label={product.category.name}
|
label={product.category.name}
|
||||||
@@ -231,15 +235,15 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
|||||||
>
|
>
|
||||||
{product.shortDescription ?? 'Описание появится позже.'}
|
{product.shortDescription ?? 'Описание появится позже.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 'auto', pt: 0.5 }}>
|
|
||||||
<Typography variant="h6" color="primary" sx={{ fontWeight: 700, fontSize: '1.1rem' }}>
|
|
||||||
{formatPriceRub(product.priceCents)}
|
|
||||||
</Typography>
|
|
||||||
{actions}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ fontWeight: 700, fontSize: '1.1rem' }}>
|
||||||
|
{formatPriceRub(product.priceCents)}
|
||||||
|
</Typography>
|
||||||
|
{actions}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as maplibregl from 'maplibre-gl'
|
|||||||
import Map, { Marker } from 'react-map-gl/maplibre'
|
import Map, { Marker } from 'react-map-gl/maplibre'
|
||||||
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
|
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
|
||||||
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
|
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
|
|
||||||
const rasterStyle = {
|
const rasterStyle = {
|
||||||
version: 8 as const,
|
version: 8 as const,
|
||||||
@@ -22,6 +23,7 @@ const rasterStyle = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
|
usePageTitle('О нас')
|
||||||
const { lat, lng } = PICKUP_COORDINATES
|
const { lat, lng } = PICKUP_COORDINATES
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { Minus, Plus, Trash2 } from 'lucide-react'
|
|||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
|
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
|
|
||||||
export function CartPage() {
|
export function CartPage() {
|
||||||
|
usePageTitle('Корзина')
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Container from '@mui/material/Container'
|
|||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { DeliverySection } from './sections/DeliverySection'
|
import { DeliverySection } from './sections/DeliverySection'
|
||||||
import { HowToOrderSection } from './sections/HowToOrderSection'
|
import { HowToOrderSection } from './sections/HowToOrderSection'
|
||||||
import { OrderStatusesSection } from './sections/OrderStatusesSection'
|
import { OrderStatusesSection } from './sections/OrderStatusesSection'
|
||||||
@@ -10,6 +11,7 @@ import { PaymentSection } from './sections/PaymentSection'
|
|||||||
import { ReturnsSection } from './sections/ReturnsSection'
|
import { ReturnsSection } from './sections/ReturnsSection'
|
||||||
|
|
||||||
export function InfoPage() {
|
export function InfoPage() {
|
||||||
|
usePageTitle('Информация для покупателей')
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: { xs: 4 } }}>
|
<Container maxWidth="lg" sx={{ py: { xs: 4 } }}>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const methods = [
|
|||||||
{
|
{
|
||||||
icon: <CreditCard size={18} />,
|
icon: <CreditCard size={18} />,
|
||||||
primary: 'Онлайн-оплата через ЮKassa',
|
primary: 'Онлайн-оплата через ЮKassa',
|
||||||
secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.',
|
secondary:
|
||||||
|
'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Banknote size={18} />,
|
icon: <Banknote size={18} />,
|
||||||
@@ -39,7 +40,8 @@ export function PaymentSection() {
|
|||||||
maxWidth: '56ch',
|
maxWidth: '56ch',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате.
|
Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и
|
||||||
|
готов к оплате.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function ReturnsSection() {
|
|||||||
lineHeight: 1.65,
|
lineHeight: 1.65,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
|
Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней
|
||||||
|
после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества
|
||||||
|
возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -58,7 +60,9 @@ export function ReturnsSection() {
|
|||||||
lineHeight: 1.65,
|
lineHeight: 1.65,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы решим проблему в кратчайшие сроки.
|
Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя,
|
||||||
|
устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы
|
||||||
|
решим проблему в кратчайшие сроки.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/entities/user/api/address-api'
|
} from '@/entities/user/api/address-api'
|
||||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||||
import { AddressFormDialog, type AddressFormValues } from '@/features/address-form'
|
import { AddressFormDialog, type AddressFormValues } from '@/features/address-form'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
|
|
||||||
const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({
|
const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({
|
||||||
label: '',
|
label: '',
|
||||||
@@ -29,6 +30,7 @@ const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function AddressesPage() {
|
export function AddressesPage() {
|
||||||
|
usePageTitle('Адреса доставки')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<ShippingAddress | null>(null)
|
const [editing, setEditing] = useState<ShippingAddress | null>(null)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
|||||||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||||
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
|
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||||
@@ -24,6 +25,7 @@ import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
|||||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
|
||||||
export function MessagesPage() {
|
export function MessagesPage() {
|
||||||
|
usePageTitle('Сообщения')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/entities/notification/api/notifications-api'
|
} from '@/entities/notification/api/notifications-api'
|
||||||
import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api'
|
import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api'
|
||||||
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
|
import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
|
|
||||||
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
|
function isOrderStatusChangesOn(s: UserNotificationSettings): boolean {
|
||||||
@@ -27,6 +28,7 @@ const orderStatusChangesPayload = (on: boolean) => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
|
usePageTitle('Уведомления')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { ORDER_STATUSES } from '@/shared/constants/order'
|
|||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
|
|
||||||
export function OrdersPage() {
|
export function OrdersPage() {
|
||||||
|
usePageTitle('Заказы')
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
queryKey: ['me', 'orders'],
|
queryKey: ['me', 'orders'],
|
||||||
queryFn: fetchMyOrders,
|
queryFn: fetchMyOrders,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Divider from '@mui/material/Divider'
|
|||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { AuthMethodsSection } from './AuthMethodsSection'
|
import { AuthMethodsSection } from './AuthMethodsSection'
|
||||||
import { AvatarSection } from './AvatarSection'
|
import { AvatarSection } from './AvatarSection'
|
||||||
@@ -11,6 +12,7 @@ import { DeleteAccountSection } from './DeleteAccountSection'
|
|||||||
import { ProfileSection } from './ProfileSection'
|
import { ProfileSection } from './ProfileSection'
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
|
usePageTitle('Настройки')
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
STORE_OP_ADDR,
|
STORE_OP_ADDR,
|
||||||
STORE_PUBLIC_SITE_URL,
|
STORE_PUBLIC_SITE_URL,
|
||||||
} from '@/shared/config'
|
} 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 = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ const sections = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function PrivacyPolicyPage() {
|
export function PrivacyPolicyPage() {
|
||||||
|
usePageTitle('Политика конфиденциальности')
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 800, mx: 'auto', py: { xs: 3, md: 5 }, px: { xs: 2, md: 0 } }}>
|
<Box sx={{ maxWidth: 800, mx: 'auto', py: { xs: 3, md: 5 }, px: { xs: 2, md: 0 } }}>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ProductReviewsList } from '@/features/product-review'
|
|||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url'
|
import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url'
|
||||||
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
|
||||||
|
import { usePageTitle } from '@/shared/lib/use-page-title'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ export function ProductPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePageTitle(productQuery.data?.title ?? null)
|
||||||
|
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
const p = productQuery.data
|
const p = productQuery.data
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
STORE_OP_INN,
|
STORE_OP_INN,
|
||||||
STORE_OP_ADDR,
|
STORE_OP_ADDR,
|
||||||
} from '@/shared/config'
|
} 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 = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ const sections = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function TermsPage() {
|
export function TermsPage() {
|
||||||
|
usePageTitle('Пользовательское соглашение')
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 800, mx: 'auto', py: { xs: 3, md: 5 }, px: { xs: 2, md: 0 } }}>
|
<Box sx={{ maxWidth: 800, mx: 'auto', py: { xs: 3, md: 5 }, px: { xs: 2, md: 0 } }}>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export type StatusColor = 'warning' | 'success' | 'info' | 'error'
|
export type StatusColor = 'warning' | 'success' | 'info' | 'error'
|
||||||
export type StatusIconName = 'banknote' | 'check-circle' | 'package-search' | 'package' | 'package-check' | 'store' | 'x-circle'
|
export type StatusIconName =
|
||||||
|
| 'banknote'
|
||||||
|
| 'check-circle'
|
||||||
|
| 'package-search'
|
||||||
|
| 'package'
|
||||||
|
| 'package-check'
|
||||||
|
| 'store'
|
||||||
|
| 'x-circle'
|
||||||
|
|
||||||
export interface OrderStatusData {
|
export interface OrderStatusData {
|
||||||
code: string
|
code: string
|
||||||
@@ -37,7 +44,8 @@ export const ORDER_STATUS_DATA: ReadonlyArray<OrderStatusData> = [
|
|||||||
label: 'Отправлен',
|
label: 'Отправлен',
|
||||||
iconName: 'package',
|
iconName: 'package',
|
||||||
color: 'info',
|
color: 'info',
|
||||||
description: 'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.',
|
description:
|
||||||
|
'Заказ передан в службу доставки. Трек-номер для отслеживания(при наличии) будет указан в сообщении админа.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'READY_FOR_PICKUP',
|
code: 'READY_FOR_PICKUP',
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
const BASE_TITLE = 'Любимый Креатив — Изделия ручной работы'
|
||||||
|
|
||||||
|
let currentTitle: string = BASE_TITLE
|
||||||
|
|
||||||
|
export function usePageTitle(title: string | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
currentTitle = title ? `${title} — Любимый Креатив` : BASE_TITLE
|
||||||
|
document.title = currentTitle
|
||||||
|
}, [title])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageTitleReset() {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = BASE_TITLE
|
||||||
|
currentTitle = BASE_TITLE
|
||||||
|
}, [location.pathname])
|
||||||
|
}
|
||||||
@@ -6,20 +6,47 @@ import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-stat
|
|||||||
|
|
||||||
const iconMap: Record<StatusIconName, ReactNode> = {
|
const iconMap: Record<StatusIconName, ReactNode> = {
|
||||||
banknote: (
|
banknote: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<rect x="2" y="6" width="20" height="12" rx="2" />
|
<rect x="2" y="6" width="20" height="12" rx="2" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M6 12h.01M18 12h.01" />
|
<path d="M6 12h.01M18 12h.01" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
'check-circle': (
|
'check-circle': (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
'package-search': (
|
'package-search': (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M16 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
|
<path d="M16 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z" />
|
||||||
<path d="M19 19l-3-3" />
|
<path d="M19 19l-3-3" />
|
||||||
<path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" />
|
<path d="M21 10V7a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 7v3" />
|
||||||
@@ -27,14 +54,32 @@ const iconMap: Record<StatusIconName, ReactNode> = {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
package: (
|
package: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
'package-check': (
|
'package-check': (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
@@ -42,13 +87,31 @@ const iconMap: Record<StatusIconName, ReactNode> = {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
store: (
|
store: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
<polyline points="9 22 9 12 15 12 15 22" />
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
'x-circle': (
|
'x-circle': (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="M15 9l-6 6M9 9l6 6" />
|
<path d="M15 9l-6 6M9 9l6 6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -169,3 +169,28 @@ curl http://127.0.0.1:3333/health
|
|||||||
```bash
|
```bash
|
||||||
curl https://craftshop.твой-домен/api/health
|
curl https://craftshop.твой-домен/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 9. Бэкапы БД (systemd timer)
|
||||||
|
|
||||||
|
Установить таймер для автоматического бэкапа каждые 6 часов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установить sqlite3 для безопасного копирования
|
||||||
|
apt-get install -y sqlite3
|
||||||
|
|
||||||
|
# Скопировать unit-файлы
|
||||||
|
cp /opt/craftshop/scripts/craftshop-backup.service /etc/systemd/system/
|
||||||
|
cp /opt/craftshop/scripts/craftshop-backup.timer /etc/systemd/system/
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now craftshop-backup.timer
|
||||||
|
|
||||||
|
# Проверить статус
|
||||||
|
systemctl list-timers craftshop-backup.timer
|
||||||
|
|
||||||
|
# Ручной запуск для проверки
|
||||||
|
systemctl start craftshop-backup.service
|
||||||
|
ls /opt/craftshop/server/backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
Бэкапы хранятся 30 дней (настраивается в `scripts/backup-db.sh`).
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup SQLite database — копирует .db файл с timestamp в директорию бэкапов.
|
||||||
|
# Вызывается из systemd timer или cron.
|
||||||
|
#
|
||||||
|
# Использование: ./scripts/backup-db.sh [path-to-db] [backup-dir] [retention-days]
|
||||||
|
# По умолчанию: db=server/prisma/prod.db, backup=server/backups, retention=30
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
DB_PATH="${1:-$ROOT/server/prisma/prod.db}"
|
||||||
|
BACKUP_DIR="${2:-$ROOT/server/backups}"
|
||||||
|
RETENTION_DAYS="${3:-30}"
|
||||||
|
|
||||||
|
if [[ ! -f "$DB_PATH" ]]; then
|
||||||
|
echo "[$(date -Iseconds)] DB not found: $DB_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/craftshop_${TIMESTAMP}.db"
|
||||||
|
|
||||||
|
# SQLite-safe copy: use .backup to avoid copying a file mid-write
|
||||||
|
if command -v sqlite3 &>/dev/null; then
|
||||||
|
sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'"
|
||||||
|
else
|
||||||
|
# Fallback: plain copy (risk of inconsistent state if DB is being written)
|
||||||
|
cp "$DB_PATH" "$BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
gzip -f "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Remove old backups
|
||||||
|
find "$BACKUP_DIR" -name 'craftshop_*.db.gz' -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
|
||||||
|
echo "[$(date -Iseconds)] Backup created: ${BACKUP_FILE}.gz"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Craftshop SQLite Database Backup
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/opt/craftshop/scripts/backup-db.sh /opt/craftshop/server/prisma/prod.db /opt/craftshop/server/backups 30
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Craftshop Database Backup Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00/6:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Binary file not shown.
+40
-1
@@ -19,13 +19,14 @@ import { prisma } from './lib/prisma.js'
|
|||||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||||
import { registerAuth } from './plugins/auth.js'
|
import { registerAuth } from './plugins/auth.js'
|
||||||
import { registerIpGate } from './plugins/ip-gate.js'
|
import { registerIpGate } from './plugins/ip-gate.js'
|
||||||
|
import { registerSecurityHeaders } from './plugins/security-headers.js'
|
||||||
import { registerApiRoutes } from './routes/api.js'
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||||
|
import { registerSseRoutes } from './routes/sse.js'
|
||||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||||
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||||
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
import { registerUserAddressRoutes } from './routes/user-addresses.js'
|
||||||
import { registerUserCartRoutes } from './routes/user-cart.js'
|
import { registerUserCartRoutes } from './routes/user-cart.js'
|
||||||
import { registerSseRoutes } from './routes/sse.js'
|
|
||||||
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
import { registerUserMessageRoutes } from './routes/user-messages.js'
|
||||||
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
import { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||||
@@ -48,6 +49,44 @@ await fastify.register(cors, {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await registerSecurityHeaders(fastify)
|
||||||
|
|
||||||
|
fastify.get('/health', async () => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
return { status: 'ok', database: 'connected', uptime: process.uptime() }
|
||||||
|
} catch {
|
||||||
|
return { status: 'degraded', database: 'disconnected', uptime: process.uptime() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.setErrorHandler(function errorHandler(error, request, reply) {
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
if (error.validation) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Ошибка валидации',
|
||||||
|
details: isProd ? undefined : error.validation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'FST_ERR_VALIDATION') {
|
||||||
|
return reply.code(400).send({ error: 'Неверный формат запроса' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
return reply.code(error.statusCode).send({
|
||||||
|
error: error.message || 'Произошла ошибка',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(error)
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: isProd ? 'Внутренняя ошибка сервера' : error.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
await fastify.register(jwt, {
|
await fastify.register(jwt, {
|
||||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAvatar } from '@dicebear/core'
|
|
||||||
import { avataaars } from '@dicebear/collection'
|
import { avataaars } from '@dicebear/collection'
|
||||||
|
import { createAvatar } from '@dicebear/core'
|
||||||
|
|
||||||
const DEFAULT_STYLE = avataaars
|
const DEFAULT_STYLE = avataaars
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,51 @@
|
|||||||
const windows = new Map()
|
const windows = new Map()
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 5
|
const DEFAULT_MAX_ATTEMPTS = 5
|
||||||
const WINDOW_MS = 60_000
|
const DEFAULT_WINDOW_MS = 60_000
|
||||||
|
|
||||||
|
// Per-endpoint rate limits
|
||||||
|
const LIMITS = {
|
||||||
|
login: { maxAttempts: 5, windowMs: 60_000 },
|
||||||
|
codeRequest: { maxAttempts: 3, windowMs: 60_000 },
|
||||||
|
codeVerify: { maxAttempts: 5, windowMs: 60_000 },
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [ip, entry] of windows) {
|
for (const [ip, entry] of windows) {
|
||||||
if (now - entry.start > WINDOW_MS) windows.delete(ip)
|
if (now - entry.start > DEFAULT_WINDOW_MS) windows.delete(ip)
|
||||||
}
|
}
|
||||||
}, 5 * 60_000).unref()
|
}, 5 * 60_000).unref()
|
||||||
|
|
||||||
export function checkLoginRateLimit(ip) {
|
function getKey(ip, scope) {
|
||||||
|
return `${scope}:${ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(ip, scope) {
|
||||||
|
const limit = LIMITS[scope] || { maxAttempts: DEFAULT_MAX_ATTEMPTS, windowMs: DEFAULT_WINDOW_MS }
|
||||||
|
const key = getKey(ip, scope)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const entry = windows.get(ip)
|
const entry = windows.get(key)
|
||||||
if (!entry || now - entry.start > WINDOW_MS) {
|
if (!entry || now - entry.start > limit.windowMs) {
|
||||||
windows.set(ip, { start: now, count: 1 })
|
windows.set(key, { start: now, count: 1 })
|
||||||
return { allowed: true }
|
return { allowed: true }
|
||||||
}
|
}
|
||||||
entry.count += 1
|
entry.count += 1
|
||||||
if (entry.count > MAX_ATTEMPTS) {
|
if (entry.count > limit.maxAttempts) {
|
||||||
const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000)
|
const retryAfter = Math.ceil((entry.start + limit.windowMs - now) / 1000)
|
||||||
return { allowed: false, retryAfter }
|
return { allowed: false, retryAfter }
|
||||||
}
|
}
|
||||||
return { allowed: true }
|
return { allowed: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkLoginRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'login')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCodeRequestRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'codeRequest')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCodeVerifyRateLimit(ip) {
|
||||||
|
return checkRateLimit(ip, 'codeVerify')
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export async function registerSecurityHeaders(fastify) {
|
||||||
|
fastify.addHook('onSend', async (request, reply) => {
|
||||||
|
reply.header('X-Content-Type-Options', 'nosniff')
|
||||||
|
reply.header('X-Frame-Options', 'DENY')
|
||||||
|
reply.header('X-XSS-Protection', '0')
|
||||||
|
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||||
|
|
||||||
|
const cspDirectives = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
|
||||||
|
'frame-src https://*.yookassa.ru',
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; ')
|
||||||
|
|
||||||
|
reply.header('Content-Security-Policy', cspDirectives)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
describe('OAuth — User model fields', () => {
|
describe('OAuth — User model fields', () => {
|
||||||
|
const createdIds = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of createdIds) {
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id } })
|
||||||
|
} catch {
|
||||||
|
// Already deleted by another test or cleanup — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createdIds.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
it('stores displayName and avatar fields on User model', async () => {
|
it('stores displayName and avatar fields on User model', async () => {
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -11,10 +24,10 @@ describe('OAuth — User model fields', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createdIds.push(user.id)
|
||||||
|
|
||||||
expect(user.displayName).toBe('Test User')
|
expect(user.displayName).toBe('Test User')
|
||||||
expect(user.avatar).toBe('https://example.com/avatar.jpg')
|
expect(user.avatar).toBe('https://example.com/avatar.jpg')
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id: user.id } })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('allows nullable fields', async () => {
|
it('allows nullable fields', async () => {
|
||||||
@@ -24,9 +37,9 @@ describe('OAuth — User model fields', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createdIds.push(user.id)
|
||||||
|
|
||||||
expect(user.displayName).toBeNull()
|
expect(user.displayName).toBeNull()
|
||||||
expect(user.avatar).toBeNull()
|
expect(user.avatar).toBeNull()
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id: user.id } })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Fastify from 'fastify'
|
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
|
import Fastify from 'fastify'
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
|
import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
|
||||||
|
|
||||||
@@ -84,7 +84,12 @@ describe('buildSseListeners', () => {
|
|||||||
|
|
||||||
it('forwards order:statusChanged to matching userId', () => {
|
it('forwards order:statusChanged to matching userId', () => {
|
||||||
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
const cleanup = buildSseListeners('user-1', false, eventBus, write)
|
||||||
eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
|
eventBus.emit('order:statusChanged', {
|
||||||
|
orderId: 'o1',
|
||||||
|
userId: 'user-1',
|
||||||
|
oldStatus: 'PENDING_PAYMENT',
|
||||||
|
newStatus: 'PAID',
|
||||||
|
})
|
||||||
expect(write).toHaveBeenCalledTimes(1)
|
expect(write).toHaveBeenCalledTimes(1)
|
||||||
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
|
||||||
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
|
expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../lib/auth.js'
|
} from '../lib/auth.js'
|
||||||
import { generateAvatar } from '../lib/generate-avatar.js'
|
import { generateAvatar } from '../lib/generate-avatar.js'
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
import { checkLoginRateLimit } from '../lib/rate-limit.js'
|
import { checkCodeRequestRateLimit, checkCodeVerifyRateLimit, checkLoginRateLimit } from '../lib/rate-limit.js'
|
||||||
|
|
||||||
export function mapUserForClient(user) {
|
export function mapUserForClient(user) {
|
||||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||||
@@ -30,6 +30,15 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
const email = normalizeEmail(request.body?.email)
|
const email = normalizeEmail(request.body?.email)
|
||||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
|
||||||
|
const ip = request.ip
|
||||||
|
const rate = checkCodeRequestRateLimit(ip)
|
||||||
|
if (!rate.allowed) {
|
||||||
|
return reply
|
||||||
|
.code(429)
|
||||||
|
.header('Retry-After', String(rate.retryAfter))
|
||||||
|
.send({ error: `Слишком много запросов. Попробуйте через ${rate.retryAfter} сек.` })
|
||||||
|
}
|
||||||
|
|
||||||
const code = await issueEmailCode({ email, purpose: 'login' })
|
const code = await issueEmailCode({ email, purpose: 'login' })
|
||||||
|
|
||||||
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||||
@@ -50,6 +59,15 @@ export async function registerAuthRoutes(fastify) {
|
|||||||
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' })
|
||||||
|
|
||||||
|
const ip = request.ip
|
||||||
|
const rate = checkCodeVerifyRateLimit(ip)
|
||||||
|
if (!rate.allowed) {
|
||||||
|
return reply
|
||||||
|
.code(429)
|
||||||
|
.header('Retry-After', String(rate.retryAfter))
|
||||||
|
.send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` })
|
||||||
|
}
|
||||||
|
|
||||||
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
const ok = await verifyEmailCode({ email, purpose: 'login', code })
|
||||||
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user