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(`<title[^>]*>([^<]+)`) + 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() {
)} + + +
+ {item.image_url ? ( + {item.name} + ) : ( +
+ + + + + +
+ )} +
+ +
{item.name}
+ + {isFaded && !item.completed ? ( + renderUnlockCondition(item) + ) : ( + item.price &&
{formatPrice(item.price)}
+ )} +
+ ) + } + + if (loading) { + return ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + if (error) { + return ( +
+ fetchWishlist()} /> +
+ ) + } + + return ( +
+ {/* Кнопка добавления */} + + + {/* Основной список (разблокированные и заблокированные вместе) */} + {items.length > 0 && ( +
+ {items.map(renderItem)} +
+ )} + + {/* Завершённые */} +
+ +
+ {completedExpanded && ( + <> + {completedLoading ? ( +
+
+
+ ) : ( +
+ {completed.map(renderItem)} +
+ )} + + )} + + {/* Модальное окно для действий */} + {selectedItem && ( +
setSelectedItem(null)}> +
e.stopPropagation()}> +
+

{selectedItem.name}

+
+
+ + {!selectedItem.completed && selectedItem.unlocked && ( + + )} + +
+
+
+ )} +
+ ) +} + +export default Wishlist + diff --git a/play-life-web/src/components/WishlistDetail.css b/play-life-web/src/components/WishlistDetail.css new file mode 100644 index 0000000..4977bc6 --- /dev/null +++ b/play-life-web/src/components/WishlistDetail.css @@ -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; +} + diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx new file mode 100644 index 0000000..02a6ad0 --- /dev/null +++ b/play-life-web/src/components/WishlistDetail.jsx @@ -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 ( +
+

Условия разблокировки:

+ {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 ( +
+
+ + {isMet ? ( + + ) : ( + + )} + + {conditionText} +
+ {progress && progress.type === 'points' && !isMet && ( +
+
+
+
+
+ {Math.round(progress.current)} / {Math.round(progress.required)} +
+
+ )} +
+ ) + })} +
+ ) + } + + if (loadingWishlist) { + return ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + return ( +
+ +

{wishlistItem ? wishlistItem.name : 'Желание'}

+ +
+ {error && ( + + )} + + {!error && wishlistItem && ( + <> + {/* Изображение */} + {wishlistItem.image_url && ( +
+ {wishlistItem.name} +
+ )} + + {/* Цена */} + {wishlistItem.price && ( +
+ {formatPrice(wishlistItem.price)} +
+ )} + + {/* Ссылка */} + {wishlistItem.link && ( + + )} + + {/* Условия разблокировки */} + {renderUnlockConditions()} + + {/* Кнопка завершения */} + {wishlistItem.unlocked && !wishlistItem.completed && ( +
+ +
+ )} + + )} +
+ {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default WishlistDetail + diff --git a/play-life-web/src/components/WishlistForm.css b/play-life-web/src/components/WishlistForm.css new file mode 100644 index 0000000..a762897 --- /dev/null +++ b/play-life-web/src/components/WishlistForm.css @@ -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); + } +} + diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx new file mode 100644 index 0000000..5e51ee4 --- /dev/null +++ b/play-life-web/src/components/WishlistForm.jsx @@ -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 ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + return ( +
+ +

{wishlistId ? 'Редактировать желание' : 'Новое желание'}

+ +
+
+ +
+ setLink(e.target.value)} + placeholder="https://..." + className="form-input" + disabled={fetchingMetadata} + /> + +
+
+ +
+ + setName(e.target.value)} + required + className="form-input" + /> +
+ +
+ + setPrice(e.target.value)} + placeholder="0.00" + className="form-input" + /> +
+ +
+ + {imageUrl && !showCropper && ( +
+ Preview + +
+ )} + {!imageUrl && ( + + )} +
+ + {showCropper && ( +
+
+ +
+
+ +
+
+ + +
+
+ )} + +
+ + {unlockConditions.length > 0 && ( +
+ {unlockConditions.map((cond, idx) => ( +
+ + {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' ? 'месяц' : 'год'}` : ''}`} + + +
+ ))} +
+ )} + +
+ + {error &&
{error}
} + +
+ +
+
+ + {showConditionForm && ( + setShowConditionForm(false)} + /> + )} + + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +// Компонент формы условия разблокировки +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 ( +
+
e.stopPropagation()}> +

Добавить условие разблокировки

+
+
+ + +
+ + {type === 'task_completion' && ( +
+ + +
+ )} + + {type === 'project_points' && ( + <> +
+ + +
+
+ + setRequiredPoints(e.target.value)} + className="form-input" + required + /> +
+
+ + +
+ + )} + +
+ + +
+
+
+
+ ) +} + +export default WishlistForm + diff --git a/play-life-web/src/components/auth/AuthContext.jsx b/play-life-web/src/components/auth/AuthContext.jsx index cd488e8..e8df97b 100644 --- a/play-life-web/src/components/auth/AuthContext.jsx +++ b/play-life-web/src/components/auth/AuthContext.jsx @@ -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) {