diff --git a/client/package-lock.json b/client/package-lock.json
index 3954447..621fef6 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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",
diff --git a/client/package.json b/client/package.json
index 2c828ee..6669f2d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx
index e850815..e69b54b 100644
--- a/client/src/app/App.tsx
+++ b/client/src/app/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts
index 707bf20..de69344 100644
--- a/client/src/entities/product/api/product-api.ts
+++ b/client/src/entities/product/api/product-api.ts
@@ -8,6 +8,11 @@ export async function fetchPublicProducts(categorySlug?: string): Promise {
+ const { data } = await apiClient.get(`products/${id}`)
+ return data
+}
+
export async function fetchCategories(): Promise {
const { data } = await apiClient.get('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 {
@@ -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 {
diff --git a/client/src/entities/product/model/types.ts b/client/src/entities/product/model/types.ts
index 858f568..8730571 100644
--- a/client/src/entities/product/model/types.ts
+++ b/client/src/entities/product/model/types.ts
@@ -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 }[]
}
diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx
index 7db4069..17f018a 100644
--- a/client/src/entities/product/ui/ProductCard.tsx
+++ b/client/src/entities/product/ui/ProductCard.tsx
@@ -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(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) => {
+ 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 (
- {product.imageUrl ? (
-
- ) : (
-
- Нет фото
-
- )}
+
+ {imageUrls.length ? (
+
+ {
+ swiperRef.current = s
+ }}
+ allowTouchMove={false}
+ style={{ width: '100%', height: 200 }}
+ >
+ {imageUrls.map((url) => (
+
+
+
+ ))}
+
+
+ ) : (
+
+ Нет фото
+
+ )}
+
{product.category && }
-
+
{product.title}
- {product.description ?? 'Описание появится позже.'}
+ {product.shortDescription ?? 'Описание появится позже.'}
{formatPriceRub(product.priceCents)}
diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx
index 957de20..41810f7 100644
--- a/client/src/pages/admin/ui/AdminPage.tsx
+++ b/client/src/pages/admin/ui/AdminPage.tsx
@@ -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 (
@@ -311,6 +372,13 @@ export function AdminPage() {
/>
)}
/>
+ (
+
+ )}
+ />
}
/>
- }
- />
+
+
+ Фото (загрузка)
+
+
+
+ {uploadImagesMut.isPending && Загрузка…}
+ {uploadImagesMut.isError && (
+ Не удалось загрузить фото (проверьте токен и сервер)
+ )}
+
+
+ {productForm.watch('imageUrls').length > 0 && (
+
+ {productForm.watch('imageUrls').map((url) => (
+
+
+
+
+ ))}
+
+ )}
+
)}
/>
+ (
+ field.onChange(v)} />}
+ label={field.value ? 'В наличии' : 'Под заказ'}
+ />
+ )}
+ />
+ {!inStockValue && (
+ }
+ />
+ )}
diff --git a/client/src/pages/product/index.ts b/client/src/pages/product/index.ts
new file mode 100644
index 0000000..080cb7e
--- /dev/null
+++ b/client/src/pages/product/index.ts
@@ -0,0 +1,2 @@
+export { ProductPage } from './ui/ProductPage'
+
diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx
new file mode 100644
index 0000000..b2e33b2
--- /dev/null
+++ b/client/src/pages/product/ui/ProductPage.tsx
@@ -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 Некорректная ссылка на товар.
+
+ if (productQuery.isLoading) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ if (productQuery.isError) return Не удалось загрузить товар.
+
+ const p = productQuery.data
+ if (!p) return Товар не найден.
+
+ return (
+
+
+ {imageUrls.length > 0 ? (
+
+
+ {imageUrls.map((url, idx) => (
+
+ {
+ setViewerIndex(idx)
+ setViewerOpen(true)
+ }}
+ sx={{
+ width: '100%',
+ height: 420,
+ objectFit: 'cover',
+ display: 'block',
+ cursor: 'zoom-in',
+ userSelect: 'none',
+ }}
+ />
+
+ ))}
+
+
+ ) : (
+
+ Нет фото
+
+ )}
+
+
+ {p.category?.name && }
+
+
+
+
+ {p.title}
+
+
+ {formatPriceRub(p.priceCents)}
+
+
+ {p.description ? (
+ {p.description}
+ ) : (
+ Описание появится позже.
+ )}
+
+
+
+
+ )
+}
+
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
index d02ada6..0962348 100644
--- a/client/src/vite-env.d.ts
+++ b/client/src/vite-env.d.ts
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
+
+declare module 'swiper/css'
+declare module 'swiper/css/navigation'
diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json
index d2d4d99..3e0bd24 100644
--- a/client/tsconfig.app.json
+++ b/client/tsconfig.app.json
@@ -19,6 +19,8 @@
"noEmit": true,
"jsx": "react-jsx",
+ "ignoreDeprecations": "6.0",
+
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
diff --git a/server/package-lock.json b/server/package-lock.json
index 8829e3b..d16814f 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -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",
diff --git a/server/package.json b/server/package.json
index ca24dce..6c47678 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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",
diff --git a/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql b/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql
new file mode 100644
index 0000000..33a1d84
--- /dev/null
+++ b/server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql
@@ -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;
diff --git a/server/prisma/migrations/20260428165733_product_images/migration.sql b/server/prisma/migrations/20260428165733_product_images/migration.sql
new file mode 100644
index 0000000..7ea7f8a
--- /dev/null
+++ b/server/prisma/migrations/20260428165733_product_images/migration.sql
@@ -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");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index d2c253c..ba35fb4 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -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 {
diff --git a/server/src/index.js b/server/src/index.js
index e665b50..d50980c 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -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()
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 70bc1a6..c3386e2 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -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
},
diff --git a/server/uploads/.gitkeep b/server/uploads/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/server/uploads/.gitkeep
@@ -0,0 +1 @@
+