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:
@@ -48,6 +48,18 @@ server {
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Раздача загруженных файлов (картинки wishlist) - проксируем через backend
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
27
play-life-web/package-lock.json
generated
27
play-life-web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.7.0",
|
||||
"version": "3.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "play-life-web",
|
||||
"version": "3.7.0",
|
||||
"version": "3.8.9",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -14,7 +14,8 @@
|
||||
"chart.js": "^4.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-easy-crop": "^5.5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -5482,6 +5483,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -5920,6 +5927,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
|
||||
"integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "3.8.9",
|
||||
"version": "3.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +14,8 @@
|
||||
"chart.js": "^4.4.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-easy-crop": "^5.5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
|
||||
@@ -10,6 +10,9 @@ import TestWords from './components/TestWords'
|
||||
import Profile from './components/Profile'
|
||||
import TaskList from './components/TaskList'
|
||||
import TaskForm from './components/TaskForm.jsx'
|
||||
import Wishlist from './components/Wishlist'
|
||||
import WishlistForm from './components/WishlistForm'
|
||||
import WishlistDetail from './components/WishlistDetail'
|
||||
import TodoistIntegration from './components/TodoistIntegration'
|
||||
import TelegramIntegration from './components/TelegramIntegration'
|
||||
import { AuthProvider, useAuth } from './components/auth/AuthContext'
|
||||
@@ -21,8 +24,8 @@ const CURRENT_WEEK_API_URL = '/playlife-feed'
|
||||
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
|
||||
|
||||
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
|
||||
const mainTabs = ['current', 'test-config', 'tasks', 'profile']
|
||||
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
const mainTabs = ['current', 'test-config', 'tasks', 'wishlist', 'profile']
|
||||
const deepTabs = ['add-words', 'add-config', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'words', 'todoist-integration', 'telegram-integration', 'full', 'priorities']
|
||||
|
||||
function AppContent() {
|
||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
||||
@@ -53,6 +56,9 @@ function AppContent() {
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
wishlist: false,
|
||||
'wishlist-form': false,
|
||||
'wishlist-detail': false,
|
||||
profile: false,
|
||||
'todoist-integration': false,
|
||||
'telegram-integration': false,
|
||||
@@ -70,6 +76,9 @@ function AppContent() {
|
||||
test: false,
|
||||
tasks: false,
|
||||
'task-form': false,
|
||||
wishlist: false,
|
||||
'wishlist-form': false,
|
||||
'wishlist-detail': false,
|
||||
profile: false,
|
||||
'todoist-integration': false,
|
||||
'telegram-integration': false,
|
||||
@@ -106,6 +115,7 @@ function AppContent() {
|
||||
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
|
||||
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
|
||||
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
|
||||
const [wishlistRefreshTrigger, setWishlistRefreshTrigger] = useState(0)
|
||||
|
||||
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@@ -118,7 +128,7 @@ function AppContent() {
|
||||
// Проверяем URL только для глубоких табов
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tabFromUrl = urlParams.get('tab')
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
|
||||
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||
// Если в URL есть глубокий таб, восстанавливаем его
|
||||
@@ -492,7 +502,7 @@ function AppContent() {
|
||||
// Обработчик кнопки "назад" в браузере (только для глубоких табов)
|
||||
useEffect(() => {
|
||||
const handlePopState = (event) => {
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration']
|
||||
|
||||
// Проверяем state текущей записи истории (куда мы вернулись)
|
||||
if (event.state && event.state.tab) {
|
||||
@@ -597,8 +607,8 @@ function AppContent() {
|
||||
setSelectedProject(null)
|
||||
setTabParams({})
|
||||
updateUrl('full', {}, activeTab)
|
||||
} else if (tab !== activeTab || tab === 'task-form') {
|
||||
// Для task-form всегда обновляем параметры, даже если это тот же таб
|
||||
} else if (tab !== activeTab || tab === 'task-form' || tab === 'wishlist-form') {
|
||||
// Для task-form и wishlist-form всегда обновляем параметры, даже если это тот же таб
|
||||
markTabAsLoaded(tab)
|
||||
|
||||
// Определяем, является ли текущий таб глубоким
|
||||
@@ -616,8 +626,9 @@ function AppContent() {
|
||||
updateUrl(tab, {}, activeTab)
|
||||
}
|
||||
} else {
|
||||
// Для task-form явно удаляем taskId, если он undefined
|
||||
if (tab === 'task-form' && params.taskId === undefined) {
|
||||
// Для task-form и wishlist-form явно удаляем параметры, если они undefined
|
||||
if ((tab === 'task-form' && params.taskId === undefined) ||
|
||||
(tab === 'wishlist-form' && params.wishlistId === undefined)) {
|
||||
setTabParams({})
|
||||
if (isNewTabMain) {
|
||||
clearUrl()
|
||||
@@ -653,6 +664,10 @@ function AppContent() {
|
||||
if (activeTab === 'task-form' && tab === 'tasks') {
|
||||
fetchTasksData(true)
|
||||
}
|
||||
// Обновляем список желаний при возврате из экрана редактирования
|
||||
if (activeTab === 'wishlist-form' && tab === 'wishlist') {
|
||||
setWishlistRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
// Загрузка данных произойдет в useEffect при изменении activeTab
|
||||
}
|
||||
}
|
||||
@@ -705,7 +720,7 @@ function AppContent() {
|
||||
}, [activeTab])
|
||||
|
||||
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
|
||||
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities'
|
||||
|
||||
// Определяем отступы для контейнера
|
||||
const getContainerPadding = () => {
|
||||
@@ -854,6 +869,36 @@ function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.wishlist && (
|
||||
<div className={activeTab === 'wishlist' ? 'block' : 'hidden'}>
|
||||
<Wishlist
|
||||
onNavigate={handleNavigate}
|
||||
refreshTrigger={wishlistRefreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['wishlist-form'] && (
|
||||
<div className={activeTab === 'wishlist-form' ? 'block' : 'hidden'}>
|
||||
<WishlistForm
|
||||
key={tabParams.wishlistId || 'new'}
|
||||
onNavigate={handleNavigate}
|
||||
wishlistId={tabParams.wishlistId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs['wishlist-detail'] && (
|
||||
<div className={activeTab === 'wishlist-detail' ? 'block' : 'hidden'}>
|
||||
<WishlistDetail
|
||||
key={tabParams.wishlistId}
|
||||
onNavigate={handleNavigate}
|
||||
wishlistId={tabParams.wishlistId}
|
||||
onRefresh={() => setWishlistRefreshTrigger(prev => prev + 1)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedTabs.profile && (
|
||||
<div className={activeTab === 'profile' ? 'block' : 'hidden'}>
|
||||
<Profile onNavigate={handleNavigate} />
|
||||
@@ -938,6 +983,28 @@ function AppContent() {
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('wishlist')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
activeTab === 'wishlist' || activeTab === 'wishlist-form'
|
||||
? 'text-indigo-700 bg-white/50'
|
||||
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
|
||||
}`}
|
||||
title="Желания"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||
<rect x="2" y="7" width="20" height="5"></rect>
|
||||
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{(activeTab === 'wishlist' || activeTab === 'wishlist-form') && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('profile')}
|
||||
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 40px;
|
||||
@@ -107,7 +107,8 @@
|
||||
}
|
||||
|
||||
.card-menu-button:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
309
play-life-web/src/components/Wishlist.css
Normal file
309
play-life-web/src/components/Wishlist.css
Normal file
@@ -0,0 +1,309 @@
|
||||
.wishlist {
|
||||
max-width: 42rem; /* max-w-2xl = 672px */
|
||||
margin: 0 auto;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.add-wishlist-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(to right, #6366f1, #8b5cf6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-wishlist-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.completed-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.completed-toggle:hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.completed-toggle-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-completed {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.wishlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wishlist-card {
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wishlist-card .card-image {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.wishlist-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.wishlist-card.faded {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.card-menu-button {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.7) !important;
|
||||
border: none !important;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: #000000;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.card-menu-button:hover {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
color: #333333;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 5 / 6;
|
||||
background: #f0f0f0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.card-image .placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ccc;
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
padding: 0.6rem 0 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: #2c3e50;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-price {
|
||||
padding: 0;
|
||||
color: #aaa;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.unlock-condition-wrapper {
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.unlock-condition-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.unlock-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.unlock-condition .lock-icon {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.condition-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.more-conditions {
|
||||
padding-left: calc(12px + 0.25rem);
|
||||
margin-top: -0.15rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wishlist-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.wishlist-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.wishlist-modal-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wishlist-modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wishlist-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.wishlist-modal-edit,
|
||||
.wishlist-modal-complete,
|
||||
.wishlist-modal-delete {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wishlist-modal-edit {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-modal-edit:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-modal-complete {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-modal-complete:hover {
|
||||
background-color: #229954;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-modal-delete {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-modal-delete:hover {
|
||||
background-color: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
318
play-life-web/src/components/Wishlist.jsx
Normal file
318
play-life-web/src/components/Wishlist.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import './Wishlist.css'
|
||||
|
||||
const API_URL = '/api/wishlist'
|
||||
|
||||
function Wishlist({ onNavigate, refreshTrigger = 0 }) {
|
||||
const { authFetch } = useAuth()
|
||||
const [items, setItems] = useState([])
|
||||
const [completed, setCompleted] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [completedExpanded, setCompletedExpanded] = useState(false)
|
||||
const [completedLoading, setCompletedLoading] = useState(false)
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchWishlist()
|
||||
}, [])
|
||||
|
||||
// Обновляем данные при изменении refreshTrigger
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWishlist()
|
||||
// Если завершённые развёрнуты, обновляем и их
|
||||
if (completedExpanded) {
|
||||
fetchWishlist(true)
|
||||
}
|
||||
}
|
||||
}, [refreshTrigger])
|
||||
|
||||
const fetchWishlist = async (includeCompleted = false) => {
|
||||
try {
|
||||
if (includeCompleted) {
|
||||
setCompletedLoading(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
const url = includeCompleted ? `${API_URL}?include_completed=true` : API_URL
|
||||
const response = await authFetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке желаний')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
// Объединяем разблокированные и заблокированные в один список
|
||||
const allItems = [...(data.unlocked || []), ...(data.locked || [])]
|
||||
setItems(allItems)
|
||||
if (includeCompleted) {
|
||||
setCompleted(data.completed || [])
|
||||
}
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setItems([])
|
||||
if (includeCompleted) {
|
||||
setCompleted([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setCompletedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCompleted = () => {
|
||||
const newExpanded = !completedExpanded
|
||||
setCompletedExpanded(newExpanded)
|
||||
if (newExpanded && completed.length === 0) {
|
||||
fetchWishlist(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddClick = () => {
|
||||
onNavigate?.('wishlist-form', { wishlistId: undefined })
|
||||
}
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
onNavigate?.('wishlist-detail', { wishlistId: item.id })
|
||||
}
|
||||
|
||||
const handleMenuClick = (item, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedItem(item)
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (selectedItem) {
|
||||
onNavigate?.('wishlist-form', { wishlistId: selectedItem.id })
|
||||
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 fetchWishlist(completedExpanded)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!selectedItem) return
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/${selectedItem.id}/complete`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при завершении')
|
||||
}
|
||||
|
||||
setSelectedItem(null)
|
||||
await fetchWishlist(completedExpanded)
|
||||
} catch (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 renderUnlockCondition = (item) => {
|
||||
if (item.unlocked || item.completed) return null
|
||||
if (!item.first_locked_condition) return null
|
||||
|
||||
const condition = item.first_locked_condition
|
||||
const moreCount = item.more_locked_conditions || 0
|
||||
|
||||
let conditionText = ''
|
||||
if (condition.type === 'task_completion') {
|
||||
conditionText = condition.task_name || 'Задача'
|
||||
} else {
|
||||
const points = condition.required_points || 0
|
||||
const project = condition.project_name || 'Проект'
|
||||
let period = ''
|
||||
if (condition.period_type) {
|
||||
const periodLabels = {
|
||||
week: 'за неделю',
|
||||
month: 'за месяц',
|
||||
year: 'за год',
|
||||
}
|
||||
period = ' ' + periodLabels[condition.period_type] || ''
|
||||
}
|
||||
conditionText = `${points} в ${project}${period}`
|
||||
}
|
||||
|
||||
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>
|
||||
{item.price && (
|
||||
<div className="card-price">{formatPrice(item.price)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItem = (item) => {
|
||||
const isFaded = (!item.unlocked && !item.completed) || item.completed
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`wishlist-card ${isFaded ? 'faded' : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{isFaded && !item.completed ? (
|
||||
renderUnlockCondition(item)
|
||||
) : (
|
||||
item.price && <div className="card-price">{formatPrice(item.price)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="wishlist">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="wishlist">
|
||||
<LoadingError onRetry={() => fetchWishlist()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wishlist">
|
||||
{/* Кнопка добавления */}
|
||||
<button onClick={handleAddClick} className="add-wishlist-button">
|
||||
Добавить
|
||||
</button>
|
||||
|
||||
{/* Основной список (разблокированные и заблокированные вместе) */}
|
||||
{items.length > 0 && (
|
||||
<div className="wishlist-grid">
|
||||
{items.map(renderItem)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Завершённые */}
|
||||
<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>
|
||||
{!selectedItem.completed && selectedItem.unlocked && (
|
||||
<button className="wishlist-modal-complete" onClick={handleComplete}>
|
||||
Завершить
|
||||
</button>
|
||||
)}
|
||||
<button className="wishlist-modal-delete" onClick={handleDelete}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wishlist
|
||||
|
||||
235
play-life-web/src/components/WishlistDetail.css
Normal file
235
play-life-web/src/components/WishlistDetail.css
Normal file
@@ -0,0 +1,235 @@
|
||||
.wishlist-detail {
|
||||
padding: 1rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.wishlist-detail h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.wishlist-detail-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wishlist-detail-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 5 / 6;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.wishlist-detail-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wishlist-detail-price {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wishlist-detail-link {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wishlist-detail-link a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.wishlist-detail-link a:hover {
|
||||
color: #2980b9;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wishlist-detail-conditions {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.wishlist-detail-section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.wishlist-detail-condition {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.wishlist-detail-condition.met {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.wishlist-detail-condition.not-met {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.condition-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.condition-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.condition-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.condition-progress {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: calc(16px + 0.5rem);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #3498db;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.wishlist-detail-condition.met .progress-fill {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wishlist-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.wishlist-detail-edit-button,
|
||||
.wishlist-detail-complete-button,
|
||||
.wishlist-detail-uncomplete-button,
|
||||
.wishlist-detail-delete-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wishlist-detail-edit-button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-detail-edit-button:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-detail-complete-button {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-detail-complete-button:hover:not(:disabled) {
|
||||
background-color: #229954;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-detail-complete-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wishlist-detail-uncomplete-button {
|
||||
background-color: #f39c12;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-detail-uncomplete-button:hover:not(:disabled) {
|
||||
background-color: #e67e22;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-detail-uncomplete-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wishlist-detail-delete-button {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.wishlist-detail-delete-button:hover:not(:disabled) {
|
||||
background-color: #c0392b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wishlist-detail-delete-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
302
play-life-web/src/components/WishlistDetail.jsx
Normal file
302
play-life-web/src/components/WishlistDetail.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import LoadingError from './LoadingError'
|
||||
import Toast from './Toast'
|
||||
import './WishlistDetail.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 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 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 period = ''
|
||||
if (condition.period_type) {
|
||||
const periodLabels = {
|
||||
week: 'за неделю',
|
||||
month: 'за месяц',
|
||||
year: 'за год',
|
||||
}
|
||||
period = ' ' + periodLabels[condition.period_type] || ''
|
||||
}
|
||||
conditionText = `${requiredPoints} в ${project}${period}`
|
||||
progress = {
|
||||
type: 'points',
|
||||
current: currentPoints,
|
||||
required: requiredPoints,
|
||||
percentage: requiredPoints > 0 ? Math.min(100, (currentPoints / requiredPoints) * 100) : 0
|
||||
}
|
||||
}
|
||||
|
||||
const isMet = wishlistItem.unlocked || (progress?.type === 'task' && progress.completed) ||
|
||||
(progress?.type === 'points' && 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">
|
||||
{Math.round(progress.current)} / {Math.round(progress.required)}
|
||||
</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 && (
|
||||
<div className="wishlist-detail-link">
|
||||
<a href={wishlistItem.link} target="_blank" rel="noopener noreferrer">
|
||||
Открыть ссылку
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Условия разблокировки */}
|
||||
{renderUnlockConditions()}
|
||||
|
||||
{/* Кнопка завершения */}
|
||||
{wishlistItem.unlocked && !wishlistItem.completed && (
|
||||
<div className="wishlist-detail-actions">
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
disabled={isCompleting}
|
||||
className="wishlist-detail-complete-button"
|
||||
>
|
||||
{isCompleting ? 'Завершение...' : 'Завершить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WishlistDetail
|
||||
|
||||
385
play-life-web/src/components/WishlistForm.css
Normal file
385
play-life-web/src/components/WishlistForm.css
Normal file
@@ -0,0 +1,385 @@
|
||||
.wishlist-form {
|
||||
padding: 1rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.close-x-button {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
z-index: 1600;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-x-button:hover {
|
||||
background-color: #ffffff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.wishlist-form h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.wishlist-form form {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
aspect-ratio: 5 / 6;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove-image-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(231, 76, 60, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.remove-image-button:hover {
|
||||
background: rgba(192, 57, 43, 1);
|
||||
}
|
||||
|
||||
.cropper-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 450px;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
margin-top: 1rem;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cropper-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cropper-controls input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cropper-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.cropper-actions button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cropper-actions button:first-child {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cropper-actions button:first-child:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.cropper-actions button:last-child {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cropper-actions button:last-child:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.conditions-list {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.condition-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.remove-condition-button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.remove-condition-button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.add-condition-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px dashed #9ca3af;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #374151;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-condition-button:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.condition-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.condition-form {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.condition-form h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Link input with pull button */
|
||||
.link-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.link-input-wrapper .form-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pull-metadata-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pull-metadata-button:hover:not(:disabled) {
|
||||
background: #2980b9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pull-metadata-button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pull-metadata-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mini-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -254,9 +254,17 @@ export function AuthProvider({ children }) {
|
||||
const authFetch = useCallback(async (url, options = {}) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
// Не устанавливаем Content-Type для FormData - браузер сделает это автоматически
|
||||
const isFormData = options.body instanceof FormData
|
||||
const headers = {}
|
||||
|
||||
if (!isFormData && !options.headers?.['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
// Добавляем пользовательские заголовки
|
||||
if (options.headers) {
|
||||
Object.assign(headers, options.headers)
|
||||
}
|
||||
|
||||
if (token) {
|
||||
|
||||
Reference in New Issue
Block a user