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

810 lines
27 KiB
React
Raw Normal View History

import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useAuth } from './auth/AuthContext'
import BoardSelector from './BoardSelector'
import LoadingError from './LoadingError'
import WishlistDetail from './WishlistDetail'
import { sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Wishlist.css'
const API_URL = '/api/wishlist'
const BOARDS_CACHE_KEY = 'wishlist_boards_cache'
const ITEMS_CACHE_KEY = 'wishlist_items_cache'
const SELECTED_BOARD_KEY = 'wishlist_selected_board_id'
function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) {
const { authFetch } = useAuth()
const [boards, setBoards] = useState([])
// Восстанавливаем выбранную доску из localStorage или используем initialBoardId
const getInitialBoardId = () => {
if (initialBoardId) return initialBoardId
return getSavedBoardId()
}
// Получает сохранённую доску из localStorage
const getSavedBoardId = () => {
try {
const saved = localStorage.getItem(SELECTED_BOARD_KEY)
if (saved) {
const boardId = parseInt(saved, 10)
if (!isNaN(boardId)) return boardId
}
} catch (err) {
console.error('Error loading selected board from cache:', err)
}
return null
}
const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId)
const [items, setItems] = useState([])
const [completed, setCompleted] = useState([])
const [completedCount, setCompletedCount] = useState(0)
const [loading, setLoading] = useState(true)
const [boardsLoading, setBoardsLoading] = useState(true)
const [error, setError] = useState('')
const [completedExpanded, setCompletedExpanded] = useState(false)
const [completedLoading, setCompletedLoading] = useState(false)
const [selectedItem, setSelectedItem] = useState(null)
const [selectedWishlistForDetail, setSelectedWishlistForDetail] = useState(null)
const [currentWeekData, setCurrentWeekData] = useState(null)
const fetchingRef = useRef(false)
const fetchingCompletedRef = useRef(false)
const initialFetchDoneRef = useRef(false)
const prevIsActiveRef = useRef(isActive)
// Обёртка для setSelectedBoardId с сохранением в localStorage
const setSelectedBoardId = (boardId) => {
setSelectedBoardIdState(boardId)
try {
if (boardId) {
localStorage.setItem(SELECTED_BOARD_KEY, String(boardId))
} else {
localStorage.removeItem(SELECTED_BOARD_KEY)
}
} catch (err) {
console.error('Error saving selected board to cache:', err)
}
}
// Загрузка досок из кэша
const loadBoardsFromCache = () => {
try {
const cached = localStorage.getItem(BOARDS_CACHE_KEY)
if (cached) {
const data = JSON.parse(cached)
setBoards(data.boards || [])
// Проверяем, что сохранённая доска существует в списке
if (selectedBoardId) {
const boardExists = data.boards?.some(b => b.id === selectedBoardId)
if (!boardExists && data.boards?.length > 0) {
setSelectedBoardId(data.boards[0].id)
}
} else if (data.boards?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data.boards[0].id)
}
}
return true
}
} catch (err) {
console.error('Error loading boards from cache:', err)
}
return false
}
// Сохранение досок в кэш
const saveBoardsToCache = (boardsData) => {
try {
localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({
boards: boardsData,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving boards to cache:', err)
}
}
// Загрузка желаний из кэша (по board_id)
const loadItemsFromCache = (boardId) => {
try {
const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`)
if (cached) {
const data = JSON.parse(cached)
setItems(data.items || [])
setCompletedCount(data.completedCount || 0)
return true
}
} catch (err) {
console.error('Error loading items from cache:', err)
}
return false
}
// Сохранение желаний в кэш
const saveItemsToCache = (boardId, itemsData, count) => {
try {
localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({
items: itemsData,
completedCount: count,
timestamp: Date.now()
}))
} catch (err) {
console.error('Error saving items to cache:', err)
}
}
// Загрузка списка досок
const fetchBoards = async () => {
try {
const response = await authFetch(`${API_URL}/boards`)
if (response.ok) {
const data = await response.json()
setBoards(data || [])
saveBoardsToCache(data || [])
// Проверяем, что выбранная доска существует в списке
if (selectedBoardId) {
const boardExists = data?.some(b => b.id === selectedBoardId)
if (!boardExists && data?.length > 0) {
// Сохранённая доска не существует, выбираем первую
setSelectedBoardId(data[0].id)
}
} else if (data?.length > 0) {
// Пытаемся восстановить из localStorage
const savedBoardId = getSavedBoardId()
if (savedBoardId && data.some(b => b.id === savedBoardId)) {
setSelectedBoardId(savedBoardId)
} else {
setSelectedBoardId(data[0].id)
}
}
}
} catch (err) {
console.error('Error fetching boards:', err)
} finally {
setBoardsLoading(false)
}
}
// Загрузка желаний выбранной доски
const fetchItems = async () => {
if (!selectedBoardId || fetchingRef.current) return
fetchingRef.current = true
try {
const hasDataInState = items.length > 0 || completedCount > 0
if (!hasDataInState) {
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (!cacheLoaded) {
setLoading(true)
}
}
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`)
if (!response.ok) {
throw new Error('Ошибка при загрузке желаний')
}
const data = await response.json()
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
const count = data.completed_count || 0
setItems(allItems)
setCompletedCount(count)
saveItemsToCache(selectedBoardId, allItems, count)
setError('')
} catch (err) {
setError(err.message)
if (!loadItemsFromCache(selectedBoardId)) {
setItems([])
setCompletedCount(0)
}
} finally {
setLoading(false)
fetchingRef.current = false
}
}
// Загрузка завершённых для текущей доски
const fetchCompleted = async () => {
if (fetchingCompletedRef.current || !selectedBoardId) return
fetchingCompletedRef.current = true
try {
setCompletedLoading(true)
// Используем новый API для получения завершённых на доске
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`)
if (!response.ok) {
const errText = await response.text()
const msg = errText || 'Ошибка при загрузке завершённых желаний'
throw new Error(msg)
}
const data = await response.json()
const completedData = Array.isArray(data) ? data : []
setCompleted(completedData)
} catch (err) {
console.error('Error fetching completed items:', err)
setCompleted([])
} finally {
setCompletedLoading(false)
fetchingCompletedRef.current = false
}
}
// Загрузка данных текущей недели для сортировки проектов
const fetchCurrentWeek = async () => {
try {
const response = await authFetch('/api/current-week')
if (response.ok) {
const data = await response.json()
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
if (Array.isArray(data) && data.length > 0) {
setCurrentWeekData(data[0])
} else if (data && typeof data === 'object') {
setCurrentWeekData(data)
}
}
} catch (err) {
console.error('Error loading current week data:', err)
}
}
// Первая инициализация
useEffect(() => {
if (!initialFetchDoneRef.current) {
initialFetchDoneRef.current = true
// Загружаем доски из кэша
const boardsCacheLoaded = loadBoardsFromCache()
if (boardsCacheLoaded) {
setBoardsLoading(false)
}
// Загружаем доски с сервера
fetchBoards()
// Загружаем данные текущей недели для сортировки проектов
fetchCurrentWeek()
}
}, [])
// Загружаем желания при смене доски
useEffect(() => {
if (selectedBoardId) {
// Сбрасываем состояние
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Пробуем загрузить из кэша
const cacheLoaded = loadItemsFromCache(selectedBoardId)
if (cacheLoaded) {
setLoading(false)
}
// Загружаем свежие данные
fetchItems()
}
}, [selectedBoardId])
// Обновление при активации таба
useEffect(() => {
const wasActive = prevIsActiveRef.current
prevIsActiveRef.current = isActive
if (!initialFetchDoneRef.current) return
if (isActive && !wasActive) {
fetchBoards()
if (selectedBoardId) {
fetchItems()
}
}
}, [isActive])
// Обновление при refreshTrigger
useEffect(() => {
if (refreshTrigger > 0 && selectedBoardId) {
// Очищаем кэш для текущей доски, чтобы загрузить свежие данные
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
fetchBoards()
fetchItems()
if (completedExpanded && completedCount > 0) {
fetchCompleted()
}
}
}, [refreshTrigger, selectedBoardId])
// Обновление при initialBoardId (когда создана новая доска или переход по ссылке)
useEffect(() => {
if (initialBoardId && initialBoardId !== selectedBoardId) {
// Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку
fetchingRef.current = false
// Обновляем список досок (чтобы новая доска появилась)
fetchBoards().then(() => {
// Переключаемся на новую доску после обновления списка
// Это вызовет useEffect для selectedBoardId, который загрузит данные
setSelectedBoardId(initialBoardId)
})
}
}, [initialBoardId])
// Обработка удаления доски - выбираем первую доступную
useEffect(() => {
if (boardDeleted && boards.length > 0) {
// Очищаем текущие данные
setItems([])
setCompletedCount(0)
setCompleted([])
setCompletedExpanded(false)
setLoading(true)
// Обновляем список досок и выбираем первую
fetchBoards().then(() => {
// fetchBoards обновит boards, но мы уже в этом useEffect
// selectedBoardId обновится автоматически в useEffect ниже
})
}
}, [boardDeleted])
// Если текущая доска больше не существует в списке - выбираем первую
useEffect(() => {
if (boards.length > 0 && selectedBoardId) {
const boardExists = boards.some(b => b.id === selectedBoardId)
if (!boardExists) {
setSelectedBoardId(boards[0].id)
}
}
}, [boards, selectedBoardId])
const handleBoardChange = (boardId) => {
setSelectedBoardId(boardId)
}
const handleBoardEdit = () => {
const board = boards.find(b => b.id === selectedBoardId)
if (board?.is_owner) {
onNavigate?.('board-form', { boardId: selectedBoardId })
} else {
// Показать подтверждение выхода
handleLeaveBoard()
}
}
const handleLeaveBoard = async () => {
if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return
try {
const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, {
method: 'POST'
})
if (response.ok) {
// Убираем доску из списка
const newBoards = boards.filter(b => b.id !== selectedBoardId)
setBoards(newBoards)
saveBoardsToCache(newBoards)
// Выбираем первую доску
if (newBoards.length > 0) {
setSelectedBoardId(newBoards[0].id)
} else {
setSelectedBoardId(null)
setItems([])
}
}
} catch (err) {
console.error('Error leaving board:', err)
}
}
const handleAddBoard = () => {
onNavigate?.('board-form', { boardId: null })
}
const handleToggleCompleted = () => {
const newExpanded = !completedExpanded
setCompletedExpanded(newExpanded)
if (newExpanded && completedCount > 0) {
fetchCompleted()
}
}
const handleAddClick = () => {
// Если selectedBoardId равен null, но есть доски, используем первую доску
// Если доски еще не загружены, используем initialBoardId
const boardIdToUse = selectedBoardId || (boards.length > 0 ? boards[0].id : initialBoardId)
onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: boardIdToUse })
}
const handleItemClick = (item) => {
setSelectedWishlistForDetail(item.id)
}
const handleCloseDetail = () => {
setSelectedWishlistForDetail(null)
}
const handleMenuClick = (item, e) => {
e.stopPropagation()
setSelectedItem(item)
}
const handleEdit = () => {
if (selectedItem) {
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId })
setSelectedItem(null)
}
}
const handleDelete = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении')
}
setSelectedItem(null)
await fetchItems()
if (completedExpanded) {
await fetchCompleted()
}
} catch (err) {
setError(err.message)
setSelectedItem(null)
}
}
const handleCopy = async () => {
if (!selectedItem) return
try {
const response = await authFetch(`${API_URL}/${selectedItem.id}/copy`, {
method: 'POST',
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(errorText || 'Ошибка при копировании')
}
const newItem = await response.json()
setSelectedItem(null)
// Очищаем кэш для текущей доски, чтобы новое желание появилось в списке
if (selectedBoardId) {
try {
localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`)
} catch (err) {
console.error('Error clearing cache:', err)
}
}
// Обновляем список
await fetchItems()
// Открываем форму редактирования для нового желания
onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId })
} catch (err) {
console.error('Error copying wishlist item:', err)
setError(err.message || 'Ошибка при копировании')
setSelectedItem(null)
}
}
const formatPrice = (price) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const findFirstUnmetCondition = (item) => {
if (!item.unlock_conditions || item.unlock_conditions.length === 0) {
return null
}
for (const condition of item.unlock_conditions) {
let isMet = false
if (condition.type === 'task_completion') {
isMet = condition.task_completed === true
} else if (condition.type === 'project_points') {
const currentPoints = condition.current_points || 0
const requiredPoints = condition.required_points || 0
isMet = currentPoints >= requiredPoints
}
if (!isMet) {
return condition
}
}
return null
}
const renderUnlockCondition = (item) => {
if (item.completed) return null
const condition = findFirstUnmetCondition(item)
if (!condition) return null
let conditionText = ''
if (condition.type === 'task_completion') {
conditionText = condition.task_name || 'Задача'
} else {
const requiredPoints = condition.required_points || 0
const currentPoints = condition.current_points || 0
const remainingPoints = Math.max(0, requiredPoints - currentPoints)
const project = condition.project_name || 'Проект'
// Показываем оставшиеся баллы в формате "33 в Привлекательность"
// Дата начала отсчёта уже учтена в current_points на бэкенде
conditionText = `${Math.round(remainingPoints)} в ${project}`
}
return (
<div className="unlock-condition-wrapper">
<div className="unlock-condition-line">
<div className="unlock-condition">
<svg className="lock-icon" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<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>
</div>
</div>
)
}
// Группируем желания по группам
const groupedItems = useMemo(() => {
const groups = {}
const noGroupItems = []
items.forEach(item => {
if (item.group_name && item.group_name.trim()) {
const groupName = item.group_name.trim()
if (!groups[groupName]) {
groups[groupName] = {
groupName: groupName,
items: []
}
}
groups[groupName].items.push(item)
} else {
noGroupItems.push(item)
}
})
// Сортируем группы по алфавиту
const sortedGroups = Object.values(groups).sort((a, b) =>
a.groupName.localeCompare(b.groupName)
)
return { groups: sortedGroups, noGroupItems }
}, [items])
const renderItem = (item) => {
const isFaded = (!item.unlocked && !item.completed) || item.completed
return (
<div
key={item.id}
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
onClick={() => handleItemClick(item)}
>
{item.completed && (
<div className={`card-status-indicator ${item.rejected ? 'card-status-rejected' : 'card-status-completed'}`}>
{item.rejected ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</div>
)}
<div className="wishlist-card-content">
<button
className="card-menu-button"
onClick={(e) => handleMenuClick(item, e)}
title="Меню"
>
</button>
<div className="card-image">
{item.image_url ? (
<img src={item.image_url} alt={item.name} />
) : (
<div className="placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
)}
</div>
<div className="card-name">{item.name}</div>
{(() => {
const unmetCondition = findFirstUnmetCondition(item)
if (unmetCondition && !item.completed) {
return renderUnlockCondition(item)
}
if (item.price) {
return <div className="card-price">{formatPrice(item.price)}</div>
}
return null
})()}
</div>
</div>
)
}
if (error && items.length === 0 && !boardsLoading && !loading) {
return (
<div className="wishlist">
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
showBoardAction={false}
/>
<LoadingError onRetry={() => fetchItems()} />
</div>
)
}
return (
<div className="wishlist">
{/* Селектор доски */}
<BoardSelector
boards={boards}
selectedBoardId={selectedBoardId}
onBoardChange={handleBoardChange}
onBoardEdit={handleBoardEdit}
onAddBoard={handleAddBoard}
loading={boardsLoading}
showBoardAction={false}
/>
{/* Основной список */}
{boardsLoading && loading ? (
<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>
) : loading ? (
<div className="wishlist-loading">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<>
{/* Группы проектов */}
{groupedItems.groups.map(group => (
<div key={group.groupName} className="wishlist-project-group">
<div className="wishlist-project-group-title">{group.groupName}</div>
<div className="wishlist-project-group-items">
{group.items.map(renderItem)}
</div>
</div>
))}
{/* Желания без группы */}
{groupedItems.noGroupItems.length > 0 && (
<div className="wishlist-no-project">
<div className="wishlist-grid">
{groupedItems.noGroupItems.map(renderItem)}
</div>
</div>
)}
{/* Завершённые */}
{completedCount > 0 && (
<>
<div className="section-divider">
<button
className="completed-toggle"
onClick={handleToggleCompleted}
>
<span className="completed-toggle-icon">
{completedExpanded ? '▼' : '▶'}
</span>
<span>Завершённые</span>
</button>
</div>
{completedExpanded && (
<>
{completedLoading ? (
<div className="loading-completed">
<div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="wishlist-grid">
{completed.map(renderItem)}
</div>
)}
</>
)}
</>
)}
</>
)}
{/* Модальное окно для действий */}
{selectedItem && (
<div className="wishlist-modal-overlay" onClick={() => setSelectedItem(null)}>
<div className="wishlist-modal" onClick={(e) => e.stopPropagation()}>
<div className="wishlist-modal-header">
<h3>{selectedItem.name}</h3>
</div>
<div className="wishlist-modal-actions">
<button className="wishlist-modal-edit" onClick={handleEdit}>
Редактировать
</button>
<button className="wishlist-modal-copy" onClick={handleCopy}>
Копировать
</button>
<button className="wishlist-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Модальное окно для деталей желания */}
{selectedWishlistForDetail && (
<WishlistDetail
wishlistId={selectedWishlistForDetail}
onNavigate={onNavigate}
boardId={selectedBoardId}
onRefresh={async () => {
await fetchItems()
if (completedExpanded) {
await fetchCompleted()
}
}}
onClose={handleCloseDetail}
/>
)}
</div>
)
}
export default Wishlist