feat: добавлено автозаполнение полей wishlist из ссылки (v3.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлен эндпоинт /api/wishlist/metadata для извлечения метаданных из URL - Реализовано извлечение Open Graph тегов (title, image, description) - Добавлена кнопка Pull для ручной загрузки информации из ссылки - Автоматическое заполнение полей: название, цена, картинка - Обновлена версия до 3.9.0
This commit is contained in:
682
play-life-web/src/components/WishlistForm.jsx
Normal file
682
play-life-web/src/components/WishlistForm.jsx
Normal file
@@ -0,0 +1,682 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import Toast from './Toast'
|
||||
import './WishlistForm.css'
|
||||
|
||||
const API_URL = '/api/wishlist'
|
||||
const TASKS_API_URL = '/api/tasks'
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
|
||||
function WishlistForm({ onNavigate, wishlistId }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [link, setLink] = useState('')
|
||||
const [imageUrl, setImageUrl] = useState(null)
|
||||
const [imageFile, setImageFile] = useState(null)
|
||||
const [showCropper, setShowCropper] = useState(false)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
|
||||
const [unlockConditions, setUnlockConditions] = useState([])
|
||||
const [showConditionForm, setShowConditionForm] = useState(false)
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [projects, setProjects] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [toastMessage, setToastMessage] = useState(null)
|
||||
const [loadingWishlist, setLoadingWishlist] = useState(false)
|
||||
const [fetchingMetadata, setFetchingMetadata] = useState(false)
|
||||
|
||||
// Загрузка задач и проектов
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Загружаем задачи
|
||||
const tasksResponse = await authFetch(TASKS_API_URL)
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json()
|
||||
setTasks(Array.isArray(tasksData) ? tasksData : [])
|
||||
}
|
||||
|
||||
// Загружаем проекты
|
||||
const projectsResponse = await authFetch(PROJECTS_API_URL)
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json()
|
||||
setProjects(Array.isArray(projectsData) ? projectsData : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Загрузка желания при редактировании
|
||||
useEffect(() => {
|
||||
if (wishlistId !== undefined && wishlistId !== null && tasks.length > 0 && projects.length > 0) {
|
||||
loadWishlist()
|
||||
} else if (wishlistId === undefined || wishlistId === null) {
|
||||
resetForm()
|
||||
}
|
||||
}, [wishlistId, tasks, projects])
|
||||
|
||||
const loadWishlist = async () => {
|
||||
setLoadingWishlist(true)
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/${wishlistId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки желания')
|
||||
}
|
||||
const data = await response.json()
|
||||
setName(data.name || '')
|
||||
setPrice(data.price ? String(data.price) : '')
|
||||
setLink(data.link || '')
|
||||
setImageUrl(data.image_url || null)
|
||||
if (data.unlock_conditions) {
|
||||
setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({
|
||||
type: cond.type,
|
||||
task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null,
|
||||
project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null,
|
||||
required_points: cond.required_points || null,
|
||||
period_type: cond.period_type || null,
|
||||
display_order: idx,
|
||||
})))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoadingWishlist(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setPrice('')
|
||||
setLink('')
|
||||
setImageUrl(null)
|
||||
setImageFile(null)
|
||||
setUnlockConditions([])
|
||||
setError('')
|
||||
}
|
||||
|
||||
// Функция для извлечения метаданных из ссылки (по нажатию кнопки)
|
||||
const fetchLinkMetadata = useCallback(async () => {
|
||||
if (!link || !link.trim()) {
|
||||
setToastMessage({ text: 'Введите ссылку', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем валидность URL
|
||||
try {
|
||||
new URL(link)
|
||||
} catch {
|
||||
setToastMessage({ text: 'Некорректная ссылка', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
setFetchingMetadata(true)
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: link.trim() }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const metadata = await response.json()
|
||||
let loaded = false
|
||||
|
||||
// Заполняем название только если поле пустое
|
||||
if (metadata.title && !name) {
|
||||
setName(metadata.title)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// Заполняем цену только если поле пустое
|
||||
if (metadata.price && !price) {
|
||||
setPrice(String(metadata.price))
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// Загружаем изображение только если нет текущего
|
||||
if (metadata.image && !imageUrl) {
|
||||
try {
|
||||
// Загружаем изображение напрямую
|
||||
const imgResponse = await fetch(metadata.image)
|
||||
if (imgResponse.ok) {
|
||||
const blob = await imgResponse.blob()
|
||||
// Проверяем размер (максимум 5MB)
|
||||
if (blob.size <= 5 * 1024 * 1024 && blob.type.startsWith('image/')) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setImageUrl(reader.result)
|
||||
setImageFile(blob)
|
||||
setShowCropper(true)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
} catch (imgErr) {
|
||||
console.error('Error loading image from URL:', imgErr)
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded) {
|
||||
setToastMessage({ text: 'Информация загружена из ссылки', type: 'success' })
|
||||
} else {
|
||||
setToastMessage({ text: 'Не удалось найти информацию на странице', type: 'warning' })
|
||||
}
|
||||
} else {
|
||||
setToastMessage({ text: 'Не удалось загрузить информацию', type: 'error' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching metadata:', err)
|
||||
setToastMessage({ text: 'Ошибка при загрузке информации', type: 'error' })
|
||||
} finally {
|
||||
setFetchingMetadata(false)
|
||||
}
|
||||
}, [authFetch, link, name, price, imageUrl])
|
||||
|
||||
const handleImageSelect = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setToastMessage({ text: 'Файл слишком большой (максимум 5MB)', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setImageFile(file)
|
||||
setImageUrl(reader.result)
|
||||
setShowCropper(true)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const onCropComplete = (croppedArea, croppedAreaPixels) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}
|
||||
|
||||
const createImage = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', (error) => reject(error))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
const getCroppedImg = async (imageSrc, pixelCrop) => {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height
|
||||
)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/jpeg', 0.95)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCropSave = async () => {
|
||||
if (!imageUrl || !croppedAreaPixels) return
|
||||
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(imageUrl, croppedAreaPixels)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setImageUrl(reader.result)
|
||||
setImageFile(croppedImage)
|
||||
setShowCropper(false)
|
||||
}
|
||||
reader.readAsDataURL(croppedImage)
|
||||
} catch (err) {
|
||||
setToastMessage({ text: 'Ошибка при обрезке изображения', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setShowConditionForm(true)
|
||||
}
|
||||
|
||||
const handleConditionSubmit = (condition) => {
|
||||
setUnlockConditions([...unlockConditions, { ...condition, display_order: unlockConditions.length }])
|
||||
setShowConditionForm(false)
|
||||
}
|
||||
|
||||
const handleRemoveCondition = (index) => {
|
||||
setUnlockConditions(unlockConditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Название обязательно')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
price: price ? parseFloat(price) : null,
|
||||
link: link.trim() || null,
|
||||
unlock_conditions: unlockConditions.map(cond => ({
|
||||
type: cond.type,
|
||||
task_id: cond.type === 'task_completion' ? cond.task_id : null,
|
||||
project_id: cond.type === 'project_points' ? cond.project_id : null,
|
||||
required_points: cond.type === 'project_points' ? parseFloat(cond.required_points) : null,
|
||||
period_type: cond.type === 'project_points' ? cond.period_type : null,
|
||||
})),
|
||||
}
|
||||
|
||||
const url = wishlistId ? `${API_URL}/${wishlistId}` : API_URL
|
||||
const method = wishlistId ? 'PUT' : 'POST'
|
||||
|
||||
const response = await authFetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Ошибка при сохранении'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
errorMessage = errorData.message || errorData.error || errorMessage
|
||||
} catch (e) {
|
||||
const text = await response.text().catch(() => '')
|
||||
if (text) errorMessage = text
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const savedItem = await response.json()
|
||||
const itemId = savedItem.id || wishlistId
|
||||
|
||||
// Загружаем картинку если есть
|
||||
if (imageFile && itemId) {
|
||||
const formData = new FormData()
|
||||
formData.append('image', imageFile)
|
||||
|
||||
const imageResponse = await authFetch(`${API_URL}/${itemId}/image`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
setToastMessage({ text: 'Желание сохранено, но ошибка при загрузке картинки', type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
resetForm()
|
||||
onNavigate?.('wishlist')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onNavigate?.('wishlist')
|
||||
}
|
||||
|
||||
if (loadingWishlist) {
|
||||
return (
|
||||
<div className="wishlist-form">
|
||||
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
|
||||
<div className="text-gray-600 font-medium">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wishlist-form">
|
||||
<button className="close-x-button" onClick={handleCancel}>
|
||||
✕
|
||||
</button>
|
||||
<h2>{wishlistId ? 'Редактировать желание' : 'Новое желание'}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="link">Ссылка</label>
|
||||
<div className="link-input-wrapper">
|
||||
<input
|
||||
id="link"
|
||||
type="url"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="form-input"
|
||||
disabled={fetchingMetadata}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pull-metadata-button"
|
||||
onClick={fetchLinkMetadata}
|
||||
disabled={fetchingMetadata || !link.trim()}
|
||||
title="Загрузить информацию из ссылки"
|
||||
>
|
||||
{fetchingMetadata ? (
|
||||
<div className="mini-spinner"></div>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Название *</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="price">Цена</label>
|
||||
<input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Картинка</label>
|
||||
{imageUrl && !showCropper && (
|
||||
<div className="image-preview">
|
||||
<img src={imageUrl} alt="Preview" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageUrl(null)
|
||||
setImageFile(null)
|
||||
}}
|
||||
className="remove-image-button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!imageUrl && (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="form-input"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCropper && (
|
||||
<div className="cropper-modal">
|
||||
<div className="cropper-container">
|
||||
<Cropper
|
||||
image={imageUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={5 / 6}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
<div className="cropper-controls">
|
||||
<label>
|
||||
Масштаб:
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="cropper-actions">
|
||||
<button type="button" onClick={() => setShowCropper(false)}>
|
||||
Отмена
|
||||
</button>
|
||||
<button type="button" onClick={handleCropSave}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Цель</label>
|
||||
{unlockConditions.length > 0 && (
|
||||
<div className="conditions-list">
|
||||
{unlockConditions.map((cond, idx) => (
|
||||
<div key={idx} className="condition-item">
|
||||
<span>
|
||||
{cond.type === 'task_completion'
|
||||
? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}`
|
||||
: `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.period_type ? ` за ${cond.period_type === 'week' ? 'неделю' : cond.period_type === 'month' ? 'месяц' : 'год'}` : ''}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCondition(idx)}
|
||||
className="remove-condition-button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCondition}
|
||||
className="add-condition-button"
|
||||
>
|
||||
Добавить условие
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={loading} className="submit-button">
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{showConditionForm && (
|
||||
<ConditionForm
|
||||
tasks={tasks}
|
||||
projects={projects}
|
||||
onSubmit={handleConditionSubmit}
|
||||
onCancel={() => setShowConditionForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент формы условия разблокировки
|
||||
function ConditionForm({ tasks, projects, onSubmit, onCancel }) {
|
||||
const [type, setType] = useState('task_completion')
|
||||
const [taskId, setTaskId] = useState('')
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [requiredPoints, setRequiredPoints] = useState('')
|
||||
const [periodType, setPeriodType] = useState('')
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // Предотвращаем всплытие события
|
||||
|
||||
// Валидация
|
||||
if (type === 'task_completion' && !taskId) {
|
||||
return
|
||||
}
|
||||
if (type === 'project_points' && (!projectId || !requiredPoints)) {
|
||||
return
|
||||
}
|
||||
|
||||
const condition = {
|
||||
type,
|
||||
task_id: type === 'task_completion' ? parseInt(taskId) : null,
|
||||
project_id: type === 'project_points' ? parseInt(projectId) : null,
|
||||
required_points: type === 'project_points' ? parseFloat(requiredPoints) : null,
|
||||
period_type: type === 'project_points' && periodType ? periodType : null,
|
||||
}
|
||||
onSubmit(condition)
|
||||
// Сброс формы
|
||||
setType('task_completion')
|
||||
setTaskId('')
|
||||
setProjectId('')
|
||||
setRequiredPoints('')
|
||||
setPeriodType('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="condition-form-overlay" onClick={onCancel}>
|
||||
<div className="condition-form" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Добавить условие разблокировки</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Тип условия</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="task_completion">Выполнить задачу</option>
|
||||
<option value="project_points">Набрать баллы в проекте</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{type === 'task_completion' && (
|
||||
<div className="form-group">
|
||||
<label>Задача</label>
|
||||
<select
|
||||
value={taskId}
|
||||
onChange={(e) => setTaskId(e.target.value)}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Выберите задачу</option>
|
||||
{tasks.map(task => (
|
||||
<option key={task.id} value={task.id}>{task.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'project_points' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Проект</label>
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Выберите проект</option>
|
||||
{projects.map(project => (
|
||||
<option key={project.project_id} value={project.project_id}>
|
||||
{project.project_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Необходимо баллов</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={requiredPoints}
|
||||
onChange={(e) => setRequiredPoints(e.target.value)}
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Период</label>
|
||||
<select
|
||||
value={periodType}
|
||||
onChange={(e) => setPeriodType(e.target.value)}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">За всё время</option>
|
||||
<option value="week">За неделю</option>
|
||||
<option value="month">За месяц</option>
|
||||
<option value="year">За год</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={onCancel} className="cancel-button">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" className="submit-button">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WishlistForm
|
||||
|
||||
Reference in New Issue
Block a user