diff --git a/.cursor/rules/restart_on_changes.mdc b/.cursor/rules/restart_on_changes.mdc
index 6de4137..0b8cd8f 100644
--- a/.cursor/rules/restart_on_changes.mdc
+++ b/.cursor/rules/restart_on_changes.mdc
@@ -1,5 +1,5 @@
---
-description: Перезапуск приложения после изменений в бэкенде или фронтенде
+description: "Перезапуск приложения после изменений в бэкенде или фронтенде"
alwaysApply: true
---
diff --git a/.gitignore b/.gitignore
index 43ad084..448efdd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ node_modules/
database-dumps/*.sql
database-dumps/*.sql.gz
!database-dumps/.gitkeep
+
+# Uploaded files
+uploads/
diff --git a/Dockerfile b/Dockerfile
index d78374e..a9449df 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -33,6 +33,10 @@ RUN apk --no-cache add \
# Создаем директории
WORKDIR /app
+# Создаем директорию для загруженных файлов
+RUN mkdir -p /app/uploads/wishlist && \
+ chmod 755 /app/uploads
+
# Копируем собранный frontend
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
diff --git a/VERSION b/VERSION
index d20cc2b..a5c4c76 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.8.10
+3.9.0
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ff1f66..4c40684 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,7 @@ services:
condition: service_healthy
volumes:
- ./play-life-backend/migrations:/migrations
+ - ./uploads:/app/uploads
env_file:
- .env
diff --git a/nginx-unified.conf b/nginx-unified.conf
index 518522c..f39541a 100644
--- a/nginx-unified.conf
+++ b/nginx-unified.conf
@@ -74,6 +74,19 @@ server {
expires 0;
}
+ # Раздача загруженных файлов (картинки wishlist) - проксируем через backend
+ # Используем ^~ чтобы этот location имел приоритет над regex locations
+ location ^~ /uploads/ {
+ proxy_pass http://localhost: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;
diff --git a/play-life-backend/go.mod b/play-life-backend/go.mod
index d737863..de9d41a 100644
--- a/play-life-backend/go.mod
+++ b/play-life-backend/go.mod
@@ -11,3 +11,8 @@ require (
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.28.0
)
+
+require (
+ github.com/disintegration/imaging v1.6.2 // indirect
+ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
+)
diff --git a/play-life-backend/go.sum b/play-life-backend/go.sum
index 6a0b67f..eeb8368 100644
--- a/play-life-backend/go.sum
+++ b/play-life-backend/go.sum
@@ -1,3 +1,5 @@
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
@@ -12,3 +14,6 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/play-life-backend/main.go b/play-life-backend/main.go
index 6124acc..5381bb4 100644
--- a/play-life-backend/main.go
+++ b/play-life-backend/main.go
@@ -23,6 +23,7 @@ import (
"time"
"unicode/utf16"
+ "github.com/disintegration/imaging"
"github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
@@ -31,6 +32,7 @@ import (
"github.com/lib/pq"
"github.com/robfig/cron/v3"
"golang.org/x/crypto/bcrypt"
+ "image/jpeg"
)
type Word struct {
@@ -268,6 +270,58 @@ type PostponeTaskRequest struct {
NextShowAt *string `json:"next_show_at"`
}
+// ============================================
+// Wishlist structures
+// ============================================
+
+type WishlistItem struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Price *float64 `json:"price,omitempty"`
+ ImageURL *string `json:"image_url,omitempty"`
+ Link *string `json:"link,omitempty"`
+ Unlocked bool `json:"unlocked"`
+ Completed bool `json:"completed"`
+ FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
+ MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
+ UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
+}
+
+type UnlockConditionDisplay struct {
+ ID int `json:"id"`
+ Type string `json:"type"`
+ TaskName *string `json:"task_name,omitempty"`
+ ProjectName *string `json:"project_name,omitempty"`
+ RequiredPoints *float64 `json:"required_points,omitempty"`
+ PeriodType *string `json:"period_type,omitempty"`
+ DisplayOrder int `json:"display_order"`
+ // Прогресс выполнения
+ CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points)
+ TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion)
+}
+
+type WishlistRequest struct {
+ Name string `json:"name"`
+ Price *float64 `json:"price,omitempty"`
+ Link *string `json:"link,omitempty"`
+ UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"`
+}
+
+type UnlockConditionRequest struct {
+ Type string `json:"type"`
+ TaskID *int `json:"task_id,omitempty"`
+ ProjectID *int `json:"project_id,omitempty"`
+ RequiredPoints *float64 `json:"required_points,omitempty"`
+ PeriodType *string `json:"period_type,omitempty"`
+ DisplayOrder *int `json:"display_order,omitempty"`
+}
+
+type WishlistResponse struct {
+ Unlocked []WishlistItem `json:"unlocked"`
+ Locked []WishlistItem `json:"locked"`
+ Completed []WishlistItem `json:"completed,omitempty"`
+}
+
// ============================================
// Helper functions for repetition_date
// ============================================
@@ -2756,6 +2810,12 @@ func (a *App) initAuthDB() error {
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
+ // Apply migration 019: Add wishlist tables
+ if err := a.applyMigration019(); err != nil {
+ log.Printf("Warning: Failed to apply migration 019: %v", err)
+ // Не возвращаем ошибку, чтобы приложение могло запуститься
+ }
+
// Clean up expired refresh tokens (only those with expiration date set)
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
@@ -2888,6 +2948,51 @@ func (a *App) applyMigration013() error {
return nil
}
+// applyMigration019 применяет миграцию 019_add_wishlist.sql
+func (a *App) applyMigration019() error {
+ log.Printf("Applying migration 019: Add wishlist tables")
+
+ // Проверяем, существует ли уже таблица wishlist_items
+ var exists bool
+ err := a.DB.QueryRow(`
+ SELECT EXISTS (
+ SELECT FROM information_schema.tables
+ WHERE table_schema = 'public'
+ AND table_name = 'wishlist_items'
+ )
+ `).Scan(&exists)
+ if err != nil {
+ return fmt.Errorf("failed to check if wishlist_items exists: %w", err)
+ }
+ if exists {
+ log.Printf("Migration 019 already applied (wishlist_items table exists), skipping")
+ return nil
+ }
+
+ // Читаем SQL файл миграции
+ migrationPath := "/migrations/019_add_wishlist.sql"
+ if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
+ // Пробуем альтернативный путь (для локальной разработки)
+ migrationPath = "play-life-backend/migrations/019_add_wishlist.sql"
+ if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
+ migrationPath = "migrations/019_add_wishlist.sql"
+ }
+ }
+
+ migrationSQL, err := os.ReadFile(migrationPath)
+ if err != nil {
+ return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
+ }
+
+ // Выполняем миграцию
+ if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
+ return fmt.Errorf("failed to execute migration 019: %w", err)
+ }
+
+ log.Printf("Migration 019 applied successfully")
+ return nil
+}
+
func (a *App) initPlayLifeDB() error {
// Создаем таблицу projects
createProjectsTable := `
@@ -3748,6 +3853,23 @@ func main() {
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
+ // Static files handler для uploads (public, no auth required) - ДО protected!
+ // Backend работает из /app/backend/, но uploads находится в /app/uploads/
+ r.HandleFunc("/uploads/{path:.*}", func(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ path := vars["path"]
+ filePath := filepath.Join("/app/uploads", path)
+
+ // Проверяем, что файл существует
+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Отдаём файл
+ http.ServeFile(w, r, filePath)
+ }).Methods("GET")
+
// Protected routes (require authentication)
protected := r.PathPrefix("/").Subrouter()
protected.Use(app.authMiddleware)
@@ -3807,6 +3929,17 @@ func main() {
protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
+ // Wishlist
+ protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
+ protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}", app.deleteWishlistHandler).Methods("DELETE", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS")
+ protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
+
// Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
@@ -8290,3 +8423,1328 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
})
}
+// ============================================
+// Wishlist handlers
+// ============================================
+
+// calculateProjectPointsForPeriod считает баллы проекта за указанный период
+func (a *App) calculateProjectPointsForPeriod(
+ projectID int,
+ periodType sql.NullString,
+ userID int,
+) (float64, error) {
+ var totalScore float64
+ var err error
+
+ // Обновляем materialized view перед запросом
+ _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
+ if err != nil {
+ log.Printf("Warning: Failed to refresh materialized view: %v", err)
+ }
+
+ if !periodType.Valid || periodType.String == "" {
+ // За всё время
+ err = a.DB.QueryRow(`
+ SELECT COALESCE(SUM(wr.total_score), 0)
+ FROM weekly_report_mv wr
+ JOIN projects p ON wr.project_id = p.id
+ WHERE wr.project_id = $1 AND p.user_id = $2
+ `, projectID, userID).Scan(&totalScore)
+ } else {
+ switch periodType.String {
+ case "week":
+ // Текущая неделя
+ err = a.DB.QueryRow(`
+ SELECT COALESCE(SUM(wr.total_score), 0)
+ FROM weekly_report_mv wr
+ JOIN projects p ON wr.project_id = p.id
+ WHERE wr.project_id = $1
+ AND p.user_id = $2
+ AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
+ AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
+ `, projectID, userID).Scan(&totalScore)
+
+ case "month":
+ // Текущий месяц
+ err = a.DB.QueryRow(`
+ SELECT COALESCE(SUM(wr.total_score), 0)
+ FROM weekly_report_mv wr
+ JOIN projects p ON wr.project_id = p.id
+ WHERE wr.project_id = $1
+ AND p.user_id = $2
+ AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
+ AND wr.report_week >= EXTRACT(WEEK FROM DATE_TRUNC('month', CURRENT_DATE))::INTEGER
+ AND wr.report_week <= EXTRACT(WEEK FROM (DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day'))::INTEGER
+ `, projectID, userID).Scan(&totalScore)
+
+ case "year":
+ // Текущий год
+ err = a.DB.QueryRow(`
+ SELECT COALESCE(SUM(wr.total_score), 0)
+ FROM weekly_report_mv wr
+ JOIN projects p ON wr.project_id = p.id
+ WHERE wr.project_id = $1
+ AND p.user_id = $2
+ AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
+ `, projectID, userID).Scan(&totalScore)
+
+ default:
+ return 0, fmt.Errorf("unknown period_type: %s", periodType.String)
+ }
+ }
+
+ if err != nil {
+ return 0, err
+ }
+
+ return totalScore, nil
+}
+
+// checkWishlistUnlock проверяет ВСЕ условия для желания
+// Все условия должны выполняться (AND логика)
+func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
+ // Получаем все условия разблокировки
+ rows, err := a.DB.Query(`
+ SELECT
+ wc.id,
+ wc.display_order,
+ wc.task_condition_id,
+ wc.score_condition_id,
+ tc.task_id,
+ sc.project_id,
+ sc.required_points,
+ sc.period_type
+ FROM wishlist_conditions wc
+ LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
+ LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
+ WHERE wc.wishlist_item_id = $1
+ ORDER BY wc.display_order, wc.id
+ `, itemID)
+
+ if err != nil {
+ return false, err
+ }
+ defer rows.Close()
+
+ var hasConditions bool
+ var allConditionsMet = true
+
+ for rows.Next() {
+ hasConditions = true
+
+ var wcID, displayOrder int
+ var taskConditionID, scoreConditionID sql.NullInt64
+ var taskID sql.NullInt64
+ var projectID sql.NullInt64
+ var requiredPoints sql.NullFloat64
+ var periodType sql.NullString
+
+ err := rows.Scan(
+ &wcID, &displayOrder,
+ &taskConditionID, &scoreConditionID,
+ &taskID, &projectID, &requiredPoints, &periodType,
+ )
+ if err != nil {
+ return false, err
+ }
+
+ var conditionMet bool
+
+ if taskConditionID.Valid {
+ // Проверяем условие по задаче
+ if !taskID.Valid {
+ return false, fmt.Errorf("task_id is missing for task_condition_id=%d", taskConditionID.Int64)
+ }
+
+ var completed int
+ err := a.DB.QueryRow(`
+ SELECT completed
+ FROM tasks
+ WHERE id = $1 AND user_id = $2
+ `, taskID.Int64, userID).Scan(&completed)
+
+ if err == sql.ErrNoRows {
+ conditionMet = false
+ } else if err != nil {
+ return false, err
+ } else {
+ conditionMet = completed > 0
+ }
+
+ } else if scoreConditionID.Valid {
+ // Проверяем условие по баллам
+ if !projectID.Valid || !requiredPoints.Valid {
+ return false, fmt.Errorf("project_id or required_points missing for score_condition_id=%d", scoreConditionID.Int64)
+ }
+
+ totalScore, err := a.calculateProjectPointsForPeriod(
+ int(projectID.Int64),
+ periodType,
+ userID,
+ )
+ if err != nil {
+ return false, err
+ }
+
+ conditionMet = totalScore >= requiredPoints.Float64
+ } else {
+ return false, fmt.Errorf("invalid condition: neither task nor score condition")
+ }
+
+ if !conditionMet {
+ allConditionsMet = false
+ }
+ }
+
+ // Если нет условий - желание разблокировано по умолчанию
+ if !hasConditions {
+ return true, nil
+ }
+
+ return allConditionsMet, nil
+}
+
+// getWishlistItemsWithConditions загружает желания с их условиями
+func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) {
+ query := `
+ SELECT
+ wi.id,
+ wi.name,
+ wi.price,
+ wi.image_path,
+ wi.link,
+ wi.completed,
+ wc.id AS condition_id,
+ wc.display_order,
+ wc.task_condition_id,
+ wc.score_condition_id,
+ tc.task_id,
+ t.name AS task_name,
+ sc.project_id,
+ p.name AS project_name,
+ sc.required_points,
+ sc.period_type
+ FROM wishlist_items wi
+ LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
+ LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
+ LEFT JOIN tasks t ON tc.task_id = t.id
+ LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
+ LEFT JOIN projects p ON sc.project_id = p.id
+ WHERE wi.user_id = $1
+ AND wi.deleted = FALSE
+ AND ($2 = TRUE OR wi.completed = FALSE)
+ ORDER BY wi.completed, wi.id, wc.display_order, wc.id
+ `
+
+ rows, err := a.DB.Query(query, userID, includeCompleted)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // Группируем по wishlist_item_id
+ itemsMap := make(map[int]*WishlistItem)
+
+ for rows.Next() {
+ var itemID int
+ var name string
+ var price sql.NullFloat64
+ var imagePath, link sql.NullString
+ var completed bool
+
+ var conditionID, displayOrder sql.NullInt64
+ var taskConditionID, scoreConditionID sql.NullInt64
+ var taskID sql.NullInt64
+ var taskName sql.NullString
+ var projectID sql.NullInt64
+ var projectName sql.NullString
+ var requiredPoints sql.NullFloat64
+ var periodType sql.NullString
+
+ err := rows.Scan(
+ &itemID, &name, &price, &imagePath, &link, &completed,
+ &conditionID, &displayOrder,
+ &taskConditionID, &scoreConditionID,
+ &taskID, &taskName,
+ &projectID, &projectName, &requiredPoints, &periodType,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Получаем или создаём item
+ item, exists := itemsMap[itemID]
+ if !exists {
+ item = &WishlistItem{
+ ID: itemID,
+ Name: name,
+ Completed: completed,
+ UnlockConditions: []UnlockConditionDisplay{},
+ }
+ if price.Valid {
+ p := price.Float64
+ item.Price = &p
+ }
+ if imagePath.Valid {
+ url := imagePath.String
+ item.ImageURL = &url
+ }
+ if link.Valid {
+ l := link.String
+ item.Link = &l
+ }
+ itemsMap[itemID] = item
+ }
+
+ // Добавляем условие, если есть
+ if conditionID.Valid {
+ condition := UnlockConditionDisplay{
+ ID: int(conditionID.Int64),
+ DisplayOrder: int(displayOrder.Int64),
+ }
+
+ if taskConditionID.Valid {
+ condition.Type = "task_completion"
+ if taskName.Valid {
+ condition.TaskName = &taskName.String
+ }
+ } else if scoreConditionID.Valid {
+ condition.Type = "project_points"
+ if projectName.Valid {
+ condition.ProjectName = &projectName.String
+ }
+ if requiredPoints.Valid {
+ condition.RequiredPoints = &requiredPoints.Float64
+ }
+ if periodType.Valid {
+ condition.PeriodType = &periodType.String
+ }
+ }
+
+ item.UnlockConditions = append(item.UnlockConditions, condition)
+ }
+ }
+
+ // Конвертируем map в slice и проверяем разблокировку
+ items := make([]WishlistItem, 0, len(itemsMap))
+ for _, item := range itemsMap {
+ unlocked, err := a.checkWishlistUnlock(item.ID, userID)
+ if err != nil {
+ log.Printf("Error checking unlock for wishlist %d: %v", item.ID, err)
+ unlocked = false
+ }
+ item.Unlocked = unlocked
+
+ // Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
+ if !unlocked && !item.Completed {
+ lockedCount := 0
+ var firstLocked *UnlockConditionDisplay
+ for i := range item.UnlockConditions {
+ // Проверяем каждое условие отдельно
+ condition := &item.UnlockConditions[i]
+ var conditionMet bool
+ var err error
+
+ if condition.Type == "task_completion" {
+ // Находим task_id для этого условия
+ var taskID int
+ err = a.DB.QueryRow(`
+ SELECT tc.task_id
+ FROM wishlist_conditions wc
+ JOIN task_conditions tc ON wc.task_condition_id = tc.id
+ WHERE wc.id = $1
+ `, condition.ID).Scan(&taskID)
+ if err == nil {
+ var completed int
+ err = a.DB.QueryRow(`
+ SELECT completed FROM tasks WHERE id = $1 AND user_id = $2
+ `, taskID, userID).Scan(&completed)
+ conditionMet = err == nil && completed > 0
+ completedBool := conditionMet
+ condition.TaskCompleted = &completedBool
+ }
+ } else if condition.Type == "project_points" {
+ // Находим project_id и required_points для этого условия
+ var projectID int
+ var requiredPoints float64
+ var periodType sql.NullString
+ err = a.DB.QueryRow(`
+ SELECT sc.project_id, sc.required_points, sc.period_type
+ FROM wishlist_conditions wc
+ JOIN score_conditions sc ON wc.score_condition_id = sc.id
+ WHERE wc.id = $1
+ `, condition.ID).Scan(&projectID, &requiredPoints, &periodType)
+ if err == nil {
+ totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID)
+ conditionMet = err == nil && totalScore >= requiredPoints
+ if err == nil {
+ condition.CurrentPoints = &totalScore
+ }
+ }
+ }
+
+ if !conditionMet {
+ lockedCount++
+ if firstLocked == nil {
+ firstLocked = condition
+ }
+ }
+ }
+ if firstLocked != nil {
+ item.FirstLockedCondition = firstLocked
+ item.MoreLockedConditions = lockedCount - 1
+ }
+ } else {
+ // Даже если желание разблокировано, рассчитываем прогресс для всех условий
+ for i := range item.UnlockConditions {
+ condition := &item.UnlockConditions[i]
+ if condition.Type == "task_completion" {
+ var taskID int
+ err := a.DB.QueryRow(`
+ SELECT tc.task_id
+ FROM wishlist_conditions wc
+ JOIN task_conditions tc ON wc.task_condition_id = tc.id
+ WHERE wc.id = $1
+ `, condition.ID).Scan(&taskID)
+ if err == nil {
+ var completed int
+ err = a.DB.QueryRow(`
+ SELECT completed FROM tasks WHERE id = $1 AND user_id = $2
+ `, taskID, userID).Scan(&completed)
+ if err == nil {
+ completedBool := completed > 0
+ condition.TaskCompleted = &completedBool
+ }
+ }
+ } else if condition.Type == "project_points" {
+ var projectID int
+ var requiredPoints float64
+ var periodType sql.NullString
+ err := a.DB.QueryRow(`
+ SELECT sc.project_id, sc.required_points, sc.period_type
+ FROM wishlist_conditions wc
+ JOIN score_conditions sc ON wc.score_condition_id = sc.id
+ WHERE wc.id = $1
+ `, condition.ID).Scan(&projectID, &requiredPoints, &periodType)
+ if err == nil {
+ totalScore, err := a.calculateProjectPointsForPeriod(projectID, periodType, userID)
+ if err == nil {
+ condition.CurrentPoints = &totalScore
+ }
+ }
+ }
+ }
+ }
+
+ items = append(items, *item)
+ }
+
+ return items, nil
+}
+
+// saveWishlistConditions сохраняет условия для желания
+func (a *App) saveWishlistConditions(
+ tx *sql.Tx,
+ wishlistItemID int,
+ conditions []UnlockConditionRequest,
+) error {
+ // Удаляем старые условия
+ _, err := tx.Exec(`
+ DELETE FROM wishlist_conditions
+ WHERE wishlist_item_id = $1
+ `, wishlistItemID)
+ if err != nil {
+ return err
+ }
+
+ if len(conditions) == 0 {
+ return nil
+ }
+
+ // Подготавливаем statement для вставки условий
+ stmt, err := tx.Prepare(`
+ INSERT INTO wishlist_conditions
+ (wishlist_item_id, task_condition_id, score_condition_id, display_order)
+ VALUES ($1, $2, $3, $4)
+ `)
+ if err != nil {
+ return err
+ }
+ defer stmt.Close()
+
+ for i, condition := range conditions {
+ displayOrder := i
+ if condition.DisplayOrder != nil {
+ displayOrder = *condition.DisplayOrder
+ }
+
+ var taskConditionID interface{}
+ var scoreConditionID interface{}
+
+ if condition.Type == "task_completion" {
+ if condition.TaskID == nil {
+ return fmt.Errorf("task_id is required for task_completion")
+ }
+
+ // Получаем или создаём task_condition
+ var tcID int
+ err := tx.QueryRow(`
+ SELECT id FROM task_conditions WHERE task_id = $1
+ `, *condition.TaskID).Scan(&tcID)
+
+ if err == sql.ErrNoRows {
+ // Создаём новое условие
+ err = tx.QueryRow(`
+ INSERT INTO task_conditions (task_id)
+ VALUES ($1)
+ ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id
+ RETURNING id
+ `, *condition.TaskID).Scan(&tcID)
+ if err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+
+ taskConditionID = tcID
+
+ } else if condition.Type == "project_points" {
+ if condition.ProjectID == nil || condition.RequiredPoints == nil {
+ return fmt.Errorf("project_id and required_points are required for project_points")
+ }
+
+ periodType := condition.PeriodType
+
+ // Получаем или создаём score_condition
+ var scID int
+ var periodTypeVal interface{}
+ if periodType != nil && *periodType != "" {
+ periodTypeVal = *periodType
+ } else {
+ // Пустая строка или nil = NULL для "за всё время"
+ periodTypeVal = nil
+ }
+
+ err := tx.QueryRow(`
+ SELECT id FROM score_conditions
+ WHERE project_id = $1
+ AND required_points = $2
+ AND (period_type = $3 OR (period_type IS NULL AND $3 IS NULL))
+ `, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID)
+
+ if err == sql.ErrNoRows {
+ // Создаём новое условие
+ err = tx.QueryRow(`
+ INSERT INTO score_conditions (project_id, required_points, period_type)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (project_id, required_points, period_type)
+ DO UPDATE SET project_id = EXCLUDED.project_id
+ RETURNING id
+ `, *condition.ProjectID, *condition.RequiredPoints, periodTypeVal).Scan(&scID)
+ if err != nil {
+ return err
+ }
+ } else if err != nil {
+ return err
+ }
+
+ scoreConditionID = scID
+ }
+
+ // Создаём связь
+ _, err = stmt.Exec(
+ wishlistItemID,
+ taskConditionID,
+ scoreConditionID,
+ displayOrder,
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// getWishlistHandler возвращает список желаний
+func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ includeCompleted := r.URL.Query().Get("include_completed") == "true"
+
+ items, err := a.getWishlistItemsWithConditions(userID, includeCompleted)
+ if err != nil {
+ log.Printf("Error getting wishlist items: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Группируем и сортируем
+ unlocked := make([]WishlistItem, 0)
+ locked := make([]WishlistItem, 0)
+ completed := make([]WishlistItem, 0)
+
+ for _, item := range items {
+ if item.Completed {
+ completed = append(completed, item)
+ } else if item.Unlocked {
+ unlocked = append(unlocked, item)
+ } else {
+ locked = append(locked, item)
+ }
+ }
+
+ // Сортируем внутри групп по цене (дорогие → дешёвые)
+ sort.Slice(unlocked, func(i, j int) bool {
+ priceI := 0.0
+ priceJ := 0.0
+ if unlocked[i].Price != nil {
+ priceI = *unlocked[i].Price
+ }
+ if unlocked[j].Price != nil {
+ priceJ = *unlocked[j].Price
+ }
+ return priceI > priceJ
+ })
+
+ sort.Slice(locked, func(i, j int) bool {
+ priceI := 0.0
+ priceJ := 0.0
+ if locked[i].Price != nil {
+ priceI = *locked[i].Price
+ }
+ if locked[j].Price != nil {
+ priceJ = *locked[j].Price
+ }
+ return priceI > priceJ
+ })
+
+ sort.Slice(completed, func(i, j int) bool {
+ priceI := 0.0
+ priceJ := 0.0
+ if completed[i].Price != nil {
+ priceI = *completed[i].Price
+ }
+ if completed[j].Price != nil {
+ priceJ = *completed[j].Price
+ }
+ return priceI > priceJ
+ })
+
+ response := WishlistResponse{
+ Unlocked: unlocked,
+ Locked: locked,
+ Completed: completed,
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(response)
+}
+
+// createWishlistHandler создаёт новое желание
+func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var req WishlistRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Printf("Error decoding wishlist request: %v", err)
+ sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if strings.TrimSpace(req.Name) == "" {
+ sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
+ return
+ }
+
+ tx, err := a.DB.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ var wishlistID int
+ err = tx.QueryRow(`
+ INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted)
+ VALUES ($1, $2, $3, $4, FALSE, FALSE)
+ RETURNING id
+ `, userID, strings.TrimSpace(req.Name), req.Price, req.Link).Scan(&wishlistID)
+
+ if err != nil {
+ log.Printf("Error creating wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Сохраняем условия
+ if len(req.UnlockConditions) > 0 {
+ err = a.saveWishlistConditions(tx, wishlistID, req.UnlockConditions)
+ if err != nil {
+ log.Printf("Error saving wishlist conditions: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if err := tx.Commit(); err != nil {
+ log.Printf("Error committing transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Получаем созданное желание с условиями
+ items, err := a.getWishlistItemsWithConditions(userID, false)
+ if err != nil {
+ log.Printf("Error getting created wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var createdItem *WishlistItem
+ for i := range items {
+ if items[i].ID == wishlistID {
+ createdItem = &items[i]
+ break
+ }
+ }
+
+ if createdItem == nil {
+ sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(createdItem)
+}
+
+// getWishlistItemHandler возвращает одно желание
+func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ items, err := a.getWishlistItemsWithConditions(userID, true)
+ if err != nil {
+ log.Printf("Error getting wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var item *WishlistItem
+ for i := range items {
+ if items[i].ID == itemID {
+ item = &items[i]
+ break
+ }
+ }
+
+ if item == nil {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(item)
+}
+
+// updateWishlistHandler обновляет желание
+func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Проверяем владельца
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, itemID).Scan(&ownerID)
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error checking wishlist ownership: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var req WishlistRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Printf("Error decoding wishlist request: %v", err)
+ sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if strings.TrimSpace(req.Name) == "" {
+ sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
+ return
+ }
+
+ tx, err := a.DB.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ _, err = tx.Exec(`
+ UPDATE wishlist_items
+ SET name = $1, price = $2, link = $3, updated_at = NOW()
+ WHERE id = $4 AND user_id = $5
+ `, strings.TrimSpace(req.Name), req.Price, req.Link, itemID, userID)
+
+ if err != nil {
+ log.Printf("Error updating wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error updating wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Сохраняем условия
+ err = a.saveWishlistConditions(tx, itemID, req.UnlockConditions)
+ if err != nil {
+ log.Printf("Error saving wishlist conditions: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ if err := tx.Commit(); err != nil {
+ log.Printf("Error committing transaction: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Получаем обновлённое желание
+ items, err := a.getWishlistItemsWithConditions(userID, true)
+ if err != nil {
+ log.Printf("Error getting updated wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error getting updated wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var updatedItem *WishlistItem
+ for i := range items {
+ if items[i].ID == itemID {
+ updatedItem = &items[i]
+ break
+ }
+ }
+
+ if updatedItem == nil {
+ sendErrorWithCORS(w, "Updated item not found", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(updatedItem)
+}
+
+// deleteWishlistHandler удаляет желание (soft delete)
+func (a *App) deleteWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Проверяем владельца
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, itemID).Scan(&ownerID)
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error checking wishlist ownership: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ _, err = a.DB.Exec(`
+ UPDATE wishlist_items
+ SET deleted = TRUE, updated_at = NOW()
+ WHERE id = $1 AND user_id = $2
+ `, itemID, userID)
+
+ if err != nil {
+ log.Printf("Error deleting wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error deleting wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Wishlist item deleted successfully",
+ })
+}
+
+// uploadWishlistImageHandler загружает картинку для желания
+func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ wishlistID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Проверяем владельца
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, wishlistID).Scan(&ownerID)
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error checking wishlist ownership: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Парсим multipart form (макс 5MB)
+ err = r.ParseMultipartForm(5 << 20)
+ if err != nil {
+ sendErrorWithCORS(w, "File too large (max 5MB)", http.StatusBadRequest)
+ return
+ }
+
+ file, _, err := r.FormFile("image")
+ if err != nil {
+ sendErrorWithCORS(w, "Error retrieving file", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ // Декодируем изображение
+ img, err := imaging.Decode(file)
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid image format", http.StatusBadRequest)
+ return
+ }
+
+ // Сжимаем до максимальной ширины 1200px (сохраняя пропорции)
+ if img.Bounds().Dx() > 1200 {
+ img = imaging.Resize(img, 1200, 0, imaging.Lanczos)
+ }
+
+ // Создаём директорию
+ uploadDir := fmt.Sprintf("/app/uploads/wishlist/%d", userID)
+ err = os.MkdirAll(uploadDir, 0755)
+ if err != nil {
+ log.Printf("Error creating directory: %v", err)
+ sendErrorWithCORS(w, "Error creating directory", http.StatusInternalServerError)
+ return
+ }
+
+ // Сохраняем как JPEG
+ filename := fmt.Sprintf("%d.jpg", wishlistID)
+ filepath := filepath.Join(uploadDir, filename)
+
+ dst, err := os.Create(filepath)
+ if err != nil {
+ log.Printf("Error creating file: %v", err)
+ sendErrorWithCORS(w, "Error saving file", http.StatusInternalServerError)
+ return
+ }
+ defer dst.Close()
+
+ // Кодируем в JPEG с качеством 85%
+ err = jpeg.Encode(dst, img, &jpeg.Options{Quality: 85})
+ if err != nil {
+ log.Printf("Error encoding image: %v", err)
+ sendErrorWithCORS(w, "Error encoding image", http.StatusInternalServerError)
+ return
+ }
+
+ // Обновляем путь в БД
+ imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
+ _, err = a.DB.Exec(`
+ UPDATE wishlist_items
+ SET image_path = $1, updated_at = NOW()
+ WHERE id = $2 AND user_id = $3
+ `, imagePath, wishlistID, userID)
+ if err != nil {
+ log.Printf("Error updating database: %v", err)
+ sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "image_url": imagePath,
+ })
+}
+
+// completeWishlistHandler помечает желание как завершённое
+func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Проверяем владельца
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, itemID).Scan(&ownerID)
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error checking wishlist ownership: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ _, err = a.DB.Exec(`
+ UPDATE wishlist_items
+ SET completed = TRUE, updated_at = NOW()
+ WHERE id = $1 AND user_id = $2
+ `, itemID, userID)
+
+ if err != nil {
+ log.Printf("Error completing wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error completing wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Wishlist item completed successfully",
+ })
+}
+
+// uncompleteWishlistHandler снимает отметку завершения
+func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ userID, ok := getUserIDFromContext(r)
+ if !ok {
+ sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ vars := mux.Vars(r)
+ itemID, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
+ return
+ }
+
+ // Проверяем владельца
+ var ownerID int
+ err = a.DB.QueryRow(`
+ SELECT user_id FROM wishlist_items
+ WHERE id = $1 AND deleted = FALSE
+ `, itemID).Scan(&ownerID)
+ if err == sql.ErrNoRows || ownerID != userID {
+ sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ log.Printf("Error checking wishlist ownership: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ _, err = a.DB.Exec(`
+ UPDATE wishlist_items
+ SET completed = FALSE, updated_at = NOW()
+ WHERE id = $1 AND user_id = $2
+ `, itemID, userID)
+
+ if err != nil {
+ log.Printf("Error uncompleting wishlist item: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error uncompleting wishlist item: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": true,
+ "message": "Wishlist item uncompleted successfully",
+ })
+}
+
+// LinkMetadataResponse структура ответа с метаданными ссылки
+type LinkMetadataResponse struct {
+ Title string `json:"title,omitempty"`
+ Image string `json:"image,omitempty"`
+ Price *float64 `json:"price,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+// extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL
+func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "OPTIONS" {
+ setCORSHeaders(w)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ setCORSHeaders(w)
+
+ var req struct {
+ URL string `json:"url"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.URL == "" {
+ sendErrorWithCORS(w, "URL is required", http.StatusBadRequest)
+ return
+ }
+
+ // Валидация URL
+ parsedURL, err := url.Parse(req.URL)
+ if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
+ sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
+ return
+ }
+
+ // HTTP клиент с таймаутом
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ httpReq, err := http.NewRequest("GET", req.URL, nil)
+ if err != nil {
+ log.Printf("Error creating request: %v", err)
+ sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError)
+ return
+ }
+
+ // Устанавливаем User-Agent (некоторые сайты блокируют запросы без него)
+ httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
+
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ log.Printf("Error fetching URL: %v", err)
+ sendErrorWithCORS(w, fmt.Sprintf("Error fetching URL: %v", err), http.StatusBadRequest)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadRequest)
+ return
+ }
+
+ // Ограничиваем размер ответа (первые 512KB)
+ limitedReader := io.LimitReader(resp.Body, 512*1024)
+ bodyBytes, err := io.ReadAll(limitedReader)
+ if err != nil {
+ log.Printf("Error reading response: %v", err)
+ sendErrorWithCORS(w, "Error reading response", http.StatusInternalServerError)
+ return
+ }
+
+ body := string(bodyBytes)
+ metadata := &LinkMetadataResponse{}
+
+ // Извлекаем Open Graph теги
+ ogTitleRe := regexp.MustCompile(`]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
+ ogTitleRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
+ ogImageRe := regexp.MustCompile(`]+property=["']og:image["'][^>]+content=["']([^"']+)["']`)
+ ogImageRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:image["']`)
+ ogDescRe := regexp.MustCompile(`]+property=["']og:description["'][^>]+content=["']([^"']+)["']`)
+ ogDescRe2 := regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+property=["']og:description["']`)
+
+ // og:title
+ if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Title = strings.TrimSpace(matches[1])
+ } else if matches := ogTitleRe2.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Title = strings.TrimSpace(matches[1])
+ }
+
+ // og:image
+ if matches := ogImageRe.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Image = strings.TrimSpace(matches[1])
+ } else if matches := ogImageRe2.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Image = strings.TrimSpace(matches[1])
+ }
+
+ // og:description
+ if matches := ogDescRe.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Description = strings.TrimSpace(matches[1])
+ } else if matches := ogDescRe2.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Description = strings.TrimSpace(matches[1])
+ }
+
+ // Если нет og:title, пытаемся взять
+ if metadata.Title == "" {
+ titleRe := regexp.MustCompile(`]*>([^<]+)`)
+ if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
+ metadata.Title = strings.TrimSpace(matches[1])
+ }
+ }
+
+ // Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
+ // Schema.org Product price
+ priceRe := regexp.MustCompile(`"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`)
+ if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 {
+ priceStr := strings.ReplaceAll(matches[1], ",", ".")
+ if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 {
+ metadata.Price = &price
+ }
+ }
+
+ // Нормализуем URL изображения (делаем абсолютным)
+ if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
+ baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
+ if strings.HasPrefix(metadata.Image, "//") {
+ metadata.Image = parsedURL.Scheme + ":" + metadata.Image
+ } else if strings.HasPrefix(metadata.Image, "/") {
+ metadata.Image = baseURL + metadata.Image
+ } else {
+ metadata.Image = baseURL + "/" + metadata.Image
+ }
+ }
+
+ // Декодируем HTML entities
+ metadata.Title = decodeHTMLEntities(metadata.Title)
+ metadata.Description = decodeHTMLEntities(metadata.Description)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(metadata)
+}
+
+// decodeHTMLEntities декодирует базовые HTML entities
+func decodeHTMLEntities(s string) string {
+ replacements := map[string]string{
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ """: "\"",
+ "'": "'",
+ "'": "'",
+ " ": " ",
+ "—": "—",
+ "–": "–",
+ "«": "«",
+ "»": "»",
+ }
+ for entity, char := range replacements {
+ s = strings.ReplaceAll(s, entity, char)
+ }
+ return s
+}
+
diff --git a/play-life-backend/migrations/019_add_wishlist.sql b/play-life-backend/migrations/019_add_wishlist.sql
new file mode 100644
index 0000000..59a1f02
--- /dev/null
+++ b/play-life-backend/migrations/019_add_wishlist.sql
@@ -0,0 +1,86 @@
+-- Migration: Add wishlist tables
+-- This script creates tables for wishlist management system
+-- Supports multiple unlock conditions per wishlist item (AND logic)
+
+-- ============================================
+-- Table: wishlist_items
+-- ============================================
+CREATE TABLE IF NOT EXISTS wishlist_items (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ price NUMERIC(10,2),
+ image_path VARCHAR(500),
+ link TEXT,
+ completed BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_id ON wishlist_items(user_id);
+CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_deleted ON wishlist_items(user_id, deleted);
+CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_completed ON wishlist_items(user_id, completed, deleted);
+
+-- ============================================
+-- Table: task_conditions
+-- ============================================
+-- Reusable conditions for task completion
+CREATE TABLE IF NOT EXISTS task_conditions (
+ id SERIAL PRIMARY KEY,
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT unique_task_condition UNIQUE (task_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_conditions_task_id ON task_conditions(task_id);
+
+-- ============================================
+-- Table: score_conditions
+-- ============================================
+-- Reusable conditions for project points
+CREATE TABLE IF NOT EXISTS score_conditions (
+ id SERIAL PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ required_points NUMERIC(10,4) NOT NULL,
+ period_type VARCHAR(20), -- 'week', 'month', 'year', NULL (all time)
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT unique_score_condition UNIQUE (project_id, required_points, period_type)
+);
+
+CREATE INDEX IF NOT EXISTS idx_score_conditions_project_id ON score_conditions(project_id);
+
+-- ============================================
+-- Table: wishlist_conditions
+-- ============================================
+-- Links wishlist items to unlock conditions
+CREATE TABLE IF NOT EXISTS wishlist_conditions (
+ id SERIAL PRIMARY KEY,
+ wishlist_item_id INTEGER NOT NULL REFERENCES wishlist_items(id) ON DELETE CASCADE,
+ task_condition_id INTEGER REFERENCES task_conditions(id) ON DELETE CASCADE,
+ score_condition_id INTEGER REFERENCES score_conditions(id) ON DELETE CASCADE,
+ display_order INTEGER DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT check_exactly_one_condition CHECK (
+ (task_condition_id IS NOT NULL AND score_condition_id IS NULL) OR
+ (task_condition_id IS NULL AND score_condition_id IS NOT NULL)
+ )
+);
+
+CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_item_id ON wishlist_conditions(wishlist_item_id);
+CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_item_order ON wishlist_conditions(wishlist_item_id, display_order);
+CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_task_condition_id ON wishlist_conditions(task_condition_id);
+CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_score_condition_id ON wishlist_conditions(score_condition_id);
+
+-- ============================================
+-- Comments for documentation
+-- ============================================
+COMMENT ON TABLE wishlist_items IS 'Wishlist items for users';
+COMMENT ON COLUMN wishlist_items.completed IS 'Flag indicating item was purchased/received';
+COMMENT ON COLUMN wishlist_items.image_path IS 'Path to image file relative to uploads root';
+
+COMMENT ON TABLE task_conditions IS 'Reusable unlock conditions based on task completion';
+COMMENT ON TABLE score_conditions IS 'Reusable unlock conditions based on project points';
+COMMENT ON TABLE wishlist_conditions IS 'Links between wishlist items and unlock conditions. Multiple conditions per item use AND logic.';
+COMMENT ON COLUMN wishlist_conditions.display_order IS 'Order for displaying conditions in UI';
+
diff --git a/play-life-web/nginx.conf b/play-life-web/nginx.conf
index 41db249..4ce09e3 100644
--- a/play-life-web/nginx.conf
+++ b/play-life-web/nginx.conf
@@ -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;
diff --git a/play-life-web/package-lock.json b/play-life-web/package-lock.json
index 3127b7a..389c8b6 100644
--- a/play-life-web/package-lock.json
+++ b/play-life-web/package-lock.json
@@ -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",
diff --git a/play-life-web/package.json b/play-life-web/package.json
index 4308c39..3b9aefe 100644
--- a/play-life-web/package.json
+++ b/play-life-web/package.json
@@ -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",
diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx
index f78c5af..ff8dbac 100644
--- a/play-life-web/src/App.jsx
+++ b/play-life-web/src/App.jsx
@@ -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() {
)}
+ {loadedTabs.wishlist && (
+
+
+
+ )}
+
+ {loadedTabs['wishlist-form'] && (
+
+
+
+ )}
+
+ {loadedTabs['wishlist-detail'] && (
+
+ setWishlistRefreshTrigger(prev => prev + 1)}
+ />
+
+ )}
+
{loadedTabs.profile && (
@@ -938,6 +983,28 @@ function AppContent() {
)}
+