diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index e69b54b..2dc97e6 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AppProviders } from '@/app/providers/AppProviders' import { AdminPage } from '@/pages/admin' +import { AdminUsersPage } from '@/pages/admin-users' import { AuthPage } from '@/pages/auth' import { HomePage } from '@/pages/home' import { MePage } from '@/pages/me/ui/MePage' @@ -15,6 +16,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 de69344..7187f31 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -1,9 +1,32 @@ import type { Category, Product } from '@/entities/product/model/types' import { apiClient } from '@/shared/api/client' -export async function fetchPublicProducts(categorySlug?: string): Promise { - const { data } = await apiClient.get('products', { - params: categorySlug ? { categorySlug } : undefined, +export type PublicProductsResponse = { + items: Product[] + total: number + page: number + pageSize: number +} + +export async function fetchPublicProducts(params?: { + categorySlug?: string + q?: string + sort?: 'price_asc' | 'price_desc' | '' + page?: number + pageSize?: number + priceMinCents?: number + priceMaxCents?: number +}): Promise { + const { data } = await apiClient.get('products', { + params: { + categorySlug: params?.categorySlug || undefined, + q: params?.q || undefined, + sort: params?.sort || undefined, + page: params?.page || undefined, + pageSize: params?.pageSize || undefined, + priceMin: params?.priceMinCents ?? undefined, + priceMax: params?.priceMaxCents ?? undefined, + }, }) return data } @@ -32,6 +55,8 @@ export async function createProduct( slug?: string shortDescription?: string | null description?: string | null + quantity?: number | null + materials?: string[] priceCents: number imageUrl?: string | null imageUrls?: string[] @@ -55,6 +80,8 @@ export async function updateProduct( slug: string shortDescription: string | null description: string | null + quantity: number | null + materials: string[] priceCents: number imageUrl: string | null imageUrls: string[] diff --git a/client/src/entities/product/model/types.ts b/client/src/entities/product/model/types.ts index 8730571..e0c6c9b 100644 --- a/client/src/entities/product/model/types.ts +++ b/client/src/entities/product/model/types.ts @@ -11,6 +11,8 @@ export type Product = { slug: string shortDescription: string | null description: string | null + quantity?: number | null + materials?: string[] priceCents: number imageUrl: string | null imageUrls?: string[] // legacy-friendly (used only in admin payloads) diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index 17f018a..b85f804 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -14,16 +14,22 @@ 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 } +type Props = { product: Product; mediaHeight?: number } -export function ProductCard({ product }: Props) { +export function ProductCard({ product, mediaHeight = 200 }: 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 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 materials = (product.materials ?? []).slice(0, 3) + const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length) + const onMouseMove = (e: React.MouseEvent) => { if (!swiperRef.current) return if (imageUrls.length <= 1) return @@ -63,13 +69,13 @@ export function ProductCard({ product }: Props) { sx={{ display: 'block' }} > {imageUrls.length ? ( - + { swiperRef.current = s }} allowTouchMove={false} - style={{ width: '100%', height: 200 }} + style={{ width: '100%', height: mediaHeight }} > {imageUrls.map((url) => ( @@ -80,7 +86,7 @@ export function ProductCard({ product }: Props) { className="product-card__media" sx={{ width: '100%', - height: 200, + height: mediaHeight, objectFit: 'cover', display: 'block', transition: 'transform 240ms ease', @@ -118,6 +124,14 @@ export function ProductCard({ product }: Props) { > {product.title} + {(product.materials?.length ?? 0) > 0 && ( + + {materials.map((m) => ( + + ))} + {moreMaterials > 0 && } + + )} { + const { data } = await apiClient.get('admin/users', { + params, + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function createAdminUser( + token: string, + body: { email: string; name?: string | null; password?: string }, +): Promise { + const { data } = await apiClient.post('admin/users', body, { + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function updateAdminUser( + token: string, + id: string, + body: Partial<{ email: string; name: string | null; password: string }>, +): Promise { + const { data } = await apiClient.patch(`admin/users/${id}`, body, { + headers: { Authorization: `Bearer ${token}` }, + }) + return data +} + +export async function deleteAdminUser(token: string, id: string): Promise { + await apiClient.delete(`admin/users/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) +} diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts new file mode 100644 index 0000000..b538c0f --- /dev/null +++ b/client/src/entities/user/model/types.ts @@ -0,0 +1,8 @@ +export type AdminUser = { + id: string + email: string + name: string | null + hasPassword: boolean + createdAt: string + updatedAt: string +} diff --git a/client/src/pages/admin-users/index.ts b/client/src/pages/admin-users/index.ts new file mode 100644 index 0000000..df429c2 --- /dev/null +++ b/client/src/pages/admin-users/index.ts @@ -0,0 +1 @@ +export { AdminUsersPage } from './ui/AdminUsersPage' diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx new file mode 100644 index 0000000..faf98e4 --- /dev/null +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -0,0 +1,333 @@ +import { useEffect, useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import Stack from '@mui/material/Stack' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TablePagination from '@mui/material/TablePagination' +import TableRow from '@mui/material/TableRow' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' +import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api' +import type { AdminUser } from '@/entities/user/model/types' +import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' + +type TokenFormState = { token: string } + +type UserFormState = { + email: string + name: string + password: string +} + +const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' }) + +function formatDt(v: string) { + try { + const d = new Date(v) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleString() + } catch { + return '—' + } +} + +export function AdminUsersPage() { + const queryClient = useQueryClient() + const [token, setTokenState] = useState(() => getAdminToken()) + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [qInput, setQInput] = useState('') + const [q, setQ] = useState('') + const [page, setPage] = useState(0) + const [rowsPerPage, setRowsPerPage] = useState(20) + + const tokenForm = useForm({ + defaultValues: { token: '' }, + mode: 'onChange', + }) + + const userForm = useForm({ + defaultValues: emptyUserForm(), + mode: 'onChange', + }) + + useEffect(() => { + tokenForm.reset({ token: '' }) + }, [token, tokenForm]) + + useEffect(() => { + const t = window.setTimeout(() => { + setQ(qInput.trim()) + setPage(0) + }, 250) + return () => window.clearTimeout(t) + }, [qInput]) + + const saveToken = () => { + const t = tokenForm.getValues('token').trim() + if (!t) { + clearAdminToken() + setTokenState(null) + return + } + setAdminToken(t) + setTokenState(t) + } + + const usersQuery = useQuery({ + queryKey: ['admin', 'users', token, { q, page, rowsPerPage }], + queryFn: () => + fetchAdminUsers(token!, { + q: q || undefined, + page: page + 1, + pageSize: rowsPerPage, + }), + enabled: Boolean(token), + }) + + const createMut = useMutation({ + mutationFn: async () => { + const v = userForm.getValues() + await createAdminUser(token!, { + email: v.email.trim(), + name: v.name.trim() || null, + password: v.password.trim() || undefined, + }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + setDialogOpen(false) + }, + }) + + const updateMut = useMutation({ + mutationFn: async () => { + const v = userForm.getValues() + await updateAdminUser(token!, editing!.id, { + email: v.email.trim(), + name: v.name.trim() || null, + ...(v.password.trim() ? { password: v.password.trim() } : {}), + }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + setDialogOpen(false) + }, + }) + + const deleteMut = useMutation({ + mutationFn: async (id: string) => deleteAdminUser(token!, id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) + + const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error + + const openCreate = () => { + setEditing(null) + userForm.reset(emptyUserForm()) + setDialogOpen(true) + } + + const openEdit = (u: AdminUser) => { + setEditing(u) + userForm.reset({ + email: u.email, + name: u.name ?? '', + password: '', + }) + setDialogOpen(true) + } + + const emailValue = userForm.watch('email') + const isSaveDisabled = !emailValue.trim() || createMut.isPending || updateMut.isPending + + const users = usersQuery.data?.items ?? [] + const total = usersQuery.data?.total ?? 0 + + return ( + + + Админка — пользователи + + + + + Введите API-токен из{' '} + + .env + {' '} + сервера (ADMIN_API_TOKEN). Он сохраняется только в памяти браузера (sessionStorage). + + + + ( + + )} + /> + + + + {!token && После сохранения токена здесь появится список пользователей.} + + {token && ( + <> + + + setQInput(e.target.value)} + fullWidth + /> + + + {usersQuery.isError && ( + + Ошибка загрузки. Проверьте токен и что сервер запущен. + + )} + + {mutationError && ( + + {(mutationError as Error).message} + + )} + + + + + Почта + Имя + Пароль + Создан + Обновлён + Действия + + + + {users.map((u) => ( + + {u.email} + {u.name ?? '—'} + {u.hasPassword ? 'задан' : 'нет'} + {formatDt(u.createdAt)} + {formatDt(u.updatedAt)} + + + + + + ))} + {users.length === 0 && !usersQuery.isLoading && ( + + + Пользователей пока нет. + + + )} + +
+ + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(Number(e.target.value)) + setPage(0) + }} + rowsPerPageOptions={[10, 20, 50, 100]} + /> + + )} + + setDialogOpen(false)} fullWidth maxWidth="xs"> + {editing ? 'Редактировать пользователя' : 'Новый пользователь'} + + + } + /> + } + /> + ( + + )} + /> + + + + + + + +
+ ) +} diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index 41810f7..42c0c58 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -22,6 +22,7 @@ import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Controller, useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' import { createCategory, createProduct, @@ -40,6 +41,8 @@ type FormState = { slug: string shortDescription: string description: string + quantity: string + materials: string priceRub: string imageUrls: string[] published: boolean @@ -53,6 +56,8 @@ const emptyForm = (): FormState => ({ slug: '', shortDescription: '', description: '', + quantity: '', + materials: '', priceRub: '', imageUrls: [], published: true, @@ -131,6 +136,8 @@ export function AdminPage() { slug: p.slug, shortDescription: p.shortDescription ?? '', description: p.description ?? '', + quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity), + materials: (p.materials ?? []).join(', '), priceRub: String(p.priceCents / 100), imageUrls: urls, published: p.published, @@ -152,11 +159,19 @@ export function AdminPage() { throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') } } + const qty = form.quantity.trim() ? Number(form.quantity) : null + if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) await createProduct(token!, { title: form.title.trim(), slug: form.slug.trim() || undefined, shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, + quantity: qty === null ? null : Math.floor(qty), + materials, priceCents, imageUrls: form.imageUrls, published: form.published, @@ -183,11 +198,19 @@ export function AdminPage() { throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0') } } + const qty = form.quantity.trim() ? Number(form.quantity) : null + if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество') + const materials = form.materials + .split(',') + .map((x) => x.trim()) + .filter(Boolean) await updateProduct(token!, editing!.id, { title: form.title.trim(), slug: form.slug.trim(), shortDescription: form.shortDescription.trim() || null, description: form.description.trim() || null, + quantity: qty === null ? null : Math.floor(qty), + materials, priceCents, imageUrls: form.imageUrls, published: form.published, @@ -305,6 +328,9 @@ export function AdminPage() { + {productsQuery.isError && ( @@ -384,6 +410,31 @@ export function AdminPage() { name="description" render={({ field }) => } /> + ( + + )} + /> + ( + + )} + /> ('') + const [qInput, setQInput] = useState('') + const [q, setQ] = useState('') + const [moreOpen, setMoreOpen] = useState(false) + const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(12) + const [priceMinRub, setPriceMinRub] = useState('') + const [priceMaxRub, setPriceMaxRub] = useState('') + const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: () => fetchCategories(), }) + useEffect(() => { + const t = window.setTimeout(() => { + setQ(qInput.trim()) + setPage(1) + }, 250) + return () => window.clearTimeout(t) + }, [qInput]) + const productsQuery = useQuery({ - queryKey: ['products', 'public', categorySlug || 'all'], - queryFn: () => fetchPublicProducts(categorySlug || undefined), + queryKey: [ + 'products', + 'public', + { + categorySlug: categorySlug || 'all', + q, + sort, + page, + pageSize, + priceMinRub, + priceMaxRub, + }, + ], + queryFn: () => { + const toCents = (v: string) => { + const n = Number(String(v).trim().replace(',', '.')) + return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined + } + return fetchPublicProducts({ + categorySlug: categorySlug || undefined, + q: q || undefined, + sort: sort || '', + page, + pageSize, + priceMinCents: toCents(priceMinRub), + priceMaxCents: toCents(priceMaxRub), + }) + }, }) const handleCategoryChange = (e: SelectChangeEvent) => { setCategorySlug(e.target.value) + setPage(1) + } + + const handleSortChange = (e: SelectChangeEvent) => { + const v = e.target.value + if (v === '' || v === 'price_asc' || v === 'price_desc') { + setSort(v) + setPage(1) + } + } + + const handlePageSizeChange = (e: SelectChangeEvent) => { + const n = Number(e.target.value) + if (Number.isFinite(n) && n > 0) { + setPageSize(n) + setPage(1) + } } const title = useMemo( @@ -36,6 +103,11 @@ export function HomePage() { [categorySlug, categoriesQuery.data], ) + const products = productsQuery.data?.items ?? [] + const total = productsQuery.data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const mediaHeight = Math.round(200 * (cardScale / 100)) + return ( @@ -45,25 +117,146 @@ export function HomePage() { Игрушки, сувениры и другие изделия ручной работы. - - Категория - - labelId="category-filter-label" - label="Категория" - value={categorySlug} - onChange={handleCategoryChange} - disabled={categoriesQuery.isLoading} + + - - Все - - {(categoriesQuery.data ?? []).map((c) => ( - - {c.name} - - ))} - - + + Категория + + labelId="category-filter-label" + label="Категория" + value={categorySlug} + onChange={handleCategoryChange} + disabled={categoriesQuery.isLoading} + > + + Все + + {(categoriesQuery.data ?? []).map((c) => ( + + {c.name} + + ))} + + + + setQInput(e.target.value)} + sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }} + /> + + + + + + + + + + + Сортировка + labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}> + + Сначала новые + + Цена: по возрастанию + Цена: по убыванию + + + + { + setPriceMinRub(e.target.value) + setPage(1) + }} + sx={{ width: { xs: '100%', md: 180 } }} + /> + { + setPriceMaxRub(e.target.value) + setPage(1) + }} + sx={{ width: { xs: '100%', md: 180 } }} + /> + + + На странице + + labelId="page-size-label" + label="На странице" + value={String(pageSize)} + onChange={handlePageSizeChange} + > + {[6, 12, 18, 24].map((n) => ( + + {n} + + ))} + + + + + + Масштаб карточек + + { + if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v) + }} + > + S + M + L + XL + + + + + {productsQuery.isLoading && ( @@ -79,18 +272,32 @@ export function HomePage() { Не удалось загрузить товары. Проверьте, что API запущен. )} - {productsQuery.isSuccess && productsQuery.data.length === 0 && ( + {productsQuery.isSuccess && products.length === 0 && ( Пока нет опубликованных товаров. )} - {productsQuery.isSuccess && productsQuery.data.length > 0 && ( - - {productsQuery.data.map((p) => ( - - - - ))} - + {productsQuery.isSuccess && products.length > 0 && ( + <> + + {products.map((p) => ( + + + + ))} + + + + setPage(v)} + color="primary" + shape="rounded" + showFirstButton + showLastButton + /> + + )} ) diff --git a/client/src/pages/product/index.ts b/client/src/pages/product/index.ts index 080cb7e..f209f80 100644 --- a/client/src/pages/product/index.ts +++ b/client/src/pages/product/index.ts @@ -1,2 +1 @@ export { ProductPage } from './ui/ProductPage' - diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index b2e33b2..cf396cb 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -30,7 +30,10 @@ export function ProductPage() { 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 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]) @@ -113,6 +116,19 @@ export function ProductPage() {
+ {(p.materials?.length ?? 0) > 0 && ( + + + Материалы + + + {(p.materials ?? []).map((m) => ( + + ))} + + + )} + {p.title} @@ -159,4 +175,3 @@ export function ProductPage() {
) } - diff --git a/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql b/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql new file mode 100644 index 0000000..fd9a90b --- /dev/null +++ b/server/prisma/migrations/20260429121131_product_quantity_materials/migration.sql @@ -0,0 +1,27 @@ +-- 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, + "quantity" INTEGER, + "materials" TEXT NOT NULL DEFAULT '[]', + "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", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "priceCents", "published", "shortDescription", "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/schema.prisma b/server/prisma/schema.prisma index ba35fb4..db4189b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -22,6 +22,10 @@ model Product { slug String @unique shortDescription String? description String? + /// Количество на складе (если null — не ведём учёт) + quantity Int? + /// Материалы (список, например: ["хлопок","дерево"]) + materials String @default("[]") /// Цена в копейках (целое число, без дробной части) priceCents Int imageUrl String? diff --git a/server/prisma/seed.js b/server/prisma/seed.js index 311f3f0..8e57afe 100644 --- a/server/prisma/seed.js +++ b/server/prisma/seed.js @@ -20,7 +20,10 @@ async function main() { create: { title: 'Мягкая сова', slug: 'myagkaya-sova', + shortDescription: 'Мягкая игрушка ручной работы.', description: 'Ручная работа, хлопок и синтепон.', + materials: JSON.stringify(['хлопок', 'синтепон']), + quantity: 3, priceCents: 189000, imageUrl: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600&q=80', @@ -35,7 +38,10 @@ async function main() { create: { title: 'Колокольчик керамический', slug: 'suvenir-kolokolchik', + shortDescription: 'Керамика с ручной росписью.', description: 'Глазурь, ручная роспись.', + materials: JSON.stringify(['керамика', 'глазурь']), + quantity: 5, priceCents: 45000, imageUrl: 'https://images.unsplash.com/photo-1513519245088-0e12902e5a38?w=600&q=80', @@ -44,6 +50,152 @@ async function main() { }, }) + const more = [ + { + title: 'Зайчик в свитере', + slug: 'zaychik-v-svitere', + shortDescription: 'Тёплый подарок — мягкий зайчик.', + description: 'Мягкая игрушка. Свитер связан вручную.', + materials: ['акрил', 'хлопок', 'синтепон'], + quantity: 2, + priceCents: 219000, + imageUrl: 'https://images.unsplash.com/photo-1543852786-1cf6624b9987?w=600&q=80', + published: true, + categoryId: toys.id, + }, + { + title: 'Подвеска “Лес”', + slug: 'podveska-les', + shortDescription: 'Лёгкая подвеска из дерева и смолы.', + description: 'Фактура дерева + прозрачная смола.', + materials: ['дерево', 'эпоксидная смола'], + quantity: 8, + priceCents: 69000, + imageUrl: 'https://images.unsplash.com/photo-1522312346375-d1a52e2b99b3?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Набор открыток (3 шт.)', + slug: 'nabor-otkrytok-3', + shortDescription: 'Мини-коллекция с акварелью.', + description: 'Три открытки с авторской акварелью.', + materials: ['бумага', 'акварель'], + quantity: 12, + priceCents: 39000, + imageUrl: 'https://images.unsplash.com/photo-1524995997946-a1c2e315a42f?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Свеча “Ягоды”', + slug: 'svecha-yagody', + shortDescription: 'Ароматная свеча с ягодной нотой.', + description: 'Соевый воск, хлопковый фитиль.', + materials: ['соевый воск', 'ароматизатор', 'хлопковый фитиль'], + quantity: 10, + priceCents: 55000, + imageUrl: 'https://images.unsplash.com/photo-1542315192-1f61a2b1a3c0?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Мишка “Карамель”', + slug: 'mishka-karamel', + shortDescription: 'Плюшевый мишка с вышивкой.', + description: 'Плюш, вышивка вручную.', + materials: ['плюш', 'нитки'], + quantity: 4, + priceCents: 199000, + imageUrl: 'https://images.unsplash.com/photo-1545315003-c5ad6226c272?w=600&q=80', + published: true, + categoryId: toys.id, + }, + { + title: 'Брелок макраме', + slug: 'brelok-makrame', + shortDescription: 'Мини-брелок, плетение макраме.', + description: 'Подходит на ключи или рюкзак.', + materials: ['хлопковый шнур', 'кольцо'], + quantity: 15, + priceCents: 25000, + imageUrl: 'https://images.unsplash.com/photo-1520975958225-2f3ab6f4c1c1?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Кукла “Тильда”', + slug: 'kukla-tilda', + shortDescription: 'Классическая кукла в стиле тильда.', + description: 'Платье шьётся вручную, можно выбрать цвет.', + materials: ['хлопок', 'лен', 'синтепух'], + quantity: 1, + priceCents: 349000, + imageUrl: 'https://images.unsplash.com/photo-1563906267088-b029e7101114?w=600&q=80', + published: true, + categoryId: toys.id, + }, + { + title: 'Ёлочная игрушка “Звезда”', + slug: 'elochnaya-igrushka-zvezda', + shortDescription: 'Фетр, вышивка, ленточка.', + description: 'Лёгкая игрушка для ёлки или декора.', + materials: ['фетр', 'нитки'], + quantity: 20, + priceCents: 29000, + imageUrl: 'https://images.unsplash.com/photo-1543589077-47d81606c1bf?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Панно “Горы”', + slug: 'panno-gory', + shortDescription: 'Минималистичное панно на стену.', + description: 'Деревянная основа, роспись акрилом.', + materials: ['дерево', 'акрил'], + quantity: 2, + priceCents: 129000, + imageUrl: 'https://images.unsplash.com/photo-1452860606245-08befc0ff44b?w=600&q=80', + published: true, + categoryId: gifts.id, + }, + { + title: 'Мягкий котик (под заказ)', + slug: 'myagkiy-kotik-pod-zakaz', + shortDescription: 'Можно выбрать цвет и имя.', + description: 'Делаем под заказ: выберите цвет и вышивку имени.', + materials: ['хлопок', 'синтепон'], + quantity: 0, + inStock: false, + leadTimeDays: 7, + priceCents: 229000, + imageUrl: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=600&q=80', + published: true, + categoryId: toys.id, + }, + ] + + for (const p of more) { + await prisma.product.upsert({ + where: { slug: p.slug }, + update: {}, + create: { + title: p.title, + slug: p.slug, + shortDescription: p.shortDescription, + description: p.description, + materials: JSON.stringify(p.materials), + quantity: p.quantity, + priceCents: p.priceCents, + imageUrl: p.imageUrl, + published: p.published, + inStock: p.inStock ?? true, + leadTimeDays: p.leadTimeDays ?? null, + categoryId: p.categoryId, + }, + }) + } + console.log('Seed готов:', { toys: toys.slug, gifts: gifts.slug }) } diff --git a/server/src/routes/api.js b/server/src/routes/api.js index c3386e2..8702ed3 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js' import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' +import { hashPassword, normalizeEmail } from '../lib/auth.js' function slugify(input) { return input @@ -17,6 +18,43 @@ function safeExtFromFilename(filename) { return allowed.has(ext) ? ext : null } +function parseMaterialsInput(input) { + if (Array.isArray(input)) { + return input + .map((x) => String(x || '').trim()) + .filter(Boolean) + .slice(0, 30) + } + if (typeof input === 'string') { + const s = input.trim() + if (!s) return [] + // поддержка: "хлопок, дерево" + return s + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + .slice(0, 30) + } + return [] +} + +function materialsFromDb(materials) { + if (Array.isArray(materials)) return materials + try { + const v = JSON.parse(String(materials || '[]')) + return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : [] + } catch { + return [] + } +} + +function mapProductForApi(p) { + return { + ...p, + materials: materialsFromDb(p.materials), + } +} + export async function registerApiRoutes(fastify) { fastify.get('/api/categories', async () => { return prisma.category.findMany({ orderBy: { sort: 'asc' } }) @@ -24,15 +62,70 @@ export async function registerApiRoutes(fastify) { fastify.get('/api/products', async (request) => { const { categorySlug } = request.query + const qRaw = request.query?.q + const q = typeof qRaw === 'string' ? qRaw.trim() : '' + + const sortRaw = request.query?.sort + const sort = typeof sortRaw === 'string' ? sortRaw : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12 + + const priceMinRaw = request.query?.priceMin + const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw) + const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null + + const priceMaxRaw = request.query?.priceMax + const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw) + const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null + const where = { published: true } if (typeof categorySlug === 'string' && categorySlug.length > 0) { where.category = { slug: categorySlug } } - return prisma.product.findMany({ + if (q) { + where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }] + } + const applyPriceFilter = !( + priceMin !== null && + priceMax !== null && + priceMin === 0 && + priceMax === 0 + ) + + if (applyPriceFilter && (priceMin !== null || priceMax !== null)) { + if (priceMin !== null && priceMax !== null && priceMax < priceMin) { + // не молчим: пользователю проще понять, чем получить пустой список + return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' }) + } + where.priceCents = { + ...(priceMin !== null ? { gte: priceMin } : {}), + ...(priceMax !== null ? { lte: priceMax } : {}), + } + } + + const orderBy = + sort === 'price_asc' + ? { priceCents: 'asc' } + : sort === 'price_desc' + ? { priceCents: 'desc' } + : { createdAt: 'desc' } + + const total = await prisma.product.count({ where }) + const items = await prisma.product.findMany({ where, include: { category: true, images: { orderBy: { sort: 'asc' } } }, - orderBy: { createdAt: 'desc' }, + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, }) + + return { items: items.map(mapProductForApi), total, page, pageSize } }) fastify.get('/api/products/:id', async (request, reply) => { @@ -45,7 +138,7 @@ export async function registerApiRoutes(fastify) { reply.code(404).send({ error: 'Товар не найден' }) return } - return product + return mapProductForApi(product) }) // ---- Админ (тот же фронт, другой раздел) ---- @@ -54,10 +147,11 @@ export async function registerApiRoutes(fastify) { '/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async () => { - return prisma.product.findMany({ + const items = await prisma.product.findMany({ include: { category: true, images: { orderBy: { sort: 'asc' } } }, orderBy: { updatedAt: 'desc' }, }) + return items.map(mapProductForApi) }, ) @@ -134,12 +228,25 @@ export async function registerApiRoutes(fastify) { reply.code(409).send({ error: 'Такой slug уже занят' }) return } + + let quantity = null + if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) { + const n = Number(body.quantity) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + quantity = Math.floor(n) + } + const product = await prisma.product.create({ data: { title, slug, shortDescription: body.shortDescription ? String(body.shortDescription) : null, description: body.description ? String(body.description) : null, + quantity, + materials: JSON.stringify(parseMaterialsInput(body.materials)), priceCents: Math.round(priceCents), imageUrl: body.imageUrl ? String(body.imageUrl) : null, published: Boolean(body.published), @@ -158,7 +265,7 @@ export async function registerApiRoutes(fastify) { }, include: { category: true, images: { orderBy: { sort: 'asc' } } }, }) - reply.code(201).send(product) + reply.code(201).send(mapProductForApi(product)) }, ) @@ -194,6 +301,22 @@ export async function registerApiRoutes(fastify) { if (body.description !== undefined) { data.description = body.description ? String(body.description) : null } + if (body.quantity !== undefined) { + const v = body.quantity + if (v === null || v === '') { + data.quantity = null + } else { + const n = Number(v) + if (!Number.isFinite(n) || n < 0) { + reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + return + } + data.quantity = Math.floor(n) + } + } + if (body.materials !== undefined) { + data.materials = JSON.stringify(parseMaterialsInput(body.materials)) + } if (body.priceCents !== undefined) { const p = Number(body.priceCents) if (!Number.isFinite(p) || p < 0) { @@ -251,7 +374,7 @@ export async function registerApiRoutes(fastify) { }, include: { category: true, images: { orderBy: { sort: 'asc' } } }, }) - return product + return mapProductForApi(product) }, ) @@ -299,4 +422,195 @@ export async function registerApiRoutes(fastify) { reply.code(201).send(category) }, ) + + // ---- Админ: пользователи ---- + + fastify.get( + '/api/admin/users', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const qRaw = request.query?.q + const q = typeof qRaw === 'string' ? qRaw.trim() : '' + + const pageRaw = request.query?.page + const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw) + const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1 + + const pageSizeRaw = request.query?.pageSize + const pageSizeParsed = + typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw) + const pageSize = + Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20 + + if (pageSize > 100) { + reply.code(400).send({ error: 'pageSize должен быть ≤ 100' }) + return + } + + const where = q + ? { + OR: [ + { email: { contains: q } }, + { name: { contains: q } }, + ], + } + : undefined + + const total = await prisma.user.count({ where }) + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + email: true, + name: true, + passwordHash: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }) + const items = users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + hasPassword: Boolean(u.passwordHash), + createdAt: u.createdAt, + updatedAt: u.updatedAt, + })) + + return { items, total, page, pageSize } + }, + ) + + fastify.post( + '/api/admin/users', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const body = request.body ?? {} + + const email = normalizeEmail(body.email) + if (!email || !email.includes('@')) { + reply.code(400).send({ error: 'Некорректная почта' }) + return + } + + const nameRaw = body.name + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (name !== null && name.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + + const password = body.password ? String(body.password) : '' + if (password && password.length < 8) { + reply.code(400).send({ error: 'Пароль минимум 8 символов' }) + return + } + + const exists = await prisma.user.findUnique({ where: { email } }) + if (exists) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + + const passwordHash = password ? await hashPassword(password) : null + const user = await prisma.user.create({ + data: { + email, + name: name && name.length ? name : null, + passwordHash: passwordHash ?? undefined, + }, + }) + + reply.code(201).send({ + id: user.id, + email: user.email, + name: user.name, + hasPassword: Boolean(user.passwordHash), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }) + }, + ) + + fastify.patch( + '/api/admin/users/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const body = request.body ?? {} + + const existing = await prisma.user.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Пользователь не найден' }) + return + } + + const data = {} + + if (body.email !== undefined) { + const email = normalizeEmail(body.email) + if (!email || !email.includes('@')) { + reply.code(400).send({ error: 'Некорректная почта' }) + return + } + if (email !== existing.email) { + const clash = await prisma.user.findUnique({ where: { email } }) + if (clash) { + reply.code(409).send({ error: 'Почта уже занята' }) + return + } + data.email = email + } + } + + if (body.name !== undefined) { + const nameRaw = body.name + const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (name !== null && name.length > 40) { + reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + return + } + data.name = name && name.length ? name : null + } + + if (body.password !== undefined) { + const password = body.password ? String(body.password) : '' + if (password) { + if (password.length < 8) { + reply.code(400).send({ error: 'Пароль минимум 8 символов' }) + return + } + data.passwordHash = await hashPassword(password) + } + } + + const user = await prisma.user.update({ where: { id }, data }) + return { + id: user.id, + email: user.email, + name: user.name, + hasPassword: Boolean(user.passwordHash), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } + }, + ) + + fastify.delete( + '/api/admin/users/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + try { + await prisma.user.delete({ where: { id } }) + reply.code(204).send() + } catch { + reply.code(404).send({ error: 'Пользователь не найден' }) + } + }, + ) }