base commit

This commit is contained in:
@kirill.komarov
2026-04-28 22:15:12 +05:00
parent d40edf97e7
commit 3f7fdb1e15
19 changed files with 929 additions and 47 deletions
+21 -1
View File
@@ -19,7 +19,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"swiper": "^12.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -6152,6 +6153,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz",
"integrity": "sha512-XcWlVmkHFICI4fuoJKgbp8PscDcS4i7pBH8nwJRBi3dpQvhCySwsWRYm4bOf/BzKVWkHOYaFw7qz9uBSrY3oug==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
+2 -1
View File
@@ -24,7 +24,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"swiper": "^12.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+2
View File
@@ -5,6 +5,7 @@ import { AdminPage } from '@/pages/admin'
import { AuthPage } from '@/pages/auth'
import { HomePage } from '@/pages/home'
import { MePage } from '@/pages/me/ui/MePage'
import { ProductPage } from '@/pages/product'
export function App() {
return (
@@ -16,6 +17,7 @@ export function App() {
<Route path="/admin" element={<AdminPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MainLayout>
@@ -8,6 +8,11 @@ export async function fetchPublicProducts(categorySlug?: string): Promise<Produc
return data
}
export async function fetchPublicProduct(id: string): Promise<Product> {
const { data } = await apiClient.get<Product>(`products/${id}`)
return data
}
export async function fetchCategories(): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>('categories')
return data
@@ -25,10 +30,14 @@ export async function createProduct(
body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
},
): Promise<Product> {
@@ -44,10 +53,14 @@ export async function updateProduct(
body: Partial<{
title: string
slug: string
shortDescription: string | null
description: string | null
priceCents: number
imageUrl: string | null
imageUrls: string[]
published: boolean
inStock: boolean
leadTimeDays: number | null
categoryId: string
}>,
): Promise<Product> {
@@ -9,12 +9,17 @@ export type Product = {
id: string
title: string
slug: string
shortDescription: string | null
description: string | null
priceCents: number
imageUrl: string | null
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
published: boolean
inStock: boolean
leadTimeDays: number | null
categoryId: string
createdAt: string
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
}
+60 -7
View File
@@ -1,15 +1,38 @@
import { useMemo, useRef } from 'react'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Swiper, SwiperSlide } from 'swiper/react'
import type { Swiper as SwiperType } from 'swiper/types'
import 'swiper/css'
import { Link as RouterLink } from 'react-router-dom'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
type Props = { product: Product }
export function ProductCard({ product }: Props) {
const swiperRef = useRef<SwiperType | null>(null)
const imageUrls = useMemo(() => {
const fromImages = (product.images ?? []).slice().sort((a, b) => a.sort - b.sort).map((x) => x.url)
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
return urls
}, [product.images, product.imageUrl])
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
if (!swiperRef.current) return
if (imageUrls.length <= 1) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
swiperRef.current.slideTo(idx, 0)
}
return (
<Card
variant="outlined"
@@ -32,19 +55,43 @@ export function ProductCard({ product }: Props) {
},
}}
>
{product.imageUrl ? (
<CardMedia
<Link
component={RouterLink}
to={`/products/${product.id}`}
underline="none"
color="inherit"
sx={{ display: 'block' }}
>
{imageUrls.length ? (
<Box onMouseMove={onMouseMove} sx={{ height: 200 }}>
<Swiper
onSwiper={(s) => {
swiperRef.current = s
}}
allowTouchMove={false}
style={{ width: '100%', height: 200 }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
<Box
component="img"
height="200"
image={product.imageUrl}
src={url}
alt={product.title}
className="product-card__media"
sx={{
width: '100%',
height: 200,
objectFit: 'cover',
display: 'block',
transition: 'transform 240ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
userSelect: 'none',
}}
className="product-card__media"
/>
</SwiperSlide>
))}
</Swiper>
</Box>
) : (
<CardMedia
component="div"
@@ -59,10 +106,16 @@ export function ProductCard({ product }: Props) {
<Typography color="text.secondary">Нет фото</Typography>
</CardMedia>
)}
</Link>
<CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />}
<Typography variant="h6" component="h2">
<Typography
variant="h6"
component={RouterLink}
to={`/products/${product.id}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{product.title}
</Typography>
<Typography
@@ -76,7 +129,7 @@ export function ProductCard({ product }: Props) {
WebkitBoxOrient: 'vertical',
}}
>
{product.description ?? 'Описание появится позже.'}
{product.shortDescription ?? 'Описание появится позже.'}
</Typography>
<Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)}
+166 -9
View File
@@ -31,26 +31,33 @@ import {
updateProduct,
} from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
type FormState = {
title: string
slug: string
shortDescription: string
description: string
priceRub: string
imageUrl: string
imageUrls: string[]
published: boolean
inStock: boolean
leadTimeDays: string
categoryId: string
}
const emptyForm = (): FormState => ({
title: '',
slug: '',
shortDescription: '',
description: '',
priceRub: '',
imageUrl: '',
imageUrls: [],
published: true,
inStock: true,
leadTimeDays: '',
categoryId: '',
})
@@ -78,6 +85,7 @@ export function AdminPage() {
const titleValue = productForm.watch('title')
const categoryIdValue = productForm.watch('categoryId')
const inStockValue = productForm.watch('inStock')
useEffect(() => {
tokenForm.reset({ token: '' })
@@ -113,13 +121,21 @@ export function AdminPage() {
const openEdit = (p: Product) => {
setEditing(p)
const urls =
(p.images ?? [])
.slice()
.sort((a, b) => a.sort - b.sort)
.map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
productForm.reset({
title: p.title,
slug: p.slug,
shortDescription: p.shortDescription ?? '',
description: p.description ?? '',
priceRub: String(p.priceCents / 100),
imageUrl: p.imageUrl ?? '',
imageUrls: urls,
published: p.published,
inStock: p.inStock,
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
categoryId: p.categoryId,
})
setDialogOpen(true)
@@ -130,13 +146,22 @@ export function AdminPage() {
const form = productForm.getValues()
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
if (!form.inStock) {
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
}
}
await createProduct(token!, {
title: form.title.trim(),
slug: form.slug.trim() || undefined,
shortDescription: form.shortDescription.trim() || null,
description: form.description.trim() || null,
priceCents,
imageUrl: form.imageUrl.trim() || null,
imageUrls: form.imageUrls,
published: form.published,
inStock: form.inStock,
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
categoryId: form.categoryId,
})
},
@@ -152,13 +177,22 @@ export function AdminPage() {
const form = productForm.getValues()
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
if (!form.inStock) {
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
}
}
await updateProduct(token!, editing!.id, {
title: form.title.trim(),
slug: form.slug.trim(),
shortDescription: form.shortDescription.trim() || null,
description: form.description.trim() || null,
priceCents,
imageUrl: form.imageUrl.trim() || null,
imageUrls: form.imageUrls,
published: form.published,
inStock: form.inStock,
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
categoryId: form.categoryId,
})
},
@@ -199,6 +233,33 @@ export function AdminPage() {
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error
const uploadImagesMut = useMutation({
mutationFn: async (files: FileList) => {
const fd = new FormData()
Array.from(files).forEach((f) => fd.append('files', f))
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'multipart/form-data',
},
})
return data.urls
},
onSuccess: (urls) => {
const current = productForm.getValues('imageUrls')
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
},
})
const removeImage = (url: string) => {
const current = productForm.getValues('imageUrls')
productForm.setValue(
'imageUrls',
current.filter((u) => u !== url),
{ shouldDirty: true },
)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
@@ -311,6 +372,13 @@ export function AdminPage() {
/>
)}
/>
<Controller
control={productForm.control}
name="shortDescription"
render={({ field }) => (
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
)}
/>
<Controller
control={productForm.control}
name="description"
@@ -321,11 +389,83 @@ export function AdminPage() {
name="priceRub"
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
/>
<Controller
control={productForm.control}
name="imageUrl"
render={({ field }) => <TextField label="Ссылка на изображение" fullWidth {...field} />}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Фото (загрузка)
</Typography>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending || !token}>
Выбрать файлы
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(e) => {
const files = e.target.files
if (!files || files.length === 0) return
uploadImagesMut.mutate(files)
e.currentTarget.value = ''
}}
/>
</Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && (
<Typography color="error">Не удалось загрузить фото (проверьте токен и сервер)</Typography>
)}
</Box>
{productForm.watch('imageUrls').length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{productForm.watch('imageUrls').map((url) => (
<Box
key={url}
sx={{
width: 92,
height: 92,
borderRadius: 1,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
}}
title={url}
>
<Box
component="img"
src={url}
alt="Фото товара"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<Button
size="small"
color="error"
variant="contained"
onClick={() => removeImage(url)}
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 0,
px: 0.75,
py: 0,
lineHeight: 1.2,
}}
>
×
</Button>
</Box>
))}
</Box>
)}
</Box>
<Controller
control={productForm.control}
name="categoryId"
@@ -352,6 +492,23 @@ export function AdminPage() {
/>
)}
/>
<Controller
control={productForm.control}
name="inStock"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label={field.value ? 'В наличии' : 'Под заказ'}
/>
)}
/>
{!inStockValue && (
<Controller
control={productForm.control}
name="leadTimeDays"
render={({ field }) => <TextField label="Срок исполнения, дней" fullWidth {...field} />}
/>
)}
</Stack>
</DialogContent>
<DialogActions>
+2
View File
@@ -0,0 +1,2 @@
export { ProductPage } from './ui/ProductPage'
+162
View File
@@ -0,0 +1,162 @@
import { useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog'
import IconButton from '@mui/material/IconButton'
import Skeleton from '@mui/material/Skeleton'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { formatPriceRub } from '@/shared/lib/format-price'
export function ProductPage() {
const { id } = useParams()
const [viewerOpen, setViewerOpen] = useState(false)
const [viewerIndex, setViewerIndex] = useState(0)
const productQuery = useQuery({
queryKey: ['products', 'public', 'byId', id],
queryFn: () => fetchPublicProduct(id!),
enabled: Boolean(id),
})
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
const fromImages = (p.images ?? []).slice().sort((a, b) => a.sort - b.sort).map((x) => x.url)
const urls = fromImages.length ? fromImages : p.imageUrl ? [p.imageUrl] : []
return urls
}, [productQuery.data])
if (!id) return <Alert severity="error">Некорректная ссылка на товар.</Alert>
if (productQuery.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Skeleton variant="rectangular" height={420} />
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="40%" />
<Skeleton variant="text" />
</Box>
)
}
if (productQuery.isError) return <Alert severity="error">Не удалось загрузить товар.</Alert>
const p = productQuery.data
if (!p) return <Alert severity="error">Товар не найден.</Alert>
return (
<Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{imageUrls.length > 0 ? (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: 420 }}>
{imageUrls.map((url, idx) => (
<SwiperSlide key={url}>
<Box
component="img"
src={url}
alt={p.title}
onClick={() => {
setViewerIndex(idx)
setViewerOpen(true)
}}
sx={{
width: '100%',
height: 420,
objectFit: 'cover',
display: 'block',
cursor: 'zoom-in',
userSelect: 'none',
}}
/>
</SwiperSlide>
))}
</Swiper>
</Box>
) : (
<Box
sx={{
height: 420,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
bgcolor: 'grey.100',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography color="text.secondary">Нет фото</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{p.category?.name && <Chip label={p.category.name} />}
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
</Box>
<Typography variant="h4" component="h1">
{p.title}
</Typography>
<Typography variant="h5" color="primary">
{formatPriceRub(p.priceCents)}
</Typography>
{p.description ? (
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
) : (
<Typography color="text.secondary">Описание появится позже.</Typography>
)}
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
<IconButton
onClick={() => setViewerOpen(false)}
sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2, color: 'white' }}
aria-label="Закрыть"
>
<CloseIcon />
</IconButton>
<Swiper
modules={[Navigation]}
navigation
initialSlide={viewerIndex}
style={{ width: '100%', height: '100%' }}
>
{imageUrls.map((url) => (
<SwiperSlide key={`fs:${url}`}>
<Box
component="img"
src={url}
alt={p.title}
sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
/>
</SwiperSlide>
))}
</Swiper>
</Box>
</Dialog>
</Box>
)
}
+3
View File
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module 'swiper/css'
declare module 'swiper/css/navigation'
+2
View File
@@ -19,6 +19,8 @@
"noEmit": true,
"jsx": "react-jsx",
"ignoreDeprecations": "6.0",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
+281
View File
@@ -10,6 +10,8 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
@@ -20,6 +22,22 @@
"prisma": "5.22.0"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -41,6 +59,12 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/busboy": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
@@ -61,6 +85,22 @@
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/deepmerge": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -154,6 +194,29 @@
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/multipart": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.0.0.tgz",
"integrity": "sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@fastify/deepmerge": "^3.0.0",
"@fastify/error": "^4.0.0",
"fastify-plugin": "^5.0.0",
"secure-json-parse": "^4.0.0"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
@@ -174,6 +237,53 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^1.0.1",
"fastify-plugin": "^5.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@@ -337,6 +447,15 @@
"fastq": "^1.17.1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -352,6 +471,31 @@
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -365,6 +509,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -395,6 +548,12 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -591,6 +750,43 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -668,12 +864,57 @@
],
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
@@ -707,6 +948,22 @@
"node": ">=14.0.0"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -921,6 +1178,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -939,6 +1202,15 @@
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
@@ -973,6 +1245,15 @@
"node": ">=12"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -16,6 +16,8 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
@@ -0,0 +1,25 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"shortDescription" TEXT,
"description" TEXT,
"priceCents" INTEGER NOT NULL,
"imageUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"inStock" BOOLEAN NOT NULL DEFAULT true,
"leadTimeDays" INTEGER,
"categoryId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "priceCents", "published", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "priceCents", "published", "slug", "title", "updatedAt" FROM "Product";
DROP TABLE "Product";
ALTER TABLE "new_Product" RENAME TO "Product";
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "ProductImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"sort" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"productId" TEXT NOT NULL,
CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "ProductImage_productId_sort_idx" ON "ProductImage"("productId", "sort");
+17
View File
@@ -20,15 +20,32 @@ model Product {
id String @id @default(cuid())
title String
slug String @unique
shortDescription String?
description String?
/// Цена в копейках (целое число, без дробной части)
priceCents Int
imageUrl String?
published Boolean @default(false)
inStock Boolean @default(true)
leadTimeDays Int?
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images ProductImage[]
}
model ProductImage {
id String @id @default(cuid())
url String
sort Int @default(0)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
@@index([productId, sort])
}
model User {
+16
View File
@@ -2,6 +2,9 @@ import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js'
@@ -23,6 +26,19 @@ await fastify.register(jwt, {
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-me',
})
await fastify.register(multipart, {
limits: {
files: 10,
fileSize: 10 * 1024 * 1024,
},
})
const uploadsDir = path.join(process.cwd(), 'uploads')
await fastify.register(fastifyStatic, {
root: uploadsDir,
prefix: '/uploads/',
})
fastify.decorate('authenticate', async function authenticate(request, reply) {
try {
await request.jwtVerify()
+114 -6
View File
@@ -1,4 +1,7 @@
import { prisma } from '../lib/prisma.js'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
function slugify(input) {
return input
@@ -8,6 +11,12 @@ function slugify(input) {
.replace(/[^a-z0-9-а-яё]/gi, '')
}
function safeExtFromFilename(filename) {
const ext = path.extname(String(filename || '')).toLowerCase()
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
return allowed.has(ext) ? ext : null
}
export async function registerApiRoutes(fastify) {
fastify.get('/api/categories', async () => {
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
@@ -21,7 +30,7 @@ export async function registerApiRoutes(fastify) {
}
return prisma.product.findMany({
where,
include: { category: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { createdAt: 'desc' },
})
})
@@ -30,7 +39,7 @@ export async function registerApiRoutes(fastify) {
const { id } = request.params
const product = await prisma.product.findFirst({
where: { id, published: true },
include: { category: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
if (!product) {
reply.code(404).send({ error: 'Товар не найден' })
@@ -46,12 +55,45 @@ export async function registerApiRoutes(fastify) {
{ preHandler: [fastify.verifyAdmin] },
async () => {
return prisma.product.findMany({
include: { category: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
orderBy: { updatedAt: 'desc' },
})
},
)
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
if (!request.isMultipart()) {
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
return
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
const ext = safeExtFromFilename(part.filename)
if (!ext) {
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
return
}
const id = crypto.randomUUID()
const fileName = `${id}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return { urls }
},
)
fastify.post(
'/api/admin/products',
{ preHandler: [fastify.verifyAdmin] },
@@ -74,6 +116,19 @@ export async function registerApiRoutes(fastify) {
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
return
}
const inStock =
body.inStock === undefined || body.inStock === null ? true : Boolean(body.inStock)
const leadTimeDaysRaw = body.leadTimeDays
const leadTimeDays =
leadTimeDaysRaw === undefined || leadTimeDaysRaw === null || leadTimeDaysRaw === ''
? null
: Number(leadTimeDaysRaw)
if (!inStock) {
if (!Number.isFinite(leadTimeDays) || leadTimeDays <= 0) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
}
const exists = await prisma.product.findUnique({ where: { slug } })
if (exists) {
reply.code(409).send({ error: 'Такой slug уже занят' })
@@ -83,13 +138,25 @@ export async function registerApiRoutes(fastify) {
data: {
title,
slug,
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
description: body.description ? String(body.description) : null,
priceCents: Math.round(priceCents),
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
published: Boolean(body.published),
inStock,
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
categoryId,
images: Array.isArray(body.imageUrls)
? {
create: body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx })),
}
: undefined,
},
include: { category: true },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
reply.code(201).send(product)
},
@@ -121,6 +188,9 @@ export async function registerApiRoutes(fastify) {
data.slug = s
}
}
if (body.shortDescription !== undefined) {
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
}
if (body.description !== undefined) {
data.description = body.description ? String(body.description) : null
}
@@ -138,10 +208,48 @@ export async function registerApiRoutes(fastify) {
if (body.published !== undefined) data.published = Boolean(body.published)
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
if (body.leadTimeDays !== undefined) {
const v = body.leadTimeDays
const n = v === null || v === '' ? null : Number(v)
if (n !== null && (!Number.isFinite(n) || n <= 0)) {
reply.code(400).send({ error: 'Срок исполнения должен быть числом дней > 0' })
return
}
data.leadTimeDays = n === null ? null : Math.round(n)
}
const nextInStock = data.inStock ?? existing.inStock
const nextLead = data.leadTimeDays ?? existing.leadTimeDays
if (!nextInStock && (!Number.isFinite(nextLead) || nextLead === null || nextLead <= 0)) {
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
return
}
if (nextInStock && data.leadTimeDays !== undefined) {
data.leadTimeDays = null
}
const imagesUpdate =
body.imageUrls !== undefined
? {
deleteMany: {},
create: Array.isArray(body.imageUrls)
? body.imageUrls
.map((u) => String(u || '').trim())
.filter(Boolean)
.slice(0, 10)
.map((u, idx) => ({ url: u, sort: idx }))
: [],
}
: undefined
const product = await prisma.product.update({
where: { id },
data,
include: { category: true },
data: {
...data,
images: imagesUpdate,
},
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
return product
},
+1
View File
@@ -0,0 +1 @@