base commit
This commit is contained in:
Generated
+21
-1
@@ -19,7 +19,8 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-hook-form": "^7.74.0",
|
"react-hook-form": "^7.74.0",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2",
|
||||||
|
"swiper": "^12.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -6152,6 +6153,25 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/synckit": {
|
||||||
"version": "0.11.12",
|
"version": "0.11.12",
|
||||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-hook-form": "^7.74.0",
|
"react-hook-form": "^7.74.0",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2",
|
||||||
|
"swiper": "^12.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AdminPage } from '@/pages/admin'
|
|||||||
import { AuthPage } from '@/pages/auth'
|
import { AuthPage } from '@/pages/auth'
|
||||||
import { HomePage } from '@/pages/home'
|
import { HomePage } from '@/pages/home'
|
||||||
import { MePage } from '@/pages/me/ui/MePage'
|
import { MePage } from '@/pages/me/ui/MePage'
|
||||||
|
import { ProductPage } from '@/pages/product'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +17,7 @@ export function App() {
|
|||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/me" element={<MePage />} />
|
<Route path="/me" element={<MePage />} />
|
||||||
|
<Route path="/products/:id" element={<ProductPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export async function fetchPublicProducts(categorySlug?: string): Promise<Produc
|
|||||||
return data
|
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[]> {
|
export async function fetchCategories(): Promise<Category[]> {
|
||||||
const { data } = await apiClient.get<Category[]>('categories')
|
const { data } = await apiClient.get<Category[]>('categories')
|
||||||
return data
|
return data
|
||||||
@@ -25,10 +30,14 @@ export async function createProduct(
|
|||||||
body: {
|
body: {
|
||||||
title: string
|
title: string
|
||||||
slug?: string
|
slug?: string
|
||||||
|
shortDescription?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl?: string | null
|
imageUrl?: string | null
|
||||||
|
imageUrls?: string[]
|
||||||
published: boolean
|
published: boolean
|
||||||
|
inStock?: boolean
|
||||||
|
leadTimeDays?: number | null
|
||||||
categoryId: string
|
categoryId: string
|
||||||
},
|
},
|
||||||
): Promise<Product> {
|
): Promise<Product> {
|
||||||
@@ -44,10 +53,14 @@ export async function updateProduct(
|
|||||||
body: Partial<{
|
body: Partial<{
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
|
shortDescription: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
imageUrls: string[]
|
||||||
published: boolean
|
published: boolean
|
||||||
|
inStock: boolean
|
||||||
|
leadTimeDays: number | null
|
||||||
categoryId: string
|
categoryId: string
|
||||||
}>,
|
}>,
|
||||||
): Promise<Product> {
|
): Promise<Product> {
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ export type Product = {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
|
shortDescription: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
||||||
published: boolean
|
published: boolean
|
||||||
|
inStock: boolean
|
||||||
|
leadTimeDays: number | null
|
||||||
categoryId: string
|
categoryId: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
category?: Category
|
category?: Category
|
||||||
|
images?: { id: string; url: string; sort: number }[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
|
import { useMemo, useRef } from 'react'
|
||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from '@mui/material/CardContent'
|
||||||
import CardMedia from '@mui/material/CardMedia'
|
import CardMedia from '@mui/material/CardMedia'
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { 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 type { Product } from '@/entities/product/model/types'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
|
||||||
type Props = { product: Product }
|
type Props = { product: Product }
|
||||||
|
|
||||||
export function ProductCard({ product }: Props) {
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -32,19 +55,43 @@ export function ProductCard({ product }: Props) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.imageUrl ? (
|
<Link
|
||||||
<CardMedia
|
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"
|
component="img"
|
||||||
height="200"
|
src={url}
|
||||||
image={product.imageUrl}
|
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
|
className="product-card__media"
|
||||||
sx={{
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: 200,
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
transition: 'transform 240ms ease',
|
transition: 'transform 240ms ease',
|
||||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
className="product-card__media"
|
|
||||||
/>
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="div"
|
component="div"
|
||||||
@@ -59,10 +106,16 @@ export function ProductCard({ product }: Props) {
|
|||||||
<Typography color="text.secondary">Нет фото</Typography>
|
<Typography color="text.secondary">Нет фото</Typography>
|
||||||
</CardMedia>
|
</CardMedia>
|
||||||
)}
|
)}
|
||||||
|
</Link>
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
{product.category && <Chip label={product.category.name} size="small" />}
|
{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}
|
{product.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -76,7 +129,7 @@ export function ProductCard({ product }: Props) {
|
|||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{product.description ?? 'Описание появится позже.'}
|
{product.shortDescription ?? 'Описание появится позже.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" color="primary">
|
<Typography variant="h6" color="primary">
|
||||||
{formatPriceRub(product.priceCents)}
|
{formatPriceRub(product.priceCents)}
|
||||||
|
|||||||
@@ -31,26 +31,33 @@ import {
|
|||||||
updateProduct,
|
updateProduct,
|
||||||
} from '@/entities/product/api/product-api'
|
} from '@/entities/product/api/product-api'
|
||||||
import type { Product } from '@/entities/product/model/types'
|
import type { Product } from '@/entities/product/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
|
shortDescription: string
|
||||||
description: string
|
description: string
|
||||||
priceRub: string
|
priceRub: string
|
||||||
imageUrl: string
|
imageUrls: string[]
|
||||||
published: boolean
|
published: boolean
|
||||||
|
inStock: boolean
|
||||||
|
leadTimeDays: string
|
||||||
categoryId: string
|
categoryId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm = (): FormState => ({
|
const emptyForm = (): FormState => ({
|
||||||
title: '',
|
title: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
|
shortDescription: '',
|
||||||
description: '',
|
description: '',
|
||||||
priceRub: '',
|
priceRub: '',
|
||||||
imageUrl: '',
|
imageUrls: [],
|
||||||
published: true,
|
published: true,
|
||||||
|
inStock: true,
|
||||||
|
leadTimeDays: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,6 +85,7 @@ export function AdminPage() {
|
|||||||
|
|
||||||
const titleValue = productForm.watch('title')
|
const titleValue = productForm.watch('title')
|
||||||
const categoryIdValue = productForm.watch('categoryId')
|
const categoryIdValue = productForm.watch('categoryId')
|
||||||
|
const inStockValue = productForm.watch('inStock')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tokenForm.reset({ token: '' })
|
tokenForm.reset({ token: '' })
|
||||||
@@ -113,13 +121,21 @@ export function AdminPage() {
|
|||||||
|
|
||||||
const openEdit = (p: Product) => {
|
const openEdit = (p: Product) => {
|
||||||
setEditing(p)
|
setEditing(p)
|
||||||
|
const urls =
|
||||||
|
(p.images ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
|
||||||
productForm.reset({
|
productForm.reset({
|
||||||
title: p.title,
|
title: p.title,
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
|
shortDescription: p.shortDescription ?? '',
|
||||||
description: p.description ?? '',
|
description: p.description ?? '',
|
||||||
priceRub: String(p.priceCents / 100),
|
priceRub: String(p.priceCents / 100),
|
||||||
imageUrl: p.imageUrl ?? '',
|
imageUrls: urls,
|
||||||
published: p.published,
|
published: p.published,
|
||||||
|
inStock: p.inStock,
|
||||||
|
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
|
||||||
categoryId: p.categoryId,
|
categoryId: p.categoryId,
|
||||||
})
|
})
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
@@ -130,13 +146,22 @@ export function AdminPage() {
|
|||||||
const form = productForm.getValues()
|
const form = productForm.getValues()
|
||||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
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!, {
|
await createProduct(token!, {
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
slug: form.slug.trim() || undefined,
|
slug: form.slug.trim() || undefined,
|
||||||
|
shortDescription: form.shortDescription.trim() || null,
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
priceCents,
|
priceCents,
|
||||||
imageUrl: form.imageUrl.trim() || null,
|
imageUrls: form.imageUrls,
|
||||||
published: form.published,
|
published: form.published,
|
||||||
|
inStock: form.inStock,
|
||||||
|
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||||
categoryId: form.categoryId,
|
categoryId: form.categoryId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -152,13 +177,22 @@ export function AdminPage() {
|
|||||||
const form = productForm.getValues()
|
const form = productForm.getValues()
|
||||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
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, {
|
await updateProduct(token!, editing!.id, {
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
slug: form.slug.trim(),
|
slug: form.slug.trim(),
|
||||||
|
shortDescription: form.shortDescription.trim() || null,
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
priceCents,
|
priceCents,
|
||||||
imageUrl: form.imageUrl.trim() || null,
|
imageUrls: form.imageUrls,
|
||||||
published: form.published,
|
published: form.published,
|
||||||
|
inStock: form.inStock,
|
||||||
|
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||||
categoryId: form.categoryId,
|
categoryId: form.categoryId,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -199,6 +233,33 @@ export function AdminPage() {
|
|||||||
|
|
||||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<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
|
<Controller
|
||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="description"
|
name="description"
|
||||||
@@ -321,11 +389,83 @@ export function AdminPage() {
|
|||||||
name="priceRub"
|
name="priceRub"
|
||||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Box>
|
||||||
control={productForm.control}
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
name="imageUrl"
|
Фото (загрузка)
|
||||||
render={({ field }) => <TextField label="Ссылка на изображение" fullWidth {...field} />}
|
</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
|
<Controller
|
||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="categoryId"
|
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>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProductPage } from './ui/ProductPage'
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+3
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'swiper/css'
|
||||||
|
declare module 'swiper/css/navigation'
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
Generated
+281
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -20,6 +22,22 @@
|
|||||||
"prisma": "5.22.0"
|
"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": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||||
@@ -41,6 +59,12 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "11.2.0",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||||
@@ -61,6 +85,22 @@
|
|||||||
"toad-cache": "^3.7.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -154,6 +194,29 @@
|
|||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
@@ -174,6 +237,53 @@
|
|||||||
"ipaddr.js": "^2.1.0"
|
"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": {
|
"node_modules/@lukeed/ms": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
@@ -337,6 +447,15 @@
|
|||||||
"fastq": "^1.17.1"
|
"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": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
@@ -352,6 +471,31 @@
|
|||||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cookie": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
@@ -365,6 +509,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -395,6 +548,12 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"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": "^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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -668,12 +864,57 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/minimalistic-assert": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.40.3",
|
"version": "0.40.3",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||||
@@ -707,6 +948,22 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
@@ -921,6 +1178,12 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
@@ -939,6 +1202,15 @@
|
|||||||
"node": ">= 10.x"
|
"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": {
|
"node_modules/steed": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||||
@@ -973,6 +1245,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^10.0.0",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
+25
@@ -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");
|
||||||
@@ -20,15 +20,32 @@ model Product {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
shortDescription String?
|
||||||
description String?
|
description String?
|
||||||
/// Цена в копейках (целое число, без дробной части)
|
/// Цена в копейках (целое число, без дробной части)
|
||||||
priceCents Int
|
priceCents Int
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
|
inStock Boolean @default(true)
|
||||||
|
leadTimeDays Int?
|
||||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||||
categoryId String
|
categoryId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model User {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'dotenv/config'
|
|||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import jwt from '@fastify/jwt'
|
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 { registerAuth } from './plugins/auth.js'
|
||||||
import { registerApiRoutes } from './routes/api.js'
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
import { registerAuthRoutes } from './routes/auth.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',
|
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) {
|
fastify.decorate('authenticate', async function authenticate(request, reply) {
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify()
|
await request.jwtVerify()
|
||||||
|
|||||||
+114
-6
@@ -1,4 +1,7 @@
|
|||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
function slugify(input) {
|
function slugify(input) {
|
||||||
return input
|
return input
|
||||||
@@ -8,6 +11,12 @@ function slugify(input) {
|
|||||||
.replace(/[^a-z0-9-а-яё]/gi, '')
|
.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) {
|
export async function registerApiRoutes(fastify) {
|
||||||
fastify.get('/api/categories', async () => {
|
fastify.get('/api/categories', async () => {
|
||||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||||
@@ -21,7 +30,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
return prisma.product.findMany({
|
return prisma.product.findMany({
|
||||||
where,
|
where,
|
||||||
include: { category: true },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -30,7 +39,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
const { id } = request.params
|
const { id } = request.params
|
||||||
const product = await prisma.product.findFirst({
|
const product = await prisma.product.findFirst({
|
||||||
where: { id, published: true },
|
where: { id, published: true },
|
||||||
include: { category: true },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (!product) {
|
if (!product) {
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
@@ -46,12 +55,45 @@ export async function registerApiRoutes(fastify) {
|
|||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
async () => {
|
async () => {
|
||||||
return prisma.product.findMany({
|
return prisma.product.findMany({
|
||||||
include: { category: true },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
orderBy: { updatedAt: 'desc' },
|
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(
|
fastify.post(
|
||||||
'/api/admin/products',
|
'/api/admin/products',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
@@ -74,6 +116,19 @@ export async function registerApiRoutes(fastify) {
|
|||||||
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
|
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
|
||||||
return
|
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 } })
|
const exists = await prisma.product.findUnique({ where: { slug } })
|
||||||
if (exists) {
|
if (exists) {
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
@@ -83,13 +138,25 @@ export async function registerApiRoutes(fastify) {
|
|||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
|
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
||||||
description: body.description ? String(body.description) : null,
|
description: body.description ? String(body.description) : null,
|
||||||
priceCents: Math.round(priceCents),
|
priceCents: Math.round(priceCents),
|
||||||
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
||||||
published: Boolean(body.published),
|
published: Boolean(body.published),
|
||||||
|
inStock,
|
||||||
|
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
|
||||||
categoryId,
|
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)
|
reply.code(201).send(product)
|
||||||
},
|
},
|
||||||
@@ -121,6 +188,9 @@ export async function registerApiRoutes(fastify) {
|
|||||||
data.slug = s
|
data.slug = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (body.shortDescription !== undefined) {
|
||||||
|
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
|
||||||
|
}
|
||||||
if (body.description !== undefined) {
|
if (body.description !== undefined) {
|
||||||
data.description = body.description ? String(body.description) : null
|
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.published !== undefined) data.published = Boolean(body.published)
|
||||||
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
|
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({
|
const product = await prisma.product.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
include: { category: true },
|
...data,
|
||||||
|
images: imagesUpdate,
|
||||||
|
},
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
})
|
})
|
||||||
return product
|
return product
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user