initial: client

This commit is contained in:
Shop Deploy
2026-06-11 13:48:08 +05:00
commit a36f96c290
271 changed files with 28009 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export type { FormState } from './model/types'
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}