base commit
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { MainLayout } from '@/app/layout/MainLayout'
|
import { MainLayout } from '@/app/layout/MainLayout'
|
||||||
import { AppProviders } from '@/app/providers/AppProviders'
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
import { AdminPage } from '@/pages/admin'
|
import { AdminPage } from '@/pages/admin'
|
||||||
|
import { AdminUsersPage } from '@/pages/admin-users'
|
||||||
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'
|
||||||
@@ -15,6 +16,7 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
<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="/products/:id" element={<ProductPage />} />
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
import type { Category, Product } from '@/entities/product/model/types'
|
import type { Category, Product } from '@/entities/product/model/types'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
export async function fetchPublicProducts(categorySlug?: string): Promise<Product[]> {
|
export type PublicProductsResponse = {
|
||||||
const { data } = await apiClient.get<Product[]>('products', {
|
items: Product[]
|
||||||
params: categorySlug ? { categorySlug } : undefined,
|
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<PublicProductsResponse> {
|
||||||
|
const { data } = await apiClient.get<PublicProductsResponse>('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
|
return data
|
||||||
}
|
}
|
||||||
@@ -32,6 +55,8 @@ export async function createProduct(
|
|||||||
slug?: string
|
slug?: string
|
||||||
shortDescription?: string | null
|
shortDescription?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
quantity?: number | null
|
||||||
|
materials?: string[]
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl?: string | null
|
imageUrl?: string | null
|
||||||
imageUrls?: string[]
|
imageUrls?: string[]
|
||||||
@@ -55,6 +80,8 @@ export async function updateProduct(
|
|||||||
slug: string
|
slug: string
|
||||||
shortDescription: string | null
|
shortDescription: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
|
quantity: number | null
|
||||||
|
materials: string[]
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
imageUrls: string[]
|
imageUrls: string[]
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type Product = {
|
|||||||
slug: string
|
slug: string
|
||||||
shortDescription: string | null
|
shortDescription: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
|
quantity?: number | null
|
||||||
|
materials?: string[]
|
||||||
priceCents: number
|
priceCents: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
||||||
|
|||||||
@@ -14,16 +14,22 @@ 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; mediaHeight?: number }
|
||||||
|
|
||||||
export function ProductCard({ product }: Props) {
|
export function ProductCard({ product, mediaHeight = 200 }: Props) {
|
||||||
const swiperRef = useRef<SwiperType | null>(null)
|
const swiperRef = useRef<SwiperType | null>(null)
|
||||||
const imageUrls = useMemo(() => {
|
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] : []
|
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
|
||||||
return urls
|
return urls
|
||||||
}, [product.images, product.imageUrl])
|
}, [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<HTMLElement>) => {
|
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
if (!swiperRef.current) return
|
if (!swiperRef.current) return
|
||||||
if (imageUrls.length <= 1) return
|
if (imageUrls.length <= 1) return
|
||||||
@@ -63,13 +69,13 @@ export function ProductCard({ product }: Props) {
|
|||||||
sx={{ display: 'block' }}
|
sx={{ display: 'block' }}
|
||||||
>
|
>
|
||||||
{imageUrls.length ? (
|
{imageUrls.length ? (
|
||||||
<Box onMouseMove={onMouseMove} sx={{ height: 200 }}>
|
<Box onMouseMove={onMouseMove} sx={{ height: mediaHeight }}>
|
||||||
<Swiper
|
<Swiper
|
||||||
onSwiper={(s) => {
|
onSwiper={(s) => {
|
||||||
swiperRef.current = s
|
swiperRef.current = s
|
||||||
}}
|
}}
|
||||||
allowTouchMove={false}
|
allowTouchMove={false}
|
||||||
style={{ width: '100%', height: 200 }}
|
style={{ width: '100%', height: mediaHeight }}
|
||||||
>
|
>
|
||||||
{imageUrls.map((url) => (
|
{imageUrls.map((url) => (
|
||||||
<SwiperSlide key={url}>
|
<SwiperSlide key={url}>
|
||||||
@@ -80,7 +86,7 @@ export function ProductCard({ product }: Props) {
|
|||||||
className="product-card__media"
|
className="product-card__media"
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 200,
|
height: mediaHeight,
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
transition: 'transform 240ms ease',
|
transition: 'transform 240ms ease',
|
||||||
@@ -118,6 +124,14 @@ export function ProductCard({ product }: Props) {
|
|||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{(product.materials?.length ?? 0) > 0 && (
|
||||||
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||||
|
{materials.map((m) => (
|
||||||
|
<Chip key={m} label={m} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
{moreMaterials > 0 && <Chip label={`+${moreMaterials}`} size="small" variant="outlined" />}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AdminUser } from '@/entities/user/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type AdminUsersListResponse = {
|
||||||
|
items: AdminUser[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminUsers(
|
||||||
|
token: string,
|
||||||
|
params?: { q?: string; page?: number; pageSize?: number },
|
||||||
|
): Promise<AdminUsersListResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', {
|
||||||
|
params,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(
|
||||||
|
token: string,
|
||||||
|
body: { email: string; name?: string | null; password?: string },
|
||||||
|
): Promise<AdminUser> {
|
||||||
|
const { data } = await apiClient.post<AdminUser>('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<AdminUser> {
|
||||||
|
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(token: string, id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`admin/users/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type AdminUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
hasPassword: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AdminUsersPage } from './ui/AdminUsersPage'
|
||||||
@@ -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<string | null>(() => getAdminToken())
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||||
|
const [qInput, setQInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(20)
|
||||||
|
|
||||||
|
const tokenForm = useForm<TokenFormState>({
|
||||||
|
defaultValues: { token: '' },
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
const userForm = useForm<UserFormState>({
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{ mb: 2, alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Админка — пользователи</Typography>
|
||||||
|
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||||
|
Товары
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Введите API-токен из{' '}
|
||||||
|
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
.env
|
||||||
|
</Typography>{' '}
|
||||||
|
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Controller
|
||||||
|
control={tokenForm.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label="Токен (Bearer)"
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
placeholder={token ? '••••••••' : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
|
||||||
|
|
||||||
|
{token && (
|
||||||
|
<>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||||
|
<Button variant="contained" onClick={openCreate}>
|
||||||
|
Новый пользователь
|
||||||
|
</Button>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Поиск (email/имя)"
|
||||||
|
value={qInput}
|
||||||
|
onChange={(e) => setQInput(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{usersQuery.isError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
Ошибка загрузки. Проверьте токен и что сервер запущен.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutationError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{(mutationError as Error).message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Почта</TableCell>
|
||||||
|
<TableCell>Имя</TableCell>
|
||||||
|
<TableCell>Пароль</TableCell>
|
||||||
|
<TableCell>Создан</TableCell>
|
||||||
|
<TableCell>Обновлён</TableCell>
|
||||||
|
<TableCell align="right">Действия</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<TableRow key={u.id} hover>
|
||||||
|
<TableCell>{u.email}</TableCell>
|
||||||
|
<TableCell>{u.name ?? '—'}</TableCell>
|
||||||
|
<TableCell>{u.hasPassword ? 'задан' : 'нет'}</TableCell>
|
||||||
|
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||||||
|
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" onClick={() => openEdit(u)}>
|
||||||
|
Изменить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirm(`Удалить пользователя ${u.email}?`)) return
|
||||||
|
deleteMut.mutate(u.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && !usersQuery.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
|
||||||
|
Пользователей пока нет.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={total}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, p) => setPage(p)}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setRowsPerPage(Number(e.target.value))
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={userForm.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={userForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={userForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
|
||||||
|
type="password"
|
||||||
|
fullWidth
|
||||||
|
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||||
|
disabled={isSaveDisabled}
|
||||||
|
>
|
||||||
|
{editing ? 'Сохранить' : 'Создать'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import TextField from '@mui/material/TextField'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
createProduct,
|
createProduct,
|
||||||
@@ -40,6 +41,8 @@ type FormState = {
|
|||||||
slug: string
|
slug: string
|
||||||
shortDescription: string
|
shortDescription: string
|
||||||
description: string
|
description: string
|
||||||
|
quantity: string
|
||||||
|
materials: string
|
||||||
priceRub: string
|
priceRub: string
|
||||||
imageUrls: string[]
|
imageUrls: string[]
|
||||||
published: boolean
|
published: boolean
|
||||||
@@ -53,6 +56,8 @@ const emptyForm = (): FormState => ({
|
|||||||
slug: '',
|
slug: '',
|
||||||
shortDescription: '',
|
shortDescription: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
quantity: '',
|
||||||
|
materials: '',
|
||||||
priceRub: '',
|
priceRub: '',
|
||||||
imageUrls: [],
|
imageUrls: [],
|
||||||
published: true,
|
published: true,
|
||||||
@@ -131,6 +136,8 @@ export function AdminPage() {
|
|||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
shortDescription: p.shortDescription ?? '',
|
shortDescription: p.shortDescription ?? '',
|
||||||
description: p.description ?? '',
|
description: p.description ?? '',
|
||||||
|
quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity),
|
||||||
|
materials: (p.materials ?? []).join(', '),
|
||||||
priceRub: String(p.priceCents / 100),
|
priceRub: String(p.priceCents / 100),
|
||||||
imageUrls: urls,
|
imageUrls: urls,
|
||||||
published: p.published,
|
published: p.published,
|
||||||
@@ -152,11 +159,19 @@ export function AdminPage() {
|
|||||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
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!, {
|
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,
|
shortDescription: form.shortDescription.trim() || null,
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
|
quantity: qty === null ? null : Math.floor(qty),
|
||||||
|
materials,
|
||||||
priceCents,
|
priceCents,
|
||||||
imageUrls: form.imageUrls,
|
imageUrls: form.imageUrls,
|
||||||
published: form.published,
|
published: form.published,
|
||||||
@@ -183,11 +198,19 @@ export function AdminPage() {
|
|||||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
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, {
|
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,
|
shortDescription: form.shortDescription.trim() || null,
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
|
quantity: qty === null ? null : Math.floor(qty),
|
||||||
|
materials,
|
||||||
priceCents,
|
priceCents,
|
||||||
imageUrls: form.imageUrls,
|
imageUrls: form.imageUrls,
|
||||||
published: form.published,
|
published: form.published,
|
||||||
@@ -305,6 +328,9 @@ export function AdminPage() {
|
|||||||
<Button variant="outlined" onClick={() => setCatOpen(true)}>
|
<Button variant="outlined" onClick={() => setCatOpen(true)}>
|
||||||
Новая категория
|
Новая категория
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button component={RouterLink} to="/admin/users" variant="outlined">
|
||||||
|
Пользователи
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{productsQuery.isError && (
|
{productsQuery.isError && (
|
||||||
@@ -384,6 +410,31 @@ export function AdminPage() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
control={productForm.control}
|
||||||
|
name="materials"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label="Материалы"
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={productForm.control}
|
||||||
|
name="quantity"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
label="Количество"
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
inputMode="numeric"
|
||||||
|
helperText="Оставьте пустым, если не хотите вести учёт"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="priceRub"
|
name="priceRub"
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Collapse from '@mui/material/Collapse'
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import Grid from '@mui/material/Grid'
|
import Grid from '@mui/material/Grid'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
|
import Pagination from '@mui/material/Pagination'
|
||||||
import Select from '@mui/material/Select'
|
import Select from '@mui/material/Select'
|
||||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||||
import Skeleton from '@mui/material/Skeleton'
|
import Skeleton from '@mui/material/Skeleton'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton'
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
||||||
@@ -15,19 +22,79 @@ import { ProductCard } from '@/entities/product/ui/ProductCard'
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||||
|
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({
|
const categoriesQuery = useQuery({
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: () => fetchCategories(),
|
queryFn: () => fetchCategories(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
setQ(qInput.trim())
|
||||||
|
setPage(1)
|
||||||
|
}, 250)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [qInput])
|
||||||
|
|
||||||
const productsQuery = useQuery({
|
const productsQuery = useQuery({
|
||||||
queryKey: ['products', 'public', categorySlug || 'all'],
|
queryKey: [
|
||||||
queryFn: () => fetchPublicProducts(categorySlug || undefined),
|
'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<string>) => {
|
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
|
||||||
setCategorySlug(e.target.value)
|
setCategorySlug(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = (e: SelectChangeEvent<string>) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '' || v === 'price_asc' || v === 'price_desc') {
|
||||||
|
setSort(v)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
|
||||||
|
const n = Number(e.target.value)
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
setPageSize(n)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(
|
||||||
@@ -36,6 +103,11 @@ export function HomePage() {
|
|||||||
[categorySlug, categoriesQuery.data],
|
[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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
@@ -45,25 +117,146 @@ export function HomePage() {
|
|||||||
Игрушки, сувениры и другие изделия ручной работы.
|
Игрушки, сувениры и другие изделия ручной работы.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControl sx={{ minWidth: 220, mb: 3 }} size="small">
|
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
<Stack
|
||||||
<Select<string>
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
labelId="category-filter-label"
|
spacing={2}
|
||||||
label="Категория"
|
alignItems={{ md: 'center' }}
|
||||||
value={categorySlug}
|
sx={{ flexWrap: { md: 'wrap' } }}
|
||||||
onChange={handleCategoryChange}
|
|
||||||
disabled={categoriesQuery.isLoading}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||||
<em>Все</em>
|
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||||
</MenuItem>
|
<Select<string>
|
||||||
{(categoriesQuery.data ?? []).map((c) => (
|
labelId="category-filter-label"
|
||||||
<MenuItem key={c.id} value={c.slug}>
|
label="Категория"
|
||||||
{c.name}
|
value={categorySlug}
|
||||||
</MenuItem>
|
onChange={handleCategoryChange}
|
||||||
))}
|
disabled={categoriesQuery.isLoading}
|
||||||
</Select>
|
>
|
||||||
</FormControl>
|
<MenuItem value="">
|
||||||
|
<em>Все</em>
|
||||||
|
</MenuItem>
|
||||||
|
{(categoriesQuery.data ?? []).map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Поиск"
|
||||||
|
value={qInput}
|
||||||
|
onChange={(e) => setQInput(e.target.value)}
|
||||||
|
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1.5}
|
||||||
|
alignItems={{ sm: 'center' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||||
|
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setCategorySlug('')
|
||||||
|
setQInput('')
|
||||||
|
setSort('')
|
||||||
|
setPriceMinRub('')
|
||||||
|
setPriceMaxRub('')
|
||||||
|
setPageSize(12)
|
||||||
|
setCardScale(90)
|
||||||
|
setMoreOpen(false)
|
||||||
|
}}
|
||||||
|
sx={{ alignSelf: { xs: 'flex-start' } }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={moreOpen} unmountOnExit>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }}
|
||||||
|
spacing={2}
|
||||||
|
alignItems={{ md: 'center' }}
|
||||||
|
sx={{ mt: 2, flexWrap: { md: 'wrap' } }}
|
||||||
|
>
|
||||||
|
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||||
|
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||||
|
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Сначала новые</em>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
|
||||||
|
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Цена от, ₽"
|
||||||
|
value={priceMinRub}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPriceMinRub(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
sx={{ width: { xs: '100%', md: 180 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Цена до, ₽"
|
||||||
|
value={priceMaxRub}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPriceMaxRub(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
sx={{ width: { xs: '100%', md: 180 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||||
|
<InputLabel id="page-size-label">На странице</InputLabel>
|
||||||
|
<Select<string>
|
||||||
|
labelId="page-size-label"
|
||||||
|
label="На странице"
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={handlePageSizeChange}
|
||||||
|
>
|
||||||
|
{[6, 12, 18, 24].map((n) => (
|
||||||
|
<MenuItem key={n} value={String(n)}>
|
||||||
|
{n}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 260 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||||
|
Масштаб карточек
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={cardScale}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value={70}>S</ToggleButton>
|
||||||
|
<ToggleButton value={90}>M</ToggleButton>
|
||||||
|
<ToggleButton value={110}>L</ToggleButton>
|
||||||
|
<ToggleButton value={130}>XL</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{productsQuery.isLoading && (
|
{productsQuery.isLoading && (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
@@ -79,18 +272,32 @@ export function HomePage() {
|
|||||||
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
|
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{productsQuery.isSuccess && productsQuery.data.length === 0 && (
|
{productsQuery.isSuccess && products.length === 0 && (
|
||||||
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
|
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{productsQuery.isSuccess && productsQuery.data.length > 0 && (
|
{productsQuery.isSuccess && products.length > 0 && (
|
||||||
<Grid container spacing={2}>
|
<>
|
||||||
{productsQuery.data.map((p) => (
|
<Grid container spacing={2}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
{products.map((p) => (
|
||||||
<ProductCard product={p} />
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||||
</Grid>
|
<ProductCard product={p} mediaHeight={mediaHeight} />
|
||||||
))}
|
</Grid>
|
||||||
</Grid>
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
count={totalPages}
|
||||||
|
onChange={(_, v) => setPage(v)}
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
showFirstButton
|
||||||
|
showLastButton
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { ProductPage } from './ui/ProductPage'
|
export { ProductPage } from './ui/ProductPage'
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export function ProductPage() {
|
|||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
const p = productQuery.data
|
const p = productQuery.data
|
||||||
if (!p) return []
|
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] : []
|
const urls = fromImages.length ? fromImages : p.imageUrl ? [p.imageUrl] : []
|
||||||
return urls
|
return urls
|
||||||
}, [productQuery.data])
|
}, [productQuery.data])
|
||||||
@@ -113,6 +116,19 @@ export function ProductPage() {
|
|||||||
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
|
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{(p.materials?.length ?? 0) > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Материалы
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{(p.materials ?? []).map((m) => (
|
||||||
|
<Chip key={m} label={m} variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="h4" component="h1">
|
<Typography variant="h4" component="h1">
|
||||||
{p.title}
|
{p.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -159,4 +175,3 @@ export function ProductPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,6 +22,10 @@ model Product {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
shortDescription String?
|
shortDescription String?
|
||||||
description String?
|
description String?
|
||||||
|
/// Количество на складе (если null — не ведём учёт)
|
||||||
|
quantity Int?
|
||||||
|
/// Материалы (список, например: ["хлопок","дерево"])
|
||||||
|
materials String @default("[]")
|
||||||
/// Цена в копейках (целое число, без дробной части)
|
/// Цена в копейках (целое число, без дробной части)
|
||||||
priceCents Int
|
priceCents Int
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ async function main() {
|
|||||||
create: {
|
create: {
|
||||||
title: 'Мягкая сова',
|
title: 'Мягкая сова',
|
||||||
slug: 'myagkaya-sova',
|
slug: 'myagkaya-sova',
|
||||||
|
shortDescription: 'Мягкая игрушка ручной работы.',
|
||||||
description: 'Ручная работа, хлопок и синтепон.',
|
description: 'Ручная работа, хлопок и синтепон.',
|
||||||
|
materials: JSON.stringify(['хлопок', 'синтепон']),
|
||||||
|
quantity: 3,
|
||||||
priceCents: 189000,
|
priceCents: 189000,
|
||||||
imageUrl:
|
imageUrl:
|
||||||
'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600&q=80',
|
'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600&q=80',
|
||||||
@@ -35,7 +38,10 @@ async function main() {
|
|||||||
create: {
|
create: {
|
||||||
title: 'Колокольчик керамический',
|
title: 'Колокольчик керамический',
|
||||||
slug: 'suvenir-kolokolchik',
|
slug: 'suvenir-kolokolchik',
|
||||||
|
shortDescription: 'Керамика с ручной росписью.',
|
||||||
description: 'Глазурь, ручная роспись.',
|
description: 'Глазурь, ручная роспись.',
|
||||||
|
materials: JSON.stringify(['керамика', 'глазурь']),
|
||||||
|
quantity: 5,
|
||||||
priceCents: 45000,
|
priceCents: 45000,
|
||||||
imageUrl:
|
imageUrl:
|
||||||
'https://images.unsplash.com/photo-1513519245088-0e12902e5a38?w=600&q=80',
|
'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 })
|
console.log('Seed готов:', { toys: toys.slug, gifts: gifts.slug })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+320
-6
@@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js'
|
|||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { hashPassword, normalizeEmail } from '../lib/auth.js'
|
||||||
|
|
||||||
function slugify(input) {
|
function slugify(input) {
|
||||||
return input
|
return input
|
||||||
@@ -17,6 +18,43 @@ function safeExtFromFilename(filename) {
|
|||||||
return allowed.has(ext) ? ext : null
|
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) {
|
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' } })
|
||||||
@@ -24,15 +62,70 @@ export async function registerApiRoutes(fastify) {
|
|||||||
|
|
||||||
fastify.get('/api/products', async (request) => {
|
fastify.get('/api/products', async (request) => {
|
||||||
const { categorySlug } = request.query
|
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 }
|
const where = { published: true }
|
||||||
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
||||||
where.category = { slug: categorySlug }
|
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,
|
where,
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
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) => {
|
fastify.get('/api/products/:id', async (request, reply) => {
|
||||||
@@ -45,7 +138,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
reply.code(404).send({ error: 'Товар не найден' })
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return product
|
return mapProductForApi(product)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Админ (тот же фронт, другой раздел) ----
|
// ---- Админ (тот же фронт, другой раздел) ----
|
||||||
@@ -54,10 +147,11 @@ export async function registerApiRoutes(fastify) {
|
|||||||
'/api/admin/products',
|
'/api/admin/products',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
async () => {
|
async () => {
|
||||||
return prisma.product.findMany({
|
const items = await prisma.product.findMany({
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
return items.map(mapProductForApi)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,12 +228,25 @@ export async function registerApiRoutes(fastify) {
|
|||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
return
|
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({
|
const product = await prisma.product.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
||||||
description: body.description ? String(body.description) : null,
|
description: body.description ? String(body.description) : null,
|
||||||
|
quantity,
|
||||||
|
materials: JSON.stringify(parseMaterialsInput(body.materials)),
|
||||||
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),
|
||||||
@@ -158,7 +265,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
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) {
|
if (body.description !== undefined) {
|
||||||
data.description = body.description ? String(body.description) : null
|
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) {
|
if (body.priceCents !== undefined) {
|
||||||
const p = Number(body.priceCents)
|
const p = Number(body.priceCents)
|
||||||
if (!Number.isFinite(p) || p < 0) {
|
if (!Number.isFinite(p) || p < 0) {
|
||||||
@@ -251,7 +374,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
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)
|
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: 'Пользователь не найден' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user