From 3f7fdb1e15767000b3f65a14f687b2216ca2d15b Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Tue, 28 Apr 2026 22:15:12 +0500 Subject: [PATCH] base commit --- client/package-lock.json | 22 +- client/package.json | 3 +- client/src/app/App.tsx | 2 + .../src/entities/product/api/product-api.ts | 13 + client/src/entities/product/model/types.ts | 5 + .../src/entities/product/ui/ProductCard.tsx | 111 +++++-- client/src/pages/admin/ui/AdminPage.tsx | 177 ++++++++++- client/src/pages/product/index.ts | 2 + client/src/pages/product/ui/ProductPage.tsx | 162 ++++++++++ client/src/vite-env.d.ts | 3 + client/tsconfig.app.json | 2 + server/package-lock.json | 281 ++++++++++++++++++ server/package.json | 2 + .../migration.sql | 25 ++ .../migration.sql | 12 + server/prisma/schema.prisma | 17 ++ server/src/index.js | 16 + server/src/routes/api.js | 120 +++++++- server/uploads/.gitkeep | 1 + 19 files changed, 929 insertions(+), 47 deletions(-) create mode 100644 client/src/pages/product/index.ts create mode 100644 client/src/pages/product/ui/ProductPage.tsx create mode 100644 server/prisma/migrations/20260428165414_product_shortdesc_stock_leadtime/migration.sql create mode 100644 server/prisma/migrations/20260428165733_product_images/migration.sql create mode 100644 server/uploads/.gitkeep 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} + ) : ( + Описание появится позже. + )} + + + setViewerOpen(false)}> + + setViewerOpen(false)} + sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2, color: 'white' }} + aria-label="Закрыть" + > + + + + + {imageUrls.map((url) => ( + + + + ))} + + + + + ) +} + 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 @@ +