initial: client
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export type { FormState } from './model/types'
|
||||
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidProductPriceRub, isValidProductQuantity } from '../use-product-form-helpers'
|
||||
|
||||
describe('product form helpers', () => {
|
||||
it('принимает корректную цену в рублях', () => {
|
||||
expect(isValidProductPriceRub('1200')).toBe(true)
|
||||
expect(isValidProductPriceRub('1200,50')).toBe(true)
|
||||
})
|
||||
|
||||
it('отклоняет пустую или некорректную цену', () => {
|
||||
expect(isValidProductPriceRub('')).toBe(false)
|
||||
expect(isValidProductPriceRub('0')).toBe(false)
|
||||
expect(isValidProductPriceRub('1200,555')).toBe(false)
|
||||
expect(isValidProductPriceRub('1,2,3')).toBe(false)
|
||||
})
|
||||
|
||||
it('принимает только целое количество от 0 до 10', () => {
|
||||
expect(isValidProductQuantity('0')).toBe(true)
|
||||
expect(isValidProductQuantity('10')).toBe(true)
|
||||
expect(isValidProductQuantity('')).toBe(false)
|
||||
expect(isValidProductQuantity('11')).toBe(false)
|
||||
expect(isValidProductQuantity('1.5')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '0',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
export function isValidProductPriceRub(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!/^\d+([,.]\d{1,2})?$/.test(trimmed)) return false
|
||||
|
||||
const priceRub = Number(trimmed.replace(',', '.'))
|
||||
return Number.isFinite(priceRub) && priceRub > 0 && priceRub <= 10_000
|
||||
}
|
||||
|
||||
export function isValidProductQuantity(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
|
||||
const quantity = Number(trimmed)
|
||||
return Number.isInteger(quantity) && quantity >= 0 && quantity <= 10
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
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 FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
|
||||
export function GalleryImagePicker({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentUrls,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (urls: string[]) => void
|
||||
currentUrls: string[]
|
||||
}) {
|
||||
const [selectedUrls, setSelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
const [hideUsed, setHideUsed] = useState(false)
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const toggleUrl = (url: string) => {
|
||||
setSelectedUrls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(url)) {
|
||||
next.delete(url)
|
||||
} else {
|
||||
next.add(url)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onSelect([...selectedUrls])
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedUrls(new Set())
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryQuery.isError && <Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>}
|
||||
{galleryQuery.data?.items.length === 0 && !galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
{galleryQuery.data &&
|
||||
galleryQuery.data.items.length > 0 &&
|
||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее пока нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hideUsed} onChange={(_, v) => setHideUsed(v)} />}
|
||||
label="Скрыть уже прикреплённые"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{galleryQuery.data &&
|
||||
galleryQuery.data.items.length > 0 &&
|
||||
galleryQuery.data.items.filter((i) => i.isResized).length === 0 &&
|
||||
!galleryQuery.isLoading && (
|
||||
<Typography color="text.secondary">
|
||||
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.filter((item) => !hideUsed || !item.inUse)
|
||||
.map((item) => {
|
||||
const alreadyInCard = currentUrls.includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || selectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
widths={[320, 640]}
|
||||
sizes="120px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleApply}
|
||||
disabled={![...selectedUrls].some((u) => !currentUrls.includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import FormHelperText from '@mui/material/FormHelperText'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
|
||||
import type { FormState } from '../model/types'
|
||||
|
||||
export function ProductFormFields({
|
||||
form,
|
||||
categories,
|
||||
onRemoveImage,
|
||||
onPickFromGallery,
|
||||
}: {
|
||||
form: UseFormReturn<FormState>
|
||||
categories: Category[]
|
||||
onRemoveImage: (url: string) => void
|
||||
onPickFromGallery: () => void
|
||||
}) {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="title"
|
||||
rules={{ required: 'Укажите название' }}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Название"
|
||||
fullWidth
|
||||
required
|
||||
{...field}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug (URL)"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Описание
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>Стилизованный текст: жирный, курсив, список</FormHelperText>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<RichTextMessageEditor value={field.value} onChange={field.onChange} placeholder="Описание товара" />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="materials"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Материалы"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="quantity"
|
||||
rules={{
|
||||
required: 'Укажите количество',
|
||||
validate: (v) => isValidProductQuantity(v) || 'Целое число от 0 до 10',
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="priceRub"
|
||||
rules={{
|
||||
required: 'Укажите цену',
|
||||
validate: (v) => isValidProductPriceRub(v) || 'Цена должна быть от 0,01 до 10 000 ₽, максимум 2 знака',
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
label="Цена, ₽"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/[^0-9.,]/g, '')
|
||||
field.onChange(v)
|
||||
}}
|
||||
helperText={fieldState.error?.message}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (из галереи)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся
|
||||
на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button variant="outlined" onClick={onPickFromGallery}>
|
||||
Из галереи
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{form.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{form.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
widths={[320, 640]}
|
||||
sizes="80px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => onRemoveImage(url)}
|
||||
aria-label="Убрать из карточки"
|
||||
title="Убрать из карточки"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 0,
|
||||
px: 0.75,
|
||||
py: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
rules={{ required: 'Выберите категорию' }}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!field.value}>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
{categories.map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user