Files
play-life/play-life-web/src/components/WishlistDetail.jsx

527 lines
20 KiB
React
Raw Normal View History

import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import TaskDetail from './TaskDetail'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './WishlistDetail.css'
import './TaskList.css'
const API_URL = '/api/wishlist'
function WishlistDetail({ wishlistId, onNavigate, onRefresh }) {
const { authFetch } = useAuth()
const [wishlistItem, setWishlistItem] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingWishlist, setLoadingWishlist] = useState(true)
const [error, setError] = useState(null)
const [isCompleting, setIsCompleting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const [selectedTaskForDetail, setSelectedTaskForDetail] = useState(null)
const fetchWishlistDetail = useCallback(async () => {
try {
setLoadingWishlist(true)
setLoading(true)
setError(null)
const response = await authFetch(`${API_URL}/${wishlistId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки желания')
}
const data = await response.json()
setWishlistItem(data)
} catch (err) {
setError(err.message)
console.error('Error fetching wishlist detail:', err)
} finally {
setLoading(false)
setLoadingWishlist(false)
}
}, [wishlistId, authFetch])
useEffect(() => {
if (wishlistId) {
fetchWishlistDetail()
} else {
setWishlistItem(null)
setLoading(true)
setLoadingWishlist(true)
setError(null)
}
}, [wishlistId, fetchWishlistDetail])
const handleEdit = () => {
onNavigate?.('wishlist-form', { wishlistId: wishlistId })
}
const handleComplete = async () => {
if (!wishlistItem || !wishlistItem.unlocked) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/complete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при завершении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error completing wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при завершении', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleUncomplete = async () => {
if (!wishlistItem || !wishlistItem.completed) return
setIsCompleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}/uncomplete`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при отмене завершения')
}
if (onRefresh) {
onRefresh()
}
fetchWishlistDetail()
} catch (err) {
console.error('Error uncompleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при отмене завершения', type: 'error' })
} finally {
setIsCompleting(false)
}
}
const handleDelete = async () => {
if (!wishlistItem) return
if (!window.confirm('Вы уверены, что хотите удалить это желание?')) {
return
}
setIsDeleting(true)
try {
const response = await authFetch(`${API_URL}/${wishlistId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
} catch (err) {
console.error('Error deleting wishlist:', err)
setToastMessage({ text: err.message || 'Ошибка при удалении', type: 'error' })
} finally {
setIsDeleting(false)
}
}
const handleCreateTask = () => {
if (!wishlistItem || !wishlistItem.unlocked || wishlistItem.completed) return
onNavigate?.('task-form', { wishlistId: wishlistId })
}
const handleTaskCheckmarkClick = (e) => {
e.stopPropagation()
if (wishlistItem?.linked_task) {
setSelectedTaskForDetail(wishlistItem.linked_task.id)
}
}
const handleTaskItemClick = () => {
if (wishlistItem?.linked_task) {
onNavigate?.('task-form', { taskId: wishlistItem.linked_task.id })
}
}
const handleCloseDetail = () => {
setSelectedTaskForDetail(null)
}
const handleTaskCompleted = () => {
setToastMessage({ text: 'Задача выполнена', type: 'success' })
// После выполнения задачи желание тоже завершается, перенаправляем на список
if (onRefresh) {
onRefresh()
}
if (onNavigate) {
onNavigate('wishlist')
}
}
const handleUnlinkTask = async (e) => {
e.stopPropagation()
if (!wishlistItem?.linked_task) return
try {
// Загружаем текущую задачу
const taskResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`)
if (!taskResponse.ok) {
throw new Error('Ошибка при загрузке задачи')
}
const taskData = await taskResponse.json()
const task = taskData.task
// Формируем payload для обновления задачи
const payload = {
name: task.name,
reward_message: task.reward_message || null,
progression_base: task.progression_base || null,
repetition_period: task.repetition_period || null,
repetition_date: task.repetition_date || null,
wishlist_id: null, // Отвязываем от желания
rewards: (task.rewards || []).map(r => ({
position: r.position,
project_name: r.project_name,
value: r.value,
use_progression: r.use_progression || false
})),
subtasks: (task.subtasks || []).map(st => ({
id: st.id,
name: st.name || null,
reward_message: st.reward_message || null,
rewards: (st.rewards || []).map(r => ({
position: r.position,
project_name: r.project_name,
value: r.value,
use_progression: r.use_progression || false
}))
}))
}
// Обновляем задачу, отвязывая от желания
const updateResponse = await authFetch(`/api/tasks/${wishlistItem.linked_task.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!updateResponse.ok) {
const errorData = await updateResponse.json().catch(() => ({}))
throw new Error(errorData.message || errorData.error || 'Ошибка при отвязке задачи')
}
setToastMessage({ text: 'Задача отвязана от желания', type: 'success' })
// Обновляем данные желания
fetchWishlistDetail()
if (onRefresh) {
onRefresh()
}
} catch (err) {
console.error('Error unlinking task:', err)
setToastMessage({ text: err.message || 'Ошибка при отвязке задачи', type: 'error' })
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const renderUnlockConditions = () => {
if (!wishlistItem || !wishlistItem.unlock_conditions || wishlistItem.unlock_conditions.length === 0) {
return null
}
return (
<div className="wishlist-detail-conditions">
<h3 className="wishlist-detail-section-title">Цели:</h3>
{wishlistItem.unlock_conditions.map((condition, index) => {
let conditionText = ''
let progress = null
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
const isCompleted = condition.task_completed === true
progress = {
type: 'task',
completed: isCompleted
}
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const project = condition.project_name || 'Проект'
let dateText = ''
if (condition.start_date) {
const date = new Date(condition.start_date + 'T00:00:00')
dateText = ` с ${date.toLocaleDateString('ru-RU')}`
} else {
dateText = ' за всё время'
}
conditionText = `${requiredPoints} в ${project}${dateText}`
const remaining = Math.max(0, requiredPoints - currentPoints)
progress = {
type: 'points',
current: currentPoints,
required: requiredPoints,
remaining: remaining,
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
}
}
// Проверяем каждое условие индивидуально
let isMet = false
if (progress?.type === 'task') {
isMet = progress.completed === true
} else if (progress?.type === 'points') {
isMet = progress.current >= progress.required
}
return (
<div
key={index}
className={`wishlist-detail-condition ${isMet ? 'met' : 'not-met'}`}
>
<div className="condition-header">
<svg className="condition-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
{isMet ? (
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
) : (
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/>
)}
</svg>
<span className="condition-text">{conditionText}</span>
</div>
{progress && progress.type === 'points' && !isMet && (
<div className="condition-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress.percentage}%` }}
></div>
</div>
<div className="progress-text">
<span>{Math.round(progress.current)} / {Math.round(progress.required)}</span>
{progress.remaining > 0 && (
<span className="progress-remaining">Осталось: {Math.round(progress.remaining)}</span>
)}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
if (loadingWishlist) {
return (
<div className="wishlist-detail">
<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-detail">
<button className="close-x-button" onClick={() => onNavigate?.('wishlist')}>
</button>
<h2>{wishlistItem ? wishlistItem.name : 'Желание'}</h2>
<div className="wishlist-detail-content">
{error && (
<LoadingError onRetry={fetchWishlistDetail} />
)}
{!error && wishlistItem && (
<>
{/* Изображение */}
{wishlistItem.image_url && (
<div className="wishlist-detail-image">
<img src={wishlistItem.image_url} alt={wishlistItem.name} />
</div>
)}
{/* Цена */}
{wishlistItem.price && (
<div className="wishlist-detail-price">
{formatPrice(wishlistItem.price)}
</div>
)}
{/* Ссылка */}
{wishlistItem.link && (() => {
try {
const url = new URL(wishlistItem.link)
const host = url.host.replace(/^www\./, '') // Убираем www. если есть
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
{host}
</a>
</div>
)
} catch {
// Если URL некорректный, показываем оригинальный текст
return (
<div className="wishlist-detail-link">
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
Открыть ссылку
</a>
</div>
)
}
})()}
{/* Условия разблокировки */}
{renderUnlockConditions()}
{/* Связанная задача или кнопки действий */}
{wishlistItem.unlocked && !wishlistItem.completed && (
<>
{wishlistItem.linked_task ? (
<div className="wishlist-detail-linked-task">
<div className="linked-task-label-header">Связанная задача:</div>
<div
className="task-item"
onClick={handleTaskItemClick}
>
<div className="task-item-content">
<div
className="task-checkmark"
onClick={handleTaskCheckmarkClick}
title="Выполнить задачу"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" fill="none" className="checkmark-circle" />
<path d="M6 10 L9 13 L14 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="checkmark-check" />
</svg>
</div>
<div className="task-name-container">
<div className="task-name-wrapper">
<div className="task-name">
{wishlistItem.linked_task.name}
</div>
{/* Показываем дату только для выполненных задач (next_show_at > сегодня) */}
{wishlistItem.linked_task.next_show_at && (() => {
const showDate = new Date(wishlistItem.linked_task.next_show_at)
// Нормализуем дату: устанавливаем время в 00:00:00 в локальном времени
const showDateNormalized = new Date(showDate.getFullYear(), showDate.getMonth(), showDate.getDate())
const today = new Date()
const todayNormalized = new Date(today.getFullYear(), today.getMonth(), today.getDate())
// Показываем только если дата > сегодня
if (showDateNormalized.getTime() <= todayNormalized.getTime()) {
return null
}
const tomorrowNormalized = new Date(todayNormalized)
tomorrowNormalized.setDate(tomorrowNormalized.getDate() + 1)
let dateText
if (showDateNormalized.getTime() === tomorrowNormalized.getTime()) {
dateText = 'Завтра'
} else {
dateText = showDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
}
return (
<div className="task-next-show-date">
{dateText}
</div>
)
})()}
</div>
</div>
<div className="task-actions">
<button
className="task-unlink-button"
onClick={handleUnlinkTask}
title="Отвязать от желания"
>
</button>
</div>
</div>
</div>
</div>
) : (
<div className="wishlist-detail-actions">
<button
onClick={handleComplete}
disabled={isCompleting}
className="wishlist-detail-complete-button"
>
{isCompleting ? 'Завершение...' : 'Завершить'}
</button>
<button
onClick={handleCreateTask}
className="wishlist-detail-create-task-button"
title="Создать задачу"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
</button>
</div>
)}
</>
)}
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
{/* Модальное окно для деталей задачи */}
{selectedTaskForDetail && (
<TaskDetail
taskId={selectedTaskForDetail}
onClose={handleCloseDetail}
onRefresh={() => {
fetchWishlistDetail()
if (onRefresh) onRefresh()
}}
onTaskCompleted={handleTaskCompleted}
onNavigate={onNavigate}
/>
)}
</div>
)
}
export default WishlistDetail