diff --git a/.DS_Store b/.DS_Store index 81bd850..b38128b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.cursor/rules/fastapinextjs.mdc b/.cursor/rules/fastapinextjs.mdc index 9645761..a520a8e 100644 --- a/.cursor/rules/fastapinextjs.mdc +++ b/.cursor/rules/fastapinextjs.mdc @@ -3,129 +3,162 @@ description: globs: alwaysApply: true --- -Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**. +Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд). -### Ключевые принципы +Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст. -- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript. -- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы. -- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода. -- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`). -- Следуйте правильным **соглашениям об именовании**: - - Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`). - - Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`). +Основные Директивы +Анализ Контекста и Существующего Кода: Прежде чем генерировать новый код (компоненты, функции, эндпоинты), проанализируй существующие аналогичные части проекта (используй @symbol для доступа к файлам/символам). Следуй установленным паттернам, структуре и стилю. Консистентность с текущей кодовой базой — ключ. -- никогда не запускай сервера бекенд и фронтенд, я сам это делаю! -- комментируй что ты делаешь +Лаконичность, Оптимизация, Точность: Пиши лаконичный и оптимизированный код. Отвечай технически точно, кратко и по делу. Приводи конкретные, рабочие примеры кода на Python и TypeScript. -### Структура проекта +Стиль Программирования: -- **Фронтенд**: - - **Язык**: TypeScript - - **Фреймворк**: Next.js 15 с App Router - - **UI Библиотеки**: Tailwind CSS, Shadcn UI, Radix UI - - **Директории**: - - `frontend/app/`: Основной код с маршрутизацией (App Router) - - `frontend/components/`: Компоненты React - - `frontend/hooks/`: React хуки - - `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда - - `frontend/public/`: Статические файлы - - `frontend/styles/`: CSS стили - - **Конфигурационные файлы**: - - `next.config.mjs` - - `tsconfig.json` - - `tailwind.config.ts` - - `postcss.config.mjs` +Функциональный/Декларативный: Предпочитай эти паттерны. Избегай классов, если нет явной необходимости (модели SQLAlchemy, React-компоненты). - - `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда - api.ts - Основной файл для работы с API. Содержит базовую функцию fetchApi, которая выполняет HTTP-запросы и обрабатывает ответы сервера. Также определяет основные интерфейсы для работы с API. - auth.ts - Модуль для аутентификации пользователей. Содержит функции для входа, регистрации, выхода из системы, сброса пароля. - users.ts - Модуль для работы с данными пользователей. Содержит функции для получения профиля, обновления данных пользователя, управления адресами. - cart.ts - Модуль для работы с корзиной. Позволяет получать состояние корзины, добавлять, обновлять и удалять товары из корзины. - catalog.ts - Большой модуль для работы с каталогом товаров на стороне пользователя. Содержит функции для получения товаров, категорий, коллекций и работы с ними. - catalog-admin.ts - Модуль для работы с каталогом товаров на стороне администратора. Содержит функции для управления категориями и коллекциями. - orders.ts - Модуль для работы с заказами. Содержит функции для получения списка заказов, создания новых заказов, обновления статуса и т.д. - analytics.ts - Модуль для работы с аналитикой. Содержит функции для логирования событий, получения отчетов и отслеживания пользовательской активности. - utils.ts - Вспомогательные функции для форматирования данных, работы с датами, ценами и другими общими задачами. - auth.tsx - React-компоненты для аутентификации (контекст, провайдеры и т.д.). +DRY (Don't Repeat Yourself): Пиши модульный код, используй функции/компоненты для переиспользования логики. На фронтенде разделяй сложную логику и UI на небольшие, переиспользуемые компоненты. +Именование: -- **Бэкенд**: - - **Язык**: Python - - **Фреймворк**: FastAPI - - **База данных**: PostgreSQL - - **ORM**: SQLAlchemy 2.0 - - **Директории**: - - `backend/app/`: Основной код - - `models/`: Модели базы данных - - `repositories/`: Репозитории для работы с данными - - `schemas/`: Pydantic схемы - - `services/`: Бизнес-логика - - `routers/`: Endpoints API - - `backend/uploads/`: Загруженные файлы - - `backend/docs/`: документация проекта +Переменные: Описательные имена с вспомогательными глаголами (is_active, has_permission, isLoading, itemCount, fetchUsers). - - **Конфигурационные файлы**: - - `alembic.ini`: Конфигурация миграций - - `.env`: Переменные окружения +Файлы/Директории: Python: snake_case (routers/user_routes.py). TypeScript: kebab-case (components/auth-wizard/, lib/api-client.ts). +Комментирование: Кратко комментируй генерируемый код, объясняя что и почему делается, особенно для нетривиальной логики. -### Стиль кода и структура +Важные Ограничения: -**Бэкенд (Python/FastAPI)**: +НЕ ЗАПУСКАЙ СЕРВЕРЫ: Никогда не предлагай и не пытайся запускать frontend или backend серверы. Я делаю это сам. -- Используйте `def` для чистых функций и `async def` для асинхронных операций. -- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных. -- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем. -- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций. -- **Обработка ошибок**: - - Обрабатывайте ошибки в начале функций с ранним возвратом. - - Используйте защитные условия и избегайте глубоко вложенных условий. - - Реализуйте правильное логирование и пользовательские типы ошибок. +Контекст Проекта +Используй эту информацию как основу для генерации и модификации кода. -**Фронтенд (TypeScript/React/Next.js)**: +1. Структура Проекта: -- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них. -- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript. Создавай компоненты где они нужны -- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии. -- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно: - - Предпочитайте серверные компоненты, где это возможно - - Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей - - Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности +. +├── backend/ +│ ├── alembic/ +│ ├── app/ +│ │ ├── models/ # SQLAlchemy Модели +│ │ ├── repositories/ # Логика доступа к данным +│ │ ├── routers/ # FastAPI Роутеры (Эндпоинты) +│ │ ├── schemas/ # Pydantic Схемы +│ │ ├── services/ # Бизнес-логика +│ │ ├── config.py +│ │ ├── core.py +│ │ └── main.py +│ ├── docs/ # Документация (.md) +│ ├── uploads/ # Загруженные файлы (статичные) +│ ├── .env +│ ├── alembic.ini +│ └── requirements.txt +├── frontend/ +│ ├── app/ # Next.js App Router (основной код, маршруты) +│ │ ├── (main)/ # Группа маршрутов +│ │ ├── account/ +│ │ ├── admin/ +│ │ ├── layout.tsx +│ │ └── globals.css +│ ├── components/ # React Компоненты (UI, layout, etc.) +│ │ ├── admin/ +│ │ ├── cart/ +│ │ ├── ui/ # Shadcn UI компоненты +│ │ └── ... +│ ├── hooks/ # React Хуки (use...) +│ ├── lib/ # Утилиты, API клиенты, константы +│ │ ├── api.ts # Базовый API клиент (fetchApi) +│ │ ├── auth.ts # Функции API для аутентификации +│ │ ├── ... (и другие модули API: users, cart, catalog, etc.) +│ │ ├── utils.ts # Общие утилиты +│ │ └── auth.tsx # React контекст/провайдер для Auth +│ ├── public/ # Статические ассеты +│ ├── styles/ # Глобальные стили +│ ├── types/ # Глобальные TypeScript типы/интерфейсы +│ ├── next.config.mjs +│ ├── tsconfig.json +│ ├── tailwind.config.ts +│ ├── postcss.config.mjs +│ └── package.json +├── docker-compose.yml +└── package.json +Use code with caution. +2. Спецификация Backend API (OpenAPI 3.1.0): -### Оптимизация производительности +Полная спецификация OpenAPI JSON должна быть доступна Cursor как контекст. -**Бэкенд**: +Ключевые эндпоинты: Auth, Users, Addresses, Catalog (Categories, Collections, Products, Variants, Images, Sizes), Cart, Orders, Reviews, Content, Analytics. -- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции. -- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных. -- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API. +Аутентификация: OAuth2 Password Bearer. -**Фронтенд**: +3. Документация Frontend API Client (frontend/lib/): -- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг. -- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image. -- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID). +Документация по модулям auth.ts, users.ts и т.д. должна быть доступна Cursor как контекст. -### Проектные соглашения +Содержит функции-обертки для вызова API бэкенда. -**Бэкенд**: +Стиль Кода и Паттерны +Бэкенд (Python / FastAPI / SQLAlchemy): -1. Следуйте **принципам проектирования RESTful API**. -2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами. -3. Используйте **SQLAlchemy 2.0** для функций ORM. -4. Обеспечьте правильную настройку **CORS** для локальной разработки. -5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API. +Функции: def / async def. -**Фронтенд**: +Типизация: Обязательные аннотации типов. Pydantic (schemas/) для API. -1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов. -2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами. -3. Используйте хуки React эффективно, избегая ненужных рендеров. -4. Реализуйте интернационализацию для поддержки русского языка. +Структура: routers, services, repositories, models, schemas. -### Тестирование и развертывание +RORO Паттерн: Принимай/возвращай объекты (схемы/модели). + +Обработка Ошибок: + +Ранний возврат / guard clauses. Избегай вложенности if. + +Используй стандартные HTTPException FastAPI для ошибок API. + +В сервисах/репозиториях используй try...except для отлова ожидаемых runtime-ошибок (e.g., NoResultFound от SQLAlchemy). Логируй ошибки. + +Зависимости: FastAPI Dependency Injection (Depends()). + +ORM: SQLAlchemy 2.0 (async Session). + +Фронтенд (TypeScript / React / Next.js): + +TypeScript: Строгая типизация. interface для объектов, type для остального. Избегай enum, используй as const или union types. unknown лучше any. + +Компоненты React: + +Функциональные компоненты с хуками. Строгая типизация props. + +Компонентный подход: Активно разбивай UI и логику на меньшие, переиспользуемые компоненты, особенно при работе с Shadcn UI. Создавай компоненты рядом с местом использования или в components/, если они общие. + +Next.js App Router: + +Приоритет Серверным Компонентам (RSC). + +"use client": Только для интерактива/хуков/browser API. Минимизируй их размер. + +Suspense: Используй для асинхронной загрузки и улучшения UX. + +Стилизация: Tailwind CSS first. Shadcn UI / Radix UI для примитивов. Mobile-first. + +API Взаимодействие: Используй функции из frontend/lib/*.ts. Обрабатывай состояния isLoading / error. + +Обработка Ошибок: В местах вызова API и в UI используй try...catch. Предоставляй обратную связь пользователю (например, через react-hot-toast или аналоги) и логируй ошибки в консоль или систему мониторинга. + +Оптимизация Производительности +Бэкенд: Асинхронность, оптимизация запросов SQLAlchemy, пагинация. Рассмотри кэширование, если потребуется. + +Фронтенд: Динамические импорты (next/dynamic), next/image, минимизация клиентского JS, Core Web Vitals. + +Проектные Соглашения +API: RESTful. + +CORS: Настроен для localhost. + +Аутентификация/Авторизация: Реализована (OAuth2). + +Тестирование и Развертывание +Пиши код, который легко тестировать (чистые функции, DI), но не генерируй тесты по умолчанию. + +Используй Docker и docker-compose.yml. + +Обеспечивай валидацию и санитизацию данных. -- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда. -- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства. -- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении. diff --git a/Logo DRESSED FOR SUCCESS/.DS_Store b/Logo DRESSED FOR SUCCESS/.DS_Store index 44ca8f1..a0b50e0 100644 Binary files a/Logo DRESSED FOR SUCCESS/.DS_Store and b/Logo DRESSED FOR SUCCESS/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/logo_horizontal.svg b/Logo DRESSED FOR SUCCESS/logo_horizontal.svg new file mode 100644 index 0000000..f2d70e9 --- /dev/null +++ b/Logo DRESSED FOR SUCCESS/logo_horizontal.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logo DRESSED FOR SUCCESS/Дополнительная версия/.DS_Store b/Logo DRESSED FOR SUCCESS/Дополнительная версия/.DS_Store index 0c991f7..754a241 100644 Binary files a/Logo DRESSED FOR SUCCESS/Дополнительная версия/.DS_Store and b/Logo DRESSED FOR SUCCESS/Дополнительная версия/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/Знак/.DS_Store b/Logo DRESSED FOR SUCCESS/Знак/.DS_Store index 891540d..bc40ab8 100644 Binary files a/Logo DRESSED FOR SUCCESS/Знак/.DS_Store and b/Logo DRESSED FOR SUCCESS/Знак/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/Название/.DS_Store b/Logo DRESSED FOR SUCCESS/Название/.DS_Store index 0672773..225543b 100644 Binary files a/Logo DRESSED FOR SUCCESS/Название/.DS_Store and b/Logo DRESSED FOR SUCCESS/Название/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/Основная версия/.DS_Store b/Logo DRESSED FOR SUCCESS/Основная версия/.DS_Store index 3582698..f949b2b 100644 Binary files a/Logo DRESSED FOR SUCCESS/Основная версия/.DS_Store and b/Logo DRESSED FOR SUCCESS/Основная версия/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/Сокращенная версия 1/.DS_Store b/Logo DRESSED FOR SUCCESS/Сокращенная версия 1/.DS_Store index f2d3a54..8d5d867 100644 Binary files a/Logo DRESSED FOR SUCCESS/Сокращенная версия 1/.DS_Store and b/Logo DRESSED FOR SUCCESS/Сокращенная версия 1/.DS_Store differ diff --git a/Logo DRESSED FOR SUCCESS/Сокращенная версия 2/.DS_Store b/Logo DRESSED FOR SUCCESS/Сокращенная версия 2/.DS_Store index 0e5e79e..e0d0307 100644 Binary files a/Logo DRESSED FOR SUCCESS/Сокращенная версия 2/.DS_Store and b/Logo DRESSED FOR SUCCESS/Сокращенная версия 2/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store index 24cf716..d9b8b2d 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/alembic/.DS_Store b/backend/alembic/.DS_Store index d2f286c..87e1d7d 100644 Binary files a/backend/alembic/.DS_Store and b/backend/alembic/.DS_Store differ diff --git a/backend/app/.DS_Store b/backend/app/.DS_Store index 7743b76..62688d5 100644 Binary files a/backend/app/.DS_Store and b/backend/app/.DS_Store differ diff --git a/backend/app/.env b/backend/app/.env new file mode 100644 index 0000000..83deb68 --- /dev/null +++ b/backend/app/.env @@ -0,0 +1,4 @@ +CDEK_LOGIN=q8AQtmLL7kPg6TuDo1eB2uqelJS4tHn2 +CDEK_PASSWORD=RmAmgvSgSl1yirlz9QupbzOJVqhCxcP5 +# CDEK_BASE_URL=https://api.cdek.ru/v2 +CDEK_BASE_URL=https://api.edu.cdek.ru/v2 diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc index 7e31dfc..f7b0c70 100644 Binary files a/backend/app/__pycache__/config.cpython-310.pyc and b/backend/app/__pycache__/config.cpython-310.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index 5aa6aba..afa5f82 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,7 +16,7 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней # Настройки базы данных -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db") +# DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db") # Настройки почты MAIL_USERNAME = os.getenv("MAIL_USERNAME", "test@example.com") @@ -79,6 +79,11 @@ class Settings(BaseSettings): PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "") PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "") + # Настройки для CDEK API + CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login") + CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass") + CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2") + # Настройки для отправки email (пример) SMTP_SERVER: str = MAIL_SERVER SMTP_PORT: int = MAIL_PORT diff --git a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc index 9eb2f7a..e475609 100644 Binary files a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/order_repo.py b/backend/app/repositories/order_repo.py index af09846..90cb5bb 100644 --- a/backend/app/repositories/order_repo.py +++ b/backend/app/repositories/order_repo.py @@ -5,7 +5,7 @@ from typing import List, Optional, Dict, Any from datetime import datetime from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod -from app.models.catalog_models import Product, ProductImage, ProductVariant +from app.models.catalog_models import Product, ProductImage, ProductVariant, Size from app.models.user_models import User, UserAddress from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate @@ -172,9 +172,6 @@ def get_all_orders( def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан - print(f"Начало создания заказа для пользователя {user_id}") - print(f"Данные заказа: {order}") - if order.shipping_address_id: address = db.query(UserAddress).filter( UserAddress.id == order.shipping_address_id, @@ -182,12 +179,10 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: ).first() if not address: - print(f"Адрес доставки {order.shipping_address_id} не найден для пользователя {user_id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Указанный адрес доставки не найден" ) - print(f"Адрес доставки найден: {address}") # Создаем новый заказ new_order = Order( @@ -202,21 +197,17 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: db.add(new_order) db.flush() # Получаем ID заказа - print(f"Создан заказ с ID: {new_order.id}") - # Получаем элементы корзины пользователя cart_items = [] # Если указаны конкретные элементы корзины if order.cart_items: - print(f"Используем указанные элементы корзины: {order.cart_items}") cart_items = db.query(CartItem).filter( CartItem.id.in_(order.cart_items), CartItem.user_id == user_id ).all() # Если указаны прямые элементы заказа elif order.items: - print(f"Используем прямые элементы заказа: {order.items}") # Создаем элементы заказа напрямую for item_data in order.items: # Проверяем, что вариант существует @@ -228,117 +219,172 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: detail=f"Вариант товара с ID {item_data.variant_id} не найден" ) + # Получаем продукт для варианта + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + db.rollback() + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт для варианта с ID {item_data.variant_id} не найден" + ) + + # Определяем цену для товара (используем discount_price если есть, иначе price) + price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price + # Создаем элемент заказа order_item = OrderItem( order_id=new_order.id, - product_id=variant.product_id, variant_id=variant.id, quantity=item_data.quantity, - price=item_data.price + price=price ) db.add(order_item) - new_order.total_amount += order_item.price * order_item.quantity + new_order.total_amount += price * item_data.quantity # Иначе используем все элементы корзины пользователя else: - print(f"Используем все элементы корзины пользователя") cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all() # Если используем элементы корзины if cart_items: - print(f"Найдено {len(cart_items)} элементов корзины") # Создаем элементы заказа из элементов корзины for cart_item in cart_items: - # Получаем вариант товара и его цену + # Получаем вариант продукта variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() if not variant: - db.rollback() - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Вариант товара с ID {cart_item.variant_id} не найден" - ) + continue - # Определяем цену (используем скидочную цену, если она есть) - price = variant.discount_price if variant.discount_price else variant.price + # Получаем продукт + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + continue + + # Определяем цену для товара (используем discount_price если есть, иначе price) + price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price # Создаем элемент заказа order_item = OrderItem( order_id=new_order.id, - variant_id=variant.id, + variant_id=cart_item.variant_id, quantity=cart_item.quantity, price=price ) db.add(order_item) - - # Обновляем общую сумму заказа new_order.total_amount += price * cart_item.quantity - - # Удаляем элемент из корзины - db.delete(cart_item) + + # Очищаем корзину пользователя после создания заказа + if cart_items and not order.cart_items: + # Если используем все элементы корзины, очищаем всю корзину + db.query(CartItem).filter(CartItem.user_id == user_id).delete() + elif order.cart_items: + # Если используем только выбранные элементы корзины, удаляем только их + db.query(CartItem).filter( + CartItem.id.in_([item.id for item in cart_items]), + CartItem.user_id == user_id + ).delete(synchronize_session=False) + + # Проверяем, были ли добавлены элементы заказа + if new_order.total_amount == 0: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Не удалось создать заказ: корзина пуста или товары недоступны" + ) try: db.commit() db.refresh(new_order) - print(f"Заказ успешно создан: {new_order.id}, общая сумма: {new_order.total_amount}") return new_order except Exception as e: db.rollback() - print(f"Ошибка при создании заказа: {str(e)}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Ошибка при создании заказа: {str(e)}" ) -def update_order(db: Session, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Order: - db_order = get_order(db, order_id) - if not db_order: +def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order: + """ + Обновляет информацию о заказе. + + Args: + db: Сессия базы данных + order_id: ID заказа + order_update: Данные для обновления + is_admin: Флаг, указывающий, является ли пользователь администратором + + Returns: + Обновленный заказ + + Raises: + HTTPException: Если заказ не найден или нет прав на обновление + """ + # Получаем заказ + order = db.query(Order).filter(Order.id == order_id).first() + if not order: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден" ) - # Обычные пользователи могут только отменить заказ - if not is_admin and order.status and order.status != OrderStatus.CANCELLED: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Недостаточно прав для изменения статуса заказа" - ) + # Проверяем возможность обновления статуса + if order_update.status: + # Если заказ уже отменен или доставлен, нельзя менять его статус + if order.status in [OrderStatus.CANCELLED, OrderStatus.DELIVERED, OrderStatus.REFUNDED]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Невозможно изменить статус заказа из {order.status}" + ) + + # Если пользователь не админ, он может только отменить заказ + if not is_admin and order_update.status != OrderStatus.CANCELLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Пользователи могут только отменить заказ" + ) + + # Если пользователь хочет отменить заказ, проверяем возможность отмены + if order_update.status == OrderStatus.CANCELLED: + if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Нельзя отменить заказ, который уже отправлен или доставлен" + ) - # Нельзя изменить статус заказа с CANCELLED или REFUNDED - if db_order.status in [OrderStatus.CANCELLED, OrderStatus.REFUNDED] and order.status: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Нельзя изменить статус заказа, который уже {db_order.status}" - ) + # Проверяем другие поля для обновления + if not is_admin: + # Обычные пользователи могут обновлять только статус (отмена) + for field in ["shipping_address_id", "payment_method", "payment_details", "tracking_number"]: + if getattr(order_update, field) is not None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Пользователи не могут изменять поле {field}" + ) - # Обновляем только предоставленные поля - update_data = order.dict(exclude_unset=True) + # Обновляем поля заказа + update_data = order_update.dict(exclude_unset=True) - # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан - if "shipping_address_id" in update_data and update_data["shipping_address_id"]: - address = db.query(UserAddress).filter( - UserAddress.id == update_data["shipping_address_id"], - UserAddress.user_id == db_order.user_id - ).first() + # Если указан адрес доставки, проверяем его существование + if "shipping_address_id" in update_data: + address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first() if not address: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Адрес доставки не найден или не принадлежит пользователю" + detail="Указанный адрес доставки не найден" ) # Применяем обновления for key, value in update_data.items(): - setattr(db_order, key, value) + setattr(order, key, value) try: db.commit() - db.refresh(db_order) - return db_order - except IntegrityError: + db.refresh(order) + return order + except Exception as e: db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ошибка при обновлении заказа" + detail=f"Ошибка при обновлении заказа: {str(e)}" ) @@ -371,128 +417,173 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool: # Функции для получения детальной информации def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, Any]]: - cart_items = get_user_cart(db, user_id) + """ + Получает корзину пользователя с детальной информацией о товарах. + """ + # Получаем элементы корзины пользователя + cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all() result = [] - for item in cart_items: - # Получаем информацию о варианте и продукте - variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + for cart_item in cart_items: + # Получаем вариант продукта + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() if not variant: continue + # Получаем продукт product = db.query(Product).filter(Product.id == variant.product_id).first() if not product: continue + # Получаем размер варианта + size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None + size_name = size.name if size else '' + # Получаем основное изображение продукта - image = db.query(ProductImage).filter( + product_image = None + primary_image = db.query(ProductImage).filter( ProductImage.product_id == product.id, ProductImage.is_primary == True ).first() - # Если нет основного изображения, берем первое доступное - if not image: - image = db.query(ProductImage).filter( - ProductImage.product_id == product.id - ).first() + if primary_image: + product_image = primary_image.image_url + else: + # Если нет основного изображения, берем любое + any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first() + if any_image: + product_image = any_image.image_url - # Рассчитываем цену - price = variant.discount_price if variant.discount_price else variant.price + # Определяем цену товара (используем discount_price если есть, иначе price) + price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price - # Формируем результат - result.append({ - "id": item.id, - "user_id": item.user_id, - "variant_id": item.variant_id, - "slug": product.slug, - "quantity": item.quantity, - "created_at": item.created_at, - "updated_at": item.updated_at, + # Формируем элемент корзины с деталями + cart_item_details = { + "id": cart_item.id, + "user_id": cart_item.user_id, + "variant_id": cart_item.variant_id, + "quantity": cart_item.quantity, + "created_at": cart_item.created_at, + "updated_at": cart_item.updated_at, "product_id": product.id, "product_name": product.name, "product_price": price, - "product_image": image.image_url if image else None, - "variant_name": variant.name, - "total_price": price * item.quantity - }) + "product_image": product_image, + "slug": product.slug, + "variant_name": size_name, + "total_price": price * cart_item.quantity + } + + result.append(cart_item_details) return result def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]: + """ + Получает заказ по ID с детальной информацией о товарах и адресе доставки. + """ + # Получаем заказ order = db.query(Order).filter(Order.id == order_id).first() if not order: return None - - # Получаем все товары в заказе с их вариантами - order_items = ( - db.query(OrderItem) - .filter(OrderItem.order_id == order_id) - .all() - ) - - items = [] - for item in order_items: - # Получаем информацию о товаре и его варианте - variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() - if not variant: - continue - - product = db.query(Product).filter(Product.id == variant.product_id).first() - if not product: - continue - - # Получаем основное изображение продукта - image = db.query(ProductImage).filter( - ProductImage.product_id == product.id, - ProductImage.is_primary == True - ).first() - - # Если нет основного изображения, берем первое доступное - if not image: - image = db.query(ProductImage).filter( - ProductImage.product_id == product.id - ).first() - - items.append({ - "id": item.id, - "product": { - "id": product.id, - "name": product.name, - "image": image.image_url if image else None, - "slug": product.slug - }, - "variant_name": variant.size.name if variant.size else None, - "quantity": item.quantity, - "price": item.price - }) - + + # Получаем информацию о пользователе + user = db.query(User).filter(User.id == order.user_id).first() + user_email = user.email if user else None + user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None + # Получаем адрес доставки shipping_address = None if order.shipping_address_id: address = db.query(UserAddress).filter(UserAddress.id == order.shipping_address_id).first() if address: shipping_address = { + "id": address.id, "address_line1": address.address_line1, "address_line2": address.address_line2, "city": address.city, + "state": address.state, "postal_code": address.postal_code, - "country": address.country + "country": address.country, + "is_default": address.is_default } - + + # Получаем элементы заказа с информацией о продуктах + items = [] + order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all() + + for item in order_items: + # Получаем вариант продукта + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + variant_name = None + size_name = None + + if variant: + # Получаем размер варианта + size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None + if size: + size_name = size.name + variant_name = f"{size.name}" + + # Получаем информацию о продукте + product = None + product_name = "Удаленный продукт" + product_image = None + + if variant: + product = db.query(Product).filter(Product.id == variant.product_id).first() + + if product: + product_name = product.name + + # Получаем основное изображение продукта + primary_image = db.query(ProductImage).filter( + ProductImage.product_id == product.id, + ProductImage.is_primary == True + ).first() + + if primary_image: + product_image = primary_image.image_url + else: + # Если нет основного изображения, берем любое + any_image = db.query(ProductImage).filter( + ProductImage.product_id == product.id + ).first() + + if any_image: + product_image = any_image.image_url + + # Добавляем информацию об элементе заказа + items.append({ + "id": item.id, + "order_id": item.order_id, + "variant_id": item.variant_id, + "quantity": item.quantity, + "price": item.price, + "product_id": variant.product_id if variant else None, + "product_name": product_name, + "product_image": product_image, + "variant_name": variant_name, + "size": size_name, + "total_price": item.price * item.quantity + }) + + # Формируем результат return { "id": order.id, "user_id": order.user_id, - "user_name": f"{order.user.first_name} {order.user.last_name}" if order.user else None, - "user_email": order.user.email if order.user else None, + "user_email": user_email, + "user_name": user_name, "status": order.status, - "total": order.total_amount, - "created_at": order.created_at, - "updated_at": order.updated_at, - "items_count": len(items), - "items": items, + "total_amount": order.total_amount, + "shipping_address_id": order.shipping_address_id, "shipping_address": shipping_address, "payment_method": order.payment_method, + "payment_details": order.payment_details, "tracking_number": order.tracking_number, - "notes": order.notes + "notes": order.notes, + "created_at": order.created_at, + "updated_at": order.updated_at, + "items": items } \ No newline at end of file diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 09c6382..14396f3 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -8,6 +8,7 @@ from app.routers.order_router import order_router from app.routers.review_router import review_router from app.routers.content_router import content_router from app.routers.analytics_router import analytics_router +from app.routers.delivery_router import delivery_router # Создаем основной роутер router = APIRouter() @@ -20,4 +21,5 @@ router.include_router(cart_router) router.include_router(order_router) router.include_router(review_router) router.include_router(content_router) -router.include_router(analytics_router) \ No newline at end of file +router.include_router(analytics_router) +router.include_router(delivery_router) \ No newline at end of file diff --git a/backend/app/routers/__pycache__/__init__.cpython-310.pyc b/backend/app/routers/__pycache__/__init__.cpython-310.pyc index 617da1e..871cb75 100644 Binary files a/backend/app/routers/__pycache__/__init__.cpython-310.pyc and b/backend/app/routers/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/cart_router.cpython-310.pyc b/backend/app/routers/__pycache__/cart_router.cpython-310.pyc index f272fec..cfa8309 100644 Binary files a/backend/app/routers/__pycache__/cart_router.cpython-310.pyc and b/backend/app/routers/__pycache__/cart_router.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/delivery_router.cpython-310.pyc b/backend/app/routers/__pycache__/delivery_router.cpython-310.pyc new file mode 100644 index 0000000..8ff0f75 Binary files /dev/null and b/backend/app/routers/__pycache__/delivery_router.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/order_router.cpython-310.pyc b/backend/app/routers/__pycache__/order_router.cpython-310.pyc index 591cfb3..6507f2d 100644 Binary files a/backend/app/routers/__pycache__/order_router.cpython-310.pyc and b/backend/app/routers/__pycache__/order_router.cpython-310.pyc differ diff --git a/backend/app/routers/cart_router.py b/backend/app/routers/cart_router.py index 7c93262..a555f33 100644 --- a/backend/app/routers/cart_router.py +++ b/backend/app/routers/cart_router.py @@ -16,6 +16,12 @@ async def add_to_cart_endpoint( current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db) ): + """ + Добавляет товар в корзину пользователя. + + - **variant_id**: ID варианта продукта + - **quantity**: Количество товара + """ return services.add_to_cart(db, current_user.id, cart_item) @@ -26,19 +32,47 @@ async def update_cart_item_endpoint( current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db) ): + """ + Обновляет количество товара в корзине. + + - **cart_item_id**: ID элемента корзины + - **quantity**: Новое количество товара + """ return services.update_cart_item(db, current_user.id, cart_item_id, cart_item) @cart_router.delete("/items/{cart_item_id}", response_model=Dict[str, Any]) -async def remove_from_cart_endpoint(cart_item_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def remove_from_cart_endpoint( + cart_item_id: int, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Удаляет товар из корзины. + + - **cart_item_id**: ID элемента корзины + """ return services.remove_from_cart(db, current_user.id, cart_item_id) @cart_router.delete("/clear", response_model=Dict[str, Any]) -async def clear_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def clear_cart_endpoint( + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """Очищает всю корзину пользователя.""" return services.clear_cart(db, current_user.id) @cart_router.get("/", response_model=Dict[str, Any]) -async def get_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def get_cart_endpoint( + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Получает содержимое корзины пользователя. + + Возвращает список товаров в корзине с информацией о продуктах, + общую сумму и количество товаров. + """ return services.get_cart(db, current_user.id) \ No newline at end of file diff --git a/backend/app/routers/delivery_router.py b/backend/app/routers/delivery_router.py new file mode 100644 index 0000000..fbac1e1 --- /dev/null +++ b/backend/app/routers/delivery_router.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Request, Depends +import logging +from app.services import get_cdek_service +from app.services.delivery_service import CDEKService + +# Настройка логирования +logger = logging.getLogger(__name__) + +# Создаем роутер для доставки +delivery_router = APIRouter( + prefix="/delivery", + tags=["delivery"], + responses={404: {"description": "Not found"}}, +) + +@delivery_router.post("/cdek") +async def cdek_service_endpoint(request: Request, cdek_service: CDEKService = Depends(get_cdek_service)): + """ + Эндпоинт для обработки запросов виджета CDEK. + Объединяет query-параметры и тело запроса, затем передает их в сервис CDEKService. + + - **action**: Тип действия (offices, calculate) + - Для action=offices: Параметры для получения списка офисов + - Для action=calculate: Параметры для расчета стоимости доставки + """ + # Получаем query-параметры из URL + query_params = dict(request.query_params) + logger.info("CDEK эндпоинт: Получены query-параметры: %s", query_params) + + try: + # Пытаемся получить тело запроса как JSON + body = await request.json() + logger.info("CDEK эндпоинт: Получено тело запроса: %s", body) + except Exception as e: + logger.warning("CDEK эндпоинт: Не удалось получить JSON тело запроса: %s", str(e)) + body = {} + + # Объединяем данные (приоритет у тела запроса) + request_data = {**query_params, **body} + logger.info("CDEK эндпоинт: Объединенные данные запроса: %s", request_data) + + # Асинхронно обрабатываем запрос + logger.debug("CDEK эндпоинт: Передача запроса в сервис CDEKService") + response = await cdek_service.process(request_data) + logger.info("CDEK эндпоинт: Получен ответ от сервиса, статус: %s", response.status_code) + return response \ No newline at end of file diff --git a/backend/app/routers/order_router.py b/backend/app/routers/order_router.py index 97f7e20..6d58e12 100644 --- a/backend/app/routers/order_router.py +++ b/backend/app/routers/order_router.py @@ -6,40 +6,98 @@ from app.core import get_db, get_current_active_user from app import services from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order from app.models.user_models import User as UserModel -from app.repositories.order_repo import get_all_orders, get_user_orders +from app.models.order_models import OrderStatus # Роутер для заказов order_router = APIRouter(prefix="/orders", tags=["Заказы"]) @order_router.post("/", response_model=Dict[str, Any]) -async def create_order_endpoint(order: OrderCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def create_order_endpoint( + order: OrderCreate, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Создает новый заказ. + + - **shipping_address_id**: ID адреса доставки + - **payment_method**: Способ оплаты + - **notes**: Примечания к заказу (опционально) + - **cart_items**: Список ID элементов корзины (опционально) + - **items**: Прямые элементы заказа (опционально) + """ return services.create_order(db, current_user.id, order) @order_router.get("/{order_id}", response_model=Dict[str, Any]) -async def get_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def get_order_endpoint( + order_id: int, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Получает информацию о заказе по ID. + + - **order_id**: ID заказа + """ return services.get_order(db, current_user.id, order_id, current_user.is_admin) @order_router.put("/{order_id}", response_model=Dict[str, Any]) -async def update_order_endpoint(order_id: int, order: OrderUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def update_order_endpoint( + order_id: int, + order: OrderUpdate, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Обновляет информацию о заказе. + + - **order_id**: ID заказа + - **status**: Новый статус заказа (опционально, только для админов) + - **shipping_address_id**: ID нового адреса доставки (опционально, только для админов) + - **payment_method**: Новый способ оплаты (опционально, только для админов) + - **payment_details**: Детали оплаты (опционально, только для админов) + - **tracking_number**: Номер отслеживания (опционально, только для админов) + - **notes**: Примечания к заказу (опционально) + """ return services.update_order(db, current_user.id, order_id, order, current_user.is_admin) @order_router.post("/{order_id}/cancel", response_model=Dict[str, Any]) -async def cancel_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): +async def cancel_order_endpoint( + order_id: int, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) + ): + """ + Отменяет заказ. + + - **order_id**: ID заказа + """ return services.cancel_order(db, current_user.id, order_id) -@order_router.get("/", response_model=List[Order]) -async def get_orders( +@order_router.get("/", response_model=List[Dict[str, Any]]) +async def get_orders_endpoint( skip: int = 0, limit: int = 100, - status: Optional[str] = None, + status: Optional[OrderStatus] = None, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db) -): + ): + """ + Получает список заказов пользователя. + + - **skip**: Количество пропускаемых записей + - **limit**: Максимальное количество записей + - **status**: Фильтр по статусу заказа (опционально) + """ + # Преобразуем заказы в словари с детальной информацией if current_user.is_admin: - return get_all_orders(db, skip, limit, status) + orders = services.order_repo.get_all_orders(db, skip, limit, status) else: - return get_user_orders(db, current_user.id, skip, limit) \ No newline at end of file + orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit) + + # Получаем полную информацию о каждом заказе + return [services.order_repo.get_order_with_details(db, order.id) for order in orders] \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 3c6b600..c98de76 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -16,7 +16,7 @@ from app.services.catalog_service import ( from app.services.order_service import ( add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart, - create_order, get_order, update_order, cancel_order + create_order, get_order, update_order, cancel_order, ) from app.services.review_service import ( @@ -26,4 +26,9 @@ from app.services.review_service import ( from app.services.content_service import ( create_page, update_page, delete_page, get_page_by_slug, log_event, get_analytics_report -) \ No newline at end of file +) + +from app.services.delivery_service import get_cdek_service + +# Импорт репозиториев для прямого доступа +from app.repositories import order_repo \ No newline at end of file diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc index a36e9a9..21cec70 100644 Binary files a/backend/app/services/__pycache__/__init__.cpython-310.pyc and b/backend/app/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/delivery_service.cpython-310.pyc b/backend/app/services/__pycache__/delivery_service.cpython-310.pyc new file mode 100644 index 0000000..0687c0d Binary files /dev/null and b/backend/app/services/__pycache__/delivery_service.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/order_service.cpython-310.pyc b/backend/app/services/__pycache__/order_service.cpython-310.pyc index 15c0542..5119498 100644 Binary files a/backend/app/services/__pycache__/order_service.cpython-310.pyc and b/backend/app/services/__pycache__/order_service.cpython-310.pyc differ diff --git a/backend/app/services/delivery_service.py b/backend/app/services/delivery_service.py new file mode 100644 index 0000000..b4ba6ec --- /dev/null +++ b/backend/app/services/delivery_service.py @@ -0,0 +1,295 @@ +import time +import logging +import json +import httpx +from fastapi import HTTPException +from fastapi.responses import Response, JSONResponse +from app.config import settings + +# Настройка детального логирования +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +class CDEKService: + def __init__(self): + """ + Инициализация сервиса с учетными данными и базовым URL для API CDEK. + """ + self.login = settings.CDEK_LOGIN + self.secret = settings.CDEK_PASSWORD + self.base_url = settings.CDEK_BASE_URL + self.auth_token = None + self.request_data = {} + self.metrics = [] # Список для хранения метрик времени выполнения запросов + logger.debug("CDEKService инициализирован с логином: %s, базовым URL: %s", self.login, self.base_url) + + def start_metrics(self) -> float: + """ + Фиксирует время начала измерения метрики. + :return: Текущее монотонное время. + """ + return time.monotonic() + + def end_metrics(self, metric_name: str, metric_description: str, start: float) -> None: + """ + Вычисляет время выполнения операции и сохраняет метрику. + :param metric_name: Имя метрики. + :param metric_description: Описание метрики. + :param start: Время начала операции. + """ + duration = round((time.monotonic() - start) * 1000, 2) # время в мс + self.metrics.append({ + "name": metric_name, + "description": metric_description, + "time": duration + }) + logger.debug("Добавлена метрика: %s, длительность: %s мс", metric_name, duration) + + async def get_auth_token(self) -> None: + """ + Асинхронно запрашивает токен авторизации у CDEK API, используя клиентские учетные данные. + При удачном ответе сохраняет полученный access_token для дальнейших запросов. + """ + start = self.start_metrics() + url = f"{self.base_url}/oauth/token" + + # Формируем данные для запроса токена - точно как в PHP версии + payload = { + "grant_type": "client_credentials", + "client_id": self.login, + "client_secret": self.secret + } + + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "X-App-Name": "widget_pvz", + "X-App-Version": "3.11.1", + "User-Agent": "widget/3.11.1" + } + + logger.info("Запрос токена авторизации к CDEK API: URL=%s, данные=%s", url, payload) + + try: + async with httpx.AsyncClient() as client: + # Отправляем данные как form-data, как в PHP оригинале + response = await client.post(url, data=payload, headers=headers) + + logger.debug("Получен ответ авторизации: status=%s, headers=%s", + response.status_code, response.headers) + logger.debug("Тело ответа авторизации: %s", response.text) + + if response.status_code != 200: + logger.error("Неуспешный статус код авторизации: %s, ответ: %s", + response.status_code, response.text) + raise HTTPException( + status_code=500, + detail=f"Ошибка авторизации CDEK API: {response.status_code}" + ) + + result = response.json() + logger.debug("Разобранный JSON ответа авторизации: %s", result) + + if "access_token" not in result: + logger.error("Токен авторизации не получен. Ответ: %s", result) + raise HTTPException( + status_code=500, + detail="Сервер не авторизован для доступа к CDEK API: токен отсутствует" + ) + + self.auth_token = result["access_token"] + logger.info("Токен авторизации успешно получен: %s...", self.auth_token[:10]) + + except json.JSONDecodeError as e: + logger.error("Ошибка декодирования JSON: %s, тело ответа: %s", str(e), response.text) + raise HTTPException( + status_code=500, + detail=f"Ошибка декодирования ответа авторизации: {str(e)}" + ) + except Exception as e: + logger.error("Непредвиденная ошибка при авторизации: %s", str(e), exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Ошибка обработки ответа авторизации: {str(e)}" + ) + finally: + self.end_metrics("auth", "Server Auth Time", start) + + async def http_request( + self, method: str, data: dict, use_form_data: bool = False, use_json: bool = False + ) -> dict: + """ + Выполняет асинхронный HTTP-запрос к API CDEK. + :param method: Метод API (например, 'deliverypoints' или 'calculator/tarifflist'). + :param data: Параметры запроса. + :param use_form_data: Если True, данные отправляются в виде form-data. + :param use_json: Если True, данные отправляются в виде JSON. + :return: Словарь с результатом запроса и дополнительными заголовками. + """ + url = f"{self.base_url}/{method}" + headers = { + "Accept": "application/json", + "X-App-Name": "widget_pvz", + "X-App-Version": "3.11.1", + "User-Agent": "widget/3.11.1" + } + + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + + logger.debug("HTTP запрос: URL=%s, метод=%s, данные=%s, заголовки=%s", + url, "POST" if (use_json or use_form_data) else "GET", data, headers) + + try: + async with httpx.AsyncClient() as client: + if use_json: + headers["Content-Type"] = "application/json" + logger.info("Отправка POST запроса с JSON данными к %s: %s", url, data) + response = await client.post(url, json=data, headers=headers) + elif use_form_data: + headers["Content-Type"] = "application/x-www-form-urlencoded" + logger.info("Отправка POST запроса с form-data к %s: %s", url, data) + response = await client.post(url, data=data, headers=headers) + else: + logger.info("Отправка GET запроса к %s с параметрами: %s", url, data) + response = await client.get(url, params=data, headers=headers) + + logger.debug("Получен ответ: status=%s, headers=%s", + response.status_code, response.headers) + logger.debug("Тело ответа: %s", response.text[:500] + "..." if len(response.text) > 500 else response.text) + + # Фильтрация заголовков, начинающихся на "X-" + added_headers = [] + for key, value in response.headers.items(): + if key.startswith("X-"): + header_line = f"{key}: {value}" + added_headers.append(header_line) + logger.debug("Добавлен заголовок: %s", header_line) + + if response.status_code < 200 or response.status_code >= 300: + logger.error("Ошибка запроса к CDEK API. Код: %s, Ответ: %s", + response.status_code, response.text) + raise HTTPException( + status_code=response.status_code, + detail=f"Ошибка запроса к CDEK API: {response.text}" + ) + + return {"result": response.text, "addedHeaders": added_headers} + except Exception as e: + logger.error("Ошибка при выполнении HTTP запроса: %s", str(e), exc_info=True) + raise HTTPException(status_code=500, detail=f"Ошибка HTTP запроса: {str(e)}") + + async def get_offices(self) -> dict: + """ + Асинхронно запрашивает список пунктов выдачи (офисов) доставки. + :return: Результат запроса в виде словаря. + """ + start = self.start_metrics() + logger.info("Запрос списка офисов с данными: %s", self.request_data) + result = await self.http_request("deliverypoints", self.request_data) + self.end_metrics("office", "Offices Request", start) + return result + + async def calculate(self) -> dict: + """ + Асинхронно запрашивает расчет стоимости доставки. + :return: Результат расчета в виде словаря. + """ + start = self.start_metrics() + logger.info("Запрос расчета доставки с данными: %s", self.request_data) + result = await self.http_request("calculator/tarifflist", self.request_data, use_form_data=False, use_json=True) + self.end_metrics("calc", "Calculate Request", start) + return result + + async def process(self, request_data: dict) -> Response: + """ + Обрабатывает входящие данные, определяет action и выполняет соответствующий запрос. + :param request_data: Данные запроса (объединение query-параметров и тела запроса). + :return: Финальный HTTP-ответ с результатом. + """ + start = self.start_metrics() + logger.info("Обработка входящего запроса с данными: %s", request_data) + self.request_data = request_data + + # Проверяем, что передан обязательный параметр "action" + if "action" not in self.request_data: + logger.warning("Отсутствует обязательный параметр 'action'") + return self.send_validation_error("Action is required") + + # Получаем токен авторизации + try: + await self.get_auth_token() + except Exception as e: + logger.error("Ошибка при получении токена авторизации: %s", str(e)) + return self.send_validation_error(f"Ошибка авторизации: {str(e)}") + + action = self.request_data.get("action") + logger.info("Выполнение действия: %s", action) + + try: + if action == "offices": + result = await self.get_offices() + elif action == "calculate": + result = await self.calculate() + else: + logger.warning("Неизвестное действие: %s", action) + return self.send_validation_error("Unknown action") + except Exception as e: + logger.error("Ошибка при выполнении действия %s: %s", action, str(e)) + return self.send_validation_error(f"Ошибка выполнения действия: {str(e)}") + + logger.info("Действие %s успешно выполнено", action) + return self.send_response(result, start) + + def send_validation_error(self, message: str) -> JSONResponse: + """ + Формирует ответ с ошибкой валидации. + :param message: Текст ошибки. + :return: JSONResponse с кодом 400 и сообщением об ошибке. + """ + headers = { + "Content-Type": "application/json", + "X-Service-Version": "3.11.1" + } + logger.warning("Ошибка валидации: %s", message) + return JSONResponse(status_code=400, content={"message": message}, headers=headers) + + def send_response(self, data: dict, start: float) -> Response: + """ + Формирует окончательный HTTP-ответ, включая заголовки с метриками. + :param data: Данные, полученные от API CDEK. + :param start: Время начала обработки запроса. + :return: HTTP-ответ с нужными заголовками и данными. + """ + self.end_metrics("total", "Total Time", start) + headers = { + "Content-Type": "application/json", + "X-Service-Version": "3.11.1" + } + # Добавляем дополнительные заголовки из ответа CDEK API + for header_line in data.get("addedHeaders", []): + parts = header_line.split(":", 1) + if len(parts) == 2: + headers[parts[0].strip()] = parts[1].strip() + logger.debug("Добавлен заголовок ответа: %s = %s", parts[0].strip(), parts[1].strip()) + + # Формирование заголовка Server-Timing с данными о метриках + server_timing = "" + for metric in self.metrics: + server_timing += f'{metric["name"]};desc="{metric["description"]}";dur={metric["time"]},' + + if server_timing: + headers["Server-Timing"] = server_timing.rstrip(",") + logger.debug("Добавлен заголовок Server-Timing: %s", headers["Server-Timing"]) + + logger.info("Формирование окончательного ответа с %d заголовками", len(headers)) + return Response(content=data["result"], status_code=200, headers=headers, media_type="application/json") + + +# Функция для создания экземпляра сервиса +def get_cdek_service() -> CDEKService: + """ + Создает и возвращает экземпляр CDEKService. + :return: Экземпляр CDEKService. + """ + return CDEKService() \ No newline at end of file diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 4d310d3..6b3ccd4 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -1,61 +1,95 @@ from sqlalchemy.orm import Session from fastapi import HTTPException, status -from typing import Dict, Any +from typing import Dict, Any, List from app.repositories import order_repo, content_repo -from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate +from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order from app.schemas.content_schemas import AnalyticsLogCreate +from app.models.order_models import OrderStatus -# Сервисы корзины и заказов +# Сервисы корзины def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]: - from app.schemas.order_schemas import CartItem as CartItemSchema - + """ + Добавляет товар в корзину пользователя. + """ new_cart_item = order_repo.create_cart_item(db, cart_item, user_id) # Логируем событие добавления в корзину log_data = AnalyticsLogCreate( user_id=user_id, event_type="add_to_cart", - product_id=new_cart_item.variant.product_id, - additional_data={"quantity": cart_item.quantity} + additional_data={ + "variant_id": cart_item.variant_id, + "quantity": cart_item.quantity + } ) content_repo.log_analytics_event(db, log_data) - # Преобразуем объект SQLAlchemy в схему Pydantic - cart_item_schema = CartItemSchema.model_validate(new_cart_item) - return {"cart_item": cart_item_schema} + return { + "success": True, + "message": "Товар успешно добавлен в корзину", + "cart_item": { + "id": new_cart_item.id, + "user_id": new_cart_item.user_id, + "variant_id": new_cart_item.variant_id, + "quantity": new_cart_item.quantity + } + } def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]: - from app.schemas.order_schemas import CartItem as CartItemSchema - + """ + Обновляет количество товара в корзине пользователя. + """ updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id) - # Преобразуем объект SQLAlchemy в схему Pydantic - cart_item_schema = CartItemSchema.model_validate(updated_cart_item) - return {"cart_item": cart_item_schema} + + return { + "success": True, + "message": "Товар в корзине успешно обновлен", + "cart_item": { + "id": updated_cart_item.id, + "user_id": updated_cart_item.user_id, + "variant_id": updated_cart_item.variant_id, + "quantity": updated_cart_item.quantity + } + } def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]: + """ + Удаляет товар из корзины пользователя. + """ success = order_repo.delete_cart_item(db, cart_item_id, user_id) - return {"success": success} + + return { + "success": success, + "message": "Товар успешно удален из корзины" if success else "Не удалось удалить товар из корзины" + } def clear_cart(db: Session, user_id: int) -> Dict[str, Any]: + """ + Очищает корзину пользователя. + """ success = order_repo.clear_cart(db, user_id) - return {"success": success} + + return { + "success": success, + "message": "Корзина успешно очищена" if success else "Не удалось очистить корзину" + } def get_cart(db: Session, user_id: int) -> Dict[str, Any]: - from app.schemas.order_schemas import CartItem as CartItemSchema - + """ + Получает корзину пользователя с детальной информацией о товарах. + """ # Получаем элементы корзины с деталями продуктов cart_items = order_repo.get_cart_with_product_details(db, user_id) # Рассчитываем общую сумму корзины total_amount = sum(item["total_price"] for item in cart_items) - # Примечание: cart_items уже содержит сериализованные данные из репозитория return { "items": cart_items, "total_amount": total_amount, @@ -63,37 +97,53 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]: } +# Сервисы заказов def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]: - from app.schemas.order_schemas import Order as OrderSchema - - print(f"Создание заказа для пользователя {user_id}: {order}") - + """ + Создает новый заказ на основе корзины или переданных элементов. + """ try: new_order = order_repo.create_order(db, order, user_id) - print(f"Заказ успешно создан: {new_order.id}") - # Логируем событие создания заказа log_data = AnalyticsLogCreate( user_id=user_id, event_type="order_created", - additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount} + additional_data={ + "order_id": new_order.id, + "total_amount": new_order.total_amount + } ) content_repo.log_analytics_event(db, log_data) # Получаем заказ с деталями order_details = order_repo.get_order_with_details(db, new_order.id) - return {"order": order_details} + return { + "success": True, + "message": "Заказ успешно создан", + "order": order_details + } except Exception as e: - print(f"Ошибка при создании заказа: {str(e)}") - raise + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Ошибка при создании заказа: {str(e)}" + ) def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]: + """ + Получает информацию о заказе по ID. + """ # Получаем заказ с деталями order_details = order_repo.get_order_with_details(db, order_id) + if not order_details: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + # Проверяем права доступа if not is_admin and order_details["user_id"] != user_id: raise HTTPException( @@ -101,40 +151,89 @@ def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) detail="Недостаточно прав для просмотра этого заказа" ) - return {"order": order_details} + return { + "success": True, + "order": order_details + } -def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]: - from app.schemas.order_schemas import Order as OrderSchema - - updated_order = order_repo.update_order(db, order_id, order, is_admin) +def update_order(db: Session, user_id: int, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]: + """ + Обновляет информацию о заказе. + """ + # Проверяем существование заказа + order = order_repo.get_order(db, order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) # Проверяем права доступа - if not is_admin and updated_order.user_id != user_id: + if not is_admin and order.user_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав для обновления этого заказа" ) - # Преобразуем объект SQLAlchemy в схему Pydantic - order_schema = OrderSchema.model_validate(updated_order) - return {"order": order_schema} + # Обычные пользователи могут только отменить заказ + if not is_admin and (order_update.status and order_update.status != OrderStatus.CANCELLED): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Пользователи могут только отменить заказ" + ) + + # Обновляем заказ + updated_order = order_repo.update_order(db, order_id, order_update, is_admin) + + # Получаем обновленный заказ с деталями + order_details = order_repo.get_order_with_details(db, order_id) + + return { + "success": True, + "message": "Заказ успешно обновлен", + "order": order_details + } def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]: - from app.schemas.order_schemas import Order as OrderSchema - - # Отменяем заказ (обычный пользователь может только отменить заказ) - order_update = OrderUpdate(status="cancelled") - updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False) + """ + Отменяет заказ. + """ + # Проверяем существование заказа + order = order_repo.get_order(db, order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) # Проверяем права доступа - if updated_order.user_id != user_id: + if order.user_id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав для отмены этого заказа" ) - # Преобразуем объект SQLAlchemy в схему Pydantic - order_schema = OrderSchema.model_validate(updated_order) - return {"order": order_schema} \ No newline at end of file + # Отменяем заказ + order_update = OrderUpdate(status=OrderStatus.CANCELLED) + order_repo.update_order(db, order_id, order_update, False) + + # Получаем обновленный заказ с деталями + order_details = order_repo.get_order_with_details(db, order_id) + + # Логируем событие отмены заказа + log_data = AnalyticsLogCreate( + user_id=user_id, + event_type="order_cancelled", + additional_data={ + "order_id": order_id + } + ) + content_repo.log_analytics_event(db, log_data) + + return { + "success": True, + "message": "Заказ успешно отменен", + "order": order_details + } \ No newline at end of file diff --git a/backend/app/test_cdek.py b/backend/app/test_cdek.py new file mode 100644 index 0000000..5b011fd --- /dev/null +++ b/backend/app/test_cdek.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import logging +import asyncio +import json +import sys +import os + +# Добавляем путь к корневой директории проекта +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.services.delivery_service import CDEKService + +# Настройка логирования +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +async def test_auth(): + """Тестирование авторизации в CDEK API""" + logger.info("Начало теста авторизации") + service = CDEKService() + try: + await service.get_auth_token() + logger.info("Токен получен успешно: %s...", service.auth_token[:10]) + return True + except Exception as e: + logger.error("Ошибка при авторизации: %s", str(e), exc_info=True) + return False + +async def test_offices(): + """Тестирование получения списка офисов""" + logger.info("Начало теста получения офисов") + service = CDEKService() + await service.get_auth_token() + + # Тестовые данные для получения офисов в Москве + service.request_data = { + "action": "offices", + "city_code": "44", # Код Москвы + "type": "PVZ" + } + + try: + result = await service.get_offices() + logger.info("Офисы получены: %s", result["result"][:100]) + return True + except Exception as e: + logger.error("Ошибка при получении офисов: %s", str(e), exc_info=True) + return False + +async def test_calculate(): + """Тестирование расчета стоимости доставки""" + logger.info("Начало теста расчета доставки") + service = CDEKService() + await service.get_auth_token() + + # Тестовые данные для расчета доставки Москва -> Санкт-Петербург + service.request_data = { + "action": "calculate", + "from_location": { + "code": "44", # Код Москвы + "city": "Москва" + }, + "to_location": { + "code": "270", # Код Санкт-Петербурга + "city": "Санкт-Петербург" + }, + "packages": [ + { + "weight": 500, + "length": 20, + "width": 20, + "height": 20 + } + ], + "tariff_code": 1 + } + + try: + result = await service.calculate() + logger.info("Расчет выполнен: %s", result["result"][:100]) + return True + except Exception as e: + logger.error("Ошибка при расчете доставки: %s", str(e), exc_info=True) + return False + +async def main(): + """Запуск всех тестов""" + logger.info("Запуск тестов CDEK API") + + # Тест авторизации + auth_result = await test_auth() + if not auth_result: + logger.error("Тест авторизации не пройден. Дальнейшие тесты отменены.") + return + + # Тест получения офисов + offices_result = await test_offices() + if not offices_result: + logger.error("Тест получения офисов не пройден.") + + # Тест расчета доставки + calculate_result = await test_calculate() + if not calculate_result: + logger.error("Тест расчета доставки не пройден.") + + if auth_result and offices_result and calculate_result: + logger.info("ВСЕ ТЕСТЫ ПРОЙДЕНЫ УСПЕШНО") + else: + logger.warning("НЕКОТОРЫЕ ТЕСТЫ НЕ ПРОЙДЕНЫ") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/docs/cdek_integration.md b/backend/docs/cdek_integration.md new file mode 100644 index 0000000..25472b9 --- /dev/null +++ b/backend/docs/cdek_integration.md @@ -0,0 +1,130 @@ +# Документация по интеграции с CDEK API + +## Обзор + +Интеграция с API CDEK предоставляет функциональность для работы с сервисом доставки CDEK в нашем интернет-магазине. В частности, эта интеграция позволяет: + +1. Получать список пунктов выдачи CDEK +2. Рассчитывать стоимость доставки + +Интеграция разработана специально для обеспечения работы виджета CDEK на фронтенде. + +## Архитектура + +Интеграция состоит из следующих компонентов: + +1. **CDEKService** (сервисный класс) - содержит всю логику взаимодействия с API CDEK: + - Авторизация через OAuth2 + - Отправка запросов к API CDEK + - Сбор метрик времени выполнения + - Форматирование ответов + +2. **Эндпоинт /api/delivery/cdek** - принимает запросы от фронтенда и передает их в сервис + +## Настройка + +Для корректной работы с API CDEK необходимо добавить следующие переменные окружения: + +``` +CDEK_LOGIN=ваш_логин +CDEK_PASSWORD=ваш_пароль +CDEK_BASE_URL=https://api.cdek.ru/v2 +``` + +По умолчанию используется продакшн окружение. Для тестов можно использовать тестовое окружение CDEK. + +## Использование API + +### Получение списка пунктов выдачи (офисов) + +**Запрос:** +``` +POST /api/delivery/cdek +Content-Type: application/json + +{ + "action": "offices", + "city_code": "44", + "type": "PVZ" +} +``` + +### Расчет стоимости доставки + +**Запрос:** +``` +POST /api/delivery/cdek +Content-Type: application/json + +{ + "action": "calculate", + "from_location": { + "code": "44", + "city": "Москва" + }, + "to_location": { + "code": "270", + "city": "Санкт-Петербург" + }, + "packages": [ + { + "weight": 500, + "length": 20, + "width": 20, + "height": 20 + } + ], + "tariff_code": 1 +} +``` + +## Метрики и мониторинг + +Сервис собирает метрики выполнения запросов и добавляет их в заголовок `Server-Timing`: + +- `auth` - время авторизации +- `office` - время запроса списка офисов +- `calc` - время расчета доставки +- `total` - общее время обработки запроса + +Эта информация может быть полезна для отладки и мониторинга производительности. + +## Интеграция с виджетом CDEK + +Для использования виджета CDEK на фронтенде необходимо: + +1. Подключить скрипт виджета в HTML: +```html + +``` + +2. Настроить виджет с указанием URL нашего прокси-эндпоинта: +```javascript +const cdekWidget = new window.CDEKWidget({ + apiUrl: '/api/delivery/cdek', + from: 'Москва', + defaultLocation: 'Санкт-Петербург' +}); +``` + +3. Настроить обработчик событий для сохранения выбранной точки доставки: +```javascript +cdekWidget.on('select', (data) => { + console.log('Выбран пункт выдачи:', data); + // Сохраняем выбранную точку доставки в состоянии заказа +}); +``` + +## Обработка ошибок + +В случае возникновения ошибок при взаимодействии с API CDEK, сервис возвращает HTTP-ответ с соответствующим кодом состояния и описанием ошибки: + +- `400 Bad Request` - неверный формат запроса или отсутствие обязательных параметров +- `500 Internal Server Error` - ошибки авторизации или взаимодействия с API CDEK + +Все ошибки логируются для дальнейшего анализа. + +## Ссылки + +- [Официальная документация API CDEK](https://api-docs.cdek.ru/29923741.html) +- [Документация виджета CDEK](https://widget.cdek.ru/docs.html) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 2d486eb..c188be1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,12 +1,13 @@ -fastapi==0.95.1 -uvicorn==0.22.0 -sqlalchemy==2.0.12 -pydantic==1.10.7 +fastapi==0.105.0 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.2 python-jose==3.3.0 passlib==1.7.4 python-multipart==0.0.6 -email-validator==2.0.0 +email-validator==2.1.0 psycopg2-binary==2.9.6 -alembic==1.10.4 +alembic==1.12.1 python-dotenv==1.0.0 -bcrypt==4.0.1 \ No newline at end of file +bcrypt==4.0.1 +httpx==0.25.2 \ No newline at end of file diff --git a/backend/uploads/0d6a250d-23f0-4dcb-a839-b1029128376e.jpg b/backend/uploads/0d6a250d-23f0-4dcb-a839-b1029128376e.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/0d6a250d-23f0-4dcb-a839-b1029128376e.jpg differ diff --git a/backend/uploads/190f8310-d7fc-4a7e-8b76-c639e13a493d.jpeg b/backend/uploads/190f8310-d7fc-4a7e-8b76-c639e13a493d.jpeg new file mode 100644 index 0000000..ecd1990 Binary files /dev/null and b/backend/uploads/190f8310-d7fc-4a7e-8b76-c639e13a493d.jpeg differ diff --git a/backend/uploads/22862a48-8c4c-4132-b5c8-05b9cfd03e78.jpeg b/backend/uploads/22862a48-8c4c-4132-b5c8-05b9cfd03e78.jpeg new file mode 100644 index 0000000..83d5105 Binary files /dev/null and b/backend/uploads/22862a48-8c4c-4132-b5c8-05b9cfd03e78.jpeg differ diff --git a/backend/uploads/229a07d2-33d5-4761-97c2-81286f0a879a.jpeg b/backend/uploads/229a07d2-33d5-4761-97c2-81286f0a879a.jpeg new file mode 100644 index 0000000..ed3d41c Binary files /dev/null and b/backend/uploads/229a07d2-33d5-4761-97c2-81286f0a879a.jpeg differ diff --git a/backend/uploads/3e7ce154-97d8-416f-af1e-d871b62415a5.jpg b/backend/uploads/3e7ce154-97d8-416f-af1e-d871b62415a5.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/3e7ce154-97d8-416f-af1e-d871b62415a5.jpg differ diff --git a/backend/uploads/559f4717-0b77-4928-bd1a-3f6e6d431f30.jpeg b/backend/uploads/559f4717-0b77-4928-bd1a-3f6e6d431f30.jpeg new file mode 100644 index 0000000..396a0aa Binary files /dev/null and b/backend/uploads/559f4717-0b77-4928-bd1a-3f6e6d431f30.jpeg differ diff --git a/backend/uploads/5748eff6-706b-495c-b857-389a70a816c0.jpg b/backend/uploads/5748eff6-706b-495c-b857-389a70a816c0.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/5748eff6-706b-495c-b857-389a70a816c0.jpg differ diff --git a/backend/uploads/68d1092c-0c17-4d6f-964e-3245613aff1b.jpeg b/backend/uploads/68d1092c-0c17-4d6f-964e-3245613aff1b.jpeg new file mode 100644 index 0000000..98053dd Binary files /dev/null and b/backend/uploads/68d1092c-0c17-4d6f-964e-3245613aff1b.jpeg differ diff --git a/backend/uploads/706987f8-90eb-4bde-9bee-51575371794b.jpeg b/backend/uploads/706987f8-90eb-4bde-9bee-51575371794b.jpeg new file mode 100644 index 0000000..27ab1e7 Binary files /dev/null and b/backend/uploads/706987f8-90eb-4bde-9bee-51575371794b.jpeg differ diff --git a/backend/uploads/99c64231-1d6c-4dd4-9631-04d5cf0a6e14.jpeg b/backend/uploads/99c64231-1d6c-4dd4-9631-04d5cf0a6e14.jpeg new file mode 100644 index 0000000..0817115 Binary files /dev/null and b/backend/uploads/99c64231-1d6c-4dd4-9631-04d5cf0a6e14.jpeg differ diff --git a/backend/uploads/9b08346b-251a-4af5-be0b-2e5a58189350.jpg b/backend/uploads/9b08346b-251a-4af5-be0b-2e5a58189350.jpg new file mode 100644 index 0000000..a030751 Binary files /dev/null and b/backend/uploads/9b08346b-251a-4af5-be0b-2e5a58189350.jpg differ diff --git a/backend/uploads/a324a97a-4530-4a3a-8641-a51001be7a4d.jpeg b/backend/uploads/a324a97a-4530-4a3a-8641-a51001be7a4d.jpeg new file mode 100644 index 0000000..73a29bc Binary files /dev/null and b/backend/uploads/a324a97a-4530-4a3a-8641-a51001be7a4d.jpeg differ diff --git a/backend/uploads/a7bc3345-500b-466a-b23f-b10ad21437bf.jpg b/backend/uploads/a7bc3345-500b-466a-b23f-b10ad21437bf.jpg new file mode 100644 index 0000000..fbfb28d Binary files /dev/null and b/backend/uploads/a7bc3345-500b-466a-b23f-b10ad21437bf.jpg differ diff --git a/backend/uploads/aae1f808-0060-4ea3-a0a2-3222ecd7d29f.jpeg b/backend/uploads/aae1f808-0060-4ea3-a0a2-3222ecd7d29f.jpeg new file mode 100644 index 0000000..ff26a42 Binary files /dev/null and b/backend/uploads/aae1f808-0060-4ea3-a0a2-3222ecd7d29f.jpeg differ diff --git a/backend/uploads/cf9b7f18-08b5-4d98-8b54-fce2f93bf3e6.jpeg b/backend/uploads/cf9b7f18-08b5-4d98-8b54-fce2f93bf3e6.jpeg new file mode 100644 index 0000000..27b6bfc Binary files /dev/null and b/backend/uploads/cf9b7f18-08b5-4d98-8b54-fce2f93bf3e6.jpeg differ diff --git a/backend/uploads/d99a1fbc-e9ae-4002-bb38-0cf271368acb.jpeg b/backend/uploads/d99a1fbc-e9ae-4002-bb38-0cf271368acb.jpeg new file mode 100644 index 0000000..728f9f1 Binary files /dev/null and b/backend/uploads/d99a1fbc-e9ae-4002-bb38-0cf271368acb.jpeg differ diff --git a/backend/uploads/db6b753a-ec93-4073-89b9-edc50f372c80.jpg b/backend/uploads/db6b753a-ec93-4073-89b9-edc50f372c80.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/db6b753a-ec93-4073-89b9-edc50f372c80.jpg differ diff --git a/backend/uploads/e1cdca42-d717-4ce7-a988-451f1ed80e08.jpeg b/backend/uploads/e1cdca42-d717-4ce7-a988-451f1ed80e08.jpeg new file mode 100644 index 0000000..2b56cd2 Binary files /dev/null and b/backend/uploads/e1cdca42-d717-4ce7-a988-451f1ed80e08.jpeg differ diff --git a/backend/uploads/e7427911-7740-4705-a6ae-b2021e7046e2.jpeg b/backend/uploads/e7427911-7740-4705-a6ae-b2021e7046e2.jpeg new file mode 100644 index 0000000..4f0f3fc Binary files /dev/null and b/backend/uploads/e7427911-7740-4705-a6ae-b2021e7046e2.jpeg differ diff --git a/backend/uploads/f67d2659-62cd-4197-931d-a93b3a6247d5.jpg b/backend/uploads/f67d2659-62cd-4197-931d-a93b3a6247d5.jpg new file mode 100644 index 0000000..1a1d2a2 Binary files /dev/null and b/backend/uploads/f67d2659-62cd-4197-931d-a93b3a6247d5.jpg differ diff --git a/frontend/.DS_Store b/frontend/.DS_Store index ba3d771..638ed51 100644 Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ diff --git a/frontend/app/(main)/.DS_Store b/frontend/app/(main)/.DS_Store new file mode 100644 index 0000000..c3f38e6 Binary files /dev/null and b/frontend/app/(main)/.DS_Store differ diff --git a/frontend/app/(main)/cart/page.tsx b/frontend/app/(main)/cart/page.tsx index 08f47d6..2f6f8d8 100644 --- a/frontend/app/(main)/cart/page.tsx +++ b/frontend/app/(main)/cart/page.tsx @@ -3,102 +3,66 @@ import { useState } from "react" import Image from "next/image" import Link from "next/link" -import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck } from "lucide-react" +import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { motion, AnimatePresence } from "framer-motion" import { useInView } from "react-intersection-observer" +import { useCart } from "@/hooks/useCart" +import { useRouter } from "next/navigation" +import { formatPrice } from "@/lib/utils" +import { CartItem as CartItemType } from "@/types/cart" +import { toast } from "@/components/ui/use-toast" +import { ProductCard } from "@/components/product/product-card" -interface CartItem { +interface RecommendedProduct { id: number name: string price: number - quantity: number - size: string - color: string image: string + slug: string } export default function CartPage() { - // Mock cart items - const [cartItems, setCartItems] = useState([ - { - id: 1, - name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ", - price: 5990, - quantity: 1, - size: "M", - color: "Белый", - image: "/placeholder.svg?height=600&width=400", - }, - { - id: 2, - name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА", - price: 4990, - quantity: 2, - size: "S", - color: "Синий", - image: "/placeholder.svg?height=600&width=400", - }, - ]) - const [promoCode, setPromoCode] = useState("") - const [promoApplied, setPromoApplied] = useState(false) + const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart() const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 }) const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 }) const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) + const router = useRouter() + const [processing, setProcessing] = useState<{ [key: number]: boolean }>({}) - const updateQuantity = (id: number, newQuantity: number) => { - if (newQuantity < 1) return - - setCartItems(cartItems.map((item) => (item.id === id ? { ...item, quantity: newQuantity } : item))) - } - - const removeItem = (id: number) => { - setCartItems(cartItems.filter((item) => item.id !== id)) - } - - const moveToWishlist = (id: number) => { - // В реальном приложении здесь был бы код для добавления в избранное - console.log(`Товар ${id} добавлен в избранное`) - removeItem(id) - } - - const applyPromoCode = () => { - if (promoCode.trim() === "SALE20") { - setPromoApplied(true) - } else { - alert("Неверный промокод") + const handleQuantityChange = async (itemId: number, newQuantity: number) => { + if (processing[itemId]) return + + setProcessing(prev => ({ ...prev, [itemId]: true })) + try { + await updateCartItem(itemId, newQuantity) + } finally { + setProcessing(prev => ({ ...prev, [itemId]: false })) } } - // Calculate totals - const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0) - const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000 - const discount = promoApplied ? Math.round(subtotal * 0.2) : 0 // 20% discount if promo applied - const total = subtotal + shipping - discount + const handleRemoveItem = async (itemId: number) => { + if (processing[itemId]) return + + setProcessing(prev => ({ ...prev, [itemId]: true })) + try { + await removeFromCart(itemId) + } finally { + setProcessing(prev => ({ ...prev, [itemId]: false })) + } + } + + const handleCheckout = () => { + router.push('/checkout') + } + + // Calculate totals + const subtotal = cart.total_price + const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000 + const total = subtotal + shipping - // Recommended products - const recommendedProducts = [ - { - id: 3, - name: "ЮБКА МИДИ ПЛИССЕ", - price: 5290, - image: "/placeholder.svg?height=600&width=400", - }, - { - id: 4, - name: "ЖАКЕТ ИЗ ИТАЛЬЯНСКОЙ ШЕРСТИ", - price: 12900, - image: "/placeholder.svg?height=600&width=400", - }, - { - id: 5, - name: "ПЛАТЬЕ-РУБАШКА ИЗ ЛЬНА", - price: 7990, - image: "/placeholder.svg?height=600&width=400", - }, - ] // Анимационные варианты const containerVariants = { @@ -116,22 +80,38 @@ export default function CartPage() { visible: { opacity: 1, y: 0, transition: { duration: 0.5 } } } + if (!loading && (!cart.items || cart.items.length === 0)) { + return ( +
+

Корзина

+
+

Ваша корзина пуста

+ + + +
+
+ ) + } + return (
-
+
Вернуться к покупкам +

Корзина

+
{/* Элемент для центрирования заголовка на мобильных устройствах */}
- -

Корзина

- {cartItems.length === 0 ? ( + {loading ? (
-

Ваша корзина пуста

-

- Добавьте товары в корзину, чтобы оформить заказ. Вы можете найти множество интересных товаров в нашем - каталоге. -

- +

Загрузка корзины...

) : ( -
+
{/* Cart Items */} - {cartItems.map((item, index) => ( - -
- - {item.name} - +
+
+
+ +

Товары в корзине ({cart.items.length})

- -
-
- - {item.name} - - -
- -
-
Размер: {item.size}
-
Цвет: {item.color}
-
- -
-
- - {item.quantity} - -
- -
- -
- {(item.price * item.quantity).toLocaleString()} ₽ -
-
-
-
- - ))} - - {/* Promo Code */} - -

Промокод

-
- setPromoCode(e.target.value)} - className="border-gray-200 focus:border-primary focus:ring-primary rounded-none" - disabled={promoApplied} - />
- - {promoApplied && ( - + {cart.items.map((item) => ( + - - - - Промокод успешно применен! Скидка 20% - - )} - -
+ {/* Изображение товара */} +
+ {item.image || item.product_image ? ( + + {item.name + + ) : ( +
+ +
+ )} +
+ + {/* Информация о товаре */} +
+ +

+ {item.name || item.product_name || "Товар"} +

+ + + {/* Вариант товара - размер и цвет */} +
+ {(item.size || item.variant_name) && ( + Размер: {item.size || item.variant_name} + )} + {(item.size || item.variant_name) && item.color && } + {item.color && Цвет: {item.color}} +
+ + {/* Количество и цена */} +
+
+ + + {item.quantity} + + +
+ +
+
+
Цена:
+
{formatPrice(item.price * item.quantity)}
+
+ + +
+
+
+ + ))} +
+
{/* Order Summary */} @@ -292,118 +264,152 @@ export default function CartPage() { initial={{ opacity: 0, y: 20 }} animate={inView2 ? { opacity: 1, y: 0 } : {}} transition={{ duration: 0.8, delay: 0.3 }} - className="md:sticky md:top-24 h-fit" + className="md:sticky md:top-24 h-fit hidden md:block" > -
-

Ваш заказ

+
+

+ + Ваш заказ +

-
- Товары ({cartItems.length}): - {subtotal.toLocaleString()} ₽ +
+ Товары ({cart.total_items}): + {formatPrice(cart.total_price)}
-
+
Доставка: - {shipping === 0 ? "Бесплатно" : `${shipping.toLocaleString()} ₽`} + + {shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`} +
- - - {promoApplied && ( - - Скидка (20%): - -{discount.toLocaleString()} ₽ - - )} - - + -
+
Итого: - {total.toLocaleString()} ₽ + {formatPrice(total)}
- - -
- Нажимая кнопку "Оформить заказ", вы соглашаетесь с условиями пользовательского соглашения и политикой конфиденциальности. -
+

+ Нажимая кнопку, вы принимаете условия публичной оферты +

-
+
-
- - Бесплатная доставка от 5000 ₽ +
+ +
+ Бесплатная доставка + при заказе от 5000 ₽ +
-
- - Возврат в течение 14 дней +
+ +
+ Возврат в течение 14 дней + без объяснения причин +
-
- - Безопасная оплата +
+ +
+ Безопасная оплата + все платежи защищены +
+ + {/* Order Summary for mobile - visible only on small screens */} +
+ + +
+

+ + Сводка заказа +

+ +
+
+ Товары ({cart.total_items}): + {formatPrice(cart.total_price)} +
+ +
+ Доставка: + + {shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`} + +
+ + + +
+ Итого: + {formatPrice(total)} +
+
+ + +
+
)} - {/* Recommended Products */} - {cartItems.length > 0 && ( - -

Вам также может понравиться

-
- {recommendedProducts.map((product, index) => ( - - -
- {product.name} -
-
-

{product.name}

-

{product.price.toLocaleString()} ₽

-
- -
- ))} -
-
- )} +
) diff --git a/frontend/app/(main)/catalog/[slug]/page.tsx b/frontend/app/(main)/catalog/[slug]/page.tsx index 68b30ec..ce7c412 100644 --- a/frontend/app/(main)/catalog/[slug]/page.tsx +++ b/frontend/app/(main)/catalog/[slug]/page.tsx @@ -1,156 +1,361 @@ "use client" -import { useEffect, useState } from "react" -import { notFound, useRouter } from "next/navigation" -import { Product } from "@/lib/catalog" +import { Suspense, useState } from "react" +import Link from "next/link" +import { notFound } from "next/navigation" +import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart, ShoppingBag, Mail, Phone, MapPin } from "lucide-react" +import catalogService, { ProductDetails } from "@/lib/catalog" +import { formatPrice } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" -import { fetchProduct, ApiResponse } from "@/lib/api" import { ImageSlider } from "@/components/product/ImageSlider" -import { ProductDetails } from "@/components/product/ProductDetails" +import { ProductDetails as ProductDetailsComponent } from "@/components/product/ProductDetails" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { motion } from "framer-motion" +import { useInView } from "react-intersection-observer" +import { Button } from "@/components/ui/button" +import { useCart } from "@/hooks/useCart" +import { toast } from "sonner" -export default function ProductPage({ params }: { params: { slug: string } }) { +interface ProductPageProps { + params: { + slug: string + } +} + +export default function ProductPage({ params }: ProductPageProps) { + const [product, setProduct] = useState(null) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [product, setProduct] = useState(null) - const router = useRouter() + const [error, setError] = useState(null) + const [mainRef, mainInView] = useInView({ triggerOnce: true, threshold: 0.1 }) + const [imageRef, imageInView] = useInView({ triggerOnce: true, threshold: 0.1 }) + const [detailsRef, detailsInView] = useInView({ triggerOnce: true, threshold: 0.1 }) + const { addToCart, loading: cartLoading } = useCart() - useEffect(() => { - const loadProduct = async () => { - setLoading(true) + // Загрузка товара при монтировании компонента + useState(() => { + const fetchProduct = async () => { try { - console.log(`Загрузка товара по slug: ${params.slug}`); + setLoading(true) + const productData = await catalogService.getProductBySlug(params.slug) - // fetchProduct возвращает ApiResponse - const response = await fetchProduct(params.slug); - console.log("Ответ API:", response); - - // Проверяем структуру ответа - if (!response.success || !response.data) { - const errorMsg = response.error || "Продукт не найден"; - console.error("Ошибка при загрузке продукта:", errorMsg); - setError(errorMsg); - return; + if (!productData) { + return notFound() } - const productData = response.data; - console.log("Данные продукта:", productData); - - // Проверяем изображения продукта - if (productData.images && Array.isArray(productData.images)) { - console.log("Изображения продукта:", productData.images); - } else { - console.warn("Продукт не содержит изображений или images не является массивом"); - // Устанавливаем пустой массив, чтобы избежать ошибок - productData.images = []; - } - - // Устанавливаем данные продукта - setProduct(productData as Product); + setProduct(productData) } catch (err) { console.error("Ошибка при загрузке товара:", err) - setError(err instanceof Error ? err.message : "Ошибка при загрузке продукта") + setError(err instanceof Error ? err : new Error("Неизвестная ошибка")) } finally { setLoading(false) } } + + fetchProduct() + }) - loadProduct() - }, [params.slug]) - - if (error && !loading) { - return ( -
-

Ошибка

-

{error}

- -
- ) - } - + // Если все еще загружается, показываем скелетон if (loading) { return } - if (!product) { - return notFound() + // Если произошла ошибка, показываем сообщение + if (error || !product) { + return ( +
+
+

Товар не найден

+

К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.

+ + + +
+
+ ) } return ( -
- - -
-
- + }> +
+
+ {/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */} + {new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && ( + + Новинка + + )} + {product.discount_price && ( + + -{Math.round((1 - product.discount_price / product.price) * 100)}% + + )} +
+ + +
+
+ + + {/* Блок информации о товаре */} + + {/* Категория и название */} + + {product.category_name && ( +
+ {product.category_name} +
+ )} + +

{product.name}

+ + {/* Цена */} +
+ {product.discount_price ? ( + <> + + {formatPrice(product.discount_price)} + + + {formatPrice(product.price)} + + + ) : ( + + {formatPrice(product.price)} + + )} +
+
+ + + + {/* Компонент выбора размера и количества */} + + + + + {/* Описание товара */} + + + + + Описание + + + Уход + + + + + + {product.description ? ( + typeof product.description === 'string' ? ( +
+ ) : ( +

Описание недоступно

+ ) + ) : ( +

Описание отсутствует

+ )} + + + + {product.care_instructions ? ( + typeof product.care_instructions === 'string' ? ( +
+ ) : ( +

Инструкции по уходу недоступны

+ ) + ) : ( +

Инструкции по уходу отсутствуют

+ )} + + + + + + {/* Информация о доставке и возврате */} + +
+
+
+ +
+

Доставка

+
+

+ Доставка по всей России 1-3 рабочих дня +

+
+ +
+
+
+ +
+

Возврат

+
+

+ Бесплатный возврат в течение 14 дней +

+
+
+ +
-
- -
-
-
+ + + + + ) } function ProductSkeleton() { return ( -
-
- -
- -
-
- -
- {[1, 2, 3].map((i) => ( - - ))} -
+
+
+
+ +
-
- - - -
- - + +
+
+
- -
- - - + +
+
+ + + +
+ + + +
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ + +
+ + + +
+
+ + +
+ +
+ +
+ + +
diff --git a/frontend/app/(main)/catalog/page.tsx b/frontend/app/(main)/catalog/page.tsx index fdb6bda..9da199a 100644 --- a/frontend/app/(main)/catalog/page.tsx +++ b/frontend/app/(main)/catalog/page.tsx @@ -1,14 +1,14 @@ "use client" -import { useState, useEffect } from "react" +import React, { useState, useEffect } from "react" import Image from "next/image" import Link from "next/link" +import { useSearchParams } from 'next/navigation' import { ChevronDown, ChevronUp, X, Sliders, Search, ArrowUpRight } from "lucide-react" -import { ProductCard } from "@/components/ui/product-card" +import { ProductCard } from "@/components/product/product-card" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" -import { Slider } from "@/components/ui/slider" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { @@ -23,50 +23,90 @@ import { import { motion, AnimatePresence } from "framer-motion" import { Input } from "@/components/ui/input" import { useInView } from "react-intersection-observer" -import { productService, categoryService, Product, Category, Collection } from "@/lib/catalog" +import catalogService, { Product, Category, Collection, Size } from "@/lib/catalog" import { Skeleton } from "@/components/ui/skeleton" -export default function CatalogPage() { - const [isFilterOpen, setIsFilterOpen] = useState(false) - const [priceRange, setPriceRange] = useState([0, 15000]) +// Расширение интерфейса Product для поддержки дополнительных свойств +interface ExtendedProduct extends Product { + category_name?: string; + collection_name?: string; + variants?: Array<{ + id: number; + product_id: number; + size_id: number; + sku: string; + stock: number; + is_active: boolean; + created_at: string; + updated_at?: string; + }>; +} + +// Интерфейс для ответа API со списком продуктов +interface ProductsResponse { + products: ExtendedProduct[]; + total: number; +} + +export default function CatalogPage({ searchParams }: { searchParams?: { [key: string]: string } }) { + const searchParamsObject = useSearchParams(); + const [activeFilters, setActiveFilters] = useState([]) const [searchQuery, setSearchQuery] = useState("") const [sortOption, setSortOption] = useState("popular") - const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [currentPage, setCurrentPage] = useState(1) const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 }) - const [selectedImage, setSelectedImage] = useState(null) const [isMobile, setIsMobile] = useState(false) // Состояния для реальных данных - const [products, setProducts] = useState([]) + const [products, setProducts] = useState([]) const [categories, setCategories] = useState([]) + const [collections, setCollections] = useState([]) + const [sizes, setSizes] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [totalProducts, setTotalProducts] = useState(0) const [selectedCategory, setSelectedCategory] = useState(null) + const [selectedCollection, setSelectedCollection] = useState(null) + const [selectedSizes, setSelectedSizes] = useState([]) - // Цвета (оставляем как есть, так как они не меняются) - const colors = [ - { id: "black", name: "Черный", hex: "#000000" }, - { id: "white", name: "Белый", hex: "#FFFFFF" }, - { id: "beige", name: "Бежевый", hex: "#F5F5DC" }, - { id: "blue", name: "Синий", hex: "#0000FF" }, - { id: "green", name: "Зеленый", hex: "#008000" }, - { id: "red", name: "Красный", hex: "#FF0000" }, - ] - - // Размеры (оставляем как есть, так как они не меняются) - const sizes = ["XS", "S", "M", "L", "XL", "XXL"] + // Инициализация фильтров из параметров URL + useEffect(() => { + // Проверяем наличие категории в URL + const categoryId = searchParamsObject.get('category_id'); + if (categoryId) { + setSelectedCategory(Number(categoryId)); + } + + // Проверяем наличие коллекции в URL + const collectionId = searchParamsObject.get('collection_id'); + if (collectionId) { + setSelectedCollection(Number(collectionId)); + } + + // Проверяем наличие размеров в URL + const sizeIds = searchParamsObject.get('size_ids'); + if (sizeIds) { + setSelectedSizes(sizeIds.split(',').map(id => Number(id))); + } + + // Проверяем наличие поискового запроса + const search = searchParamsObject.get('search'); + if (search) { + setSearchQuery(search); + } + + // Проверяем сортировку + const sort = searchParamsObject.get('sort'); + if (sort) { + setSortOption(sort); + } + }, [searchParamsObject]); // Проверка размера экрана useEffect(() => { const checkIsMobile = () => { setIsMobile(window.innerWidth < 768) - // Автоматически переключаем на сетку на мобильных устройствах - if (window.innerWidth < 640 && viewMode === 'list') { - setViewMode('grid') - } } checkIsMobile() @@ -75,32 +115,71 @@ export default function CatalogPage() { return () => { window.removeEventListener('resize', checkIsMobile) } - }, [viewMode]) + }, []) // Загрузка категорий useEffect(() => { const loadCategories = async () => { try { - const response = await categoryService.getCategories(); - if (response.success && response.data) { - setCategories(response.data); + console.log('Загрузка категорий...'); + const categoriesData = await catalogService.getCategoriesTree(); + + if (categoriesData && categoriesData.length > 0) { + setCategories(categoriesData); } else { - setError(response.error || "Не удалось загрузить категории"); + setError("Не удалось загрузить категории с сервера"); } } catch (err) { console.error("Ошибка при загрузке категорий:", err); - setError("Не удалось загрузить категории"); + setError("Не удалось загрузить категории с сервера"); } }; loadCategories(); }, []); + + // Загрузка коллекций + useEffect(() => { + const loadCollections = async () => { + try { + console.log('Загрузка коллекций...'); + const collectionsResponse = await catalogService.getCollections(); + + if (collectionsResponse && collectionsResponse.collections) { + setCollections(collectionsResponse.collections); + } + } catch (err) { + console.error("Ошибка при загрузке коллекций:", err); + } + }; + + loadCollections(); + }, []); + + // Загрузка размеров + useEffect(() => { + const loadSizes = async () => { + try { + console.log('Загрузка размеров...'); + const sizesData = await catalogService.getSizes(); + + if (sizesData && sizesData.length > 0) { + setSizes(sizesData); + } + } catch (err) { + console.error("Ошибка при загрузке размеров:", err); + } + }; + + loadSizes(); + }, []); // Загрузка продуктов с учетом фильтров useEffect(() => { const loadProducts = async () => { try { setLoading(true); + console.log('Загрузка продуктов...'); // Формируем параметры запроса const params: any = { @@ -114,12 +193,9 @@ export default function CatalogPage() { params.category_id = selectedCategory; } - // Добавляем фильтр по цене - if (priceRange[0] > 0) { - params.min_price = priceRange[0]; - } - if (priceRange[1] < 15000) { - params.max_price = priceRange[1]; + // Добавляем фильтр по коллекции + if (selectedCollection) { + params.collection_id = selectedCollection; } // Добавляем поисковый запрос @@ -127,51 +203,52 @@ export default function CatalogPage() { params.search = searchQuery; } - // Получаем продукты через сервис - const response: any = await productService.getProducts(params); - let productsData: Product[] = []; + // Здесь можно добавить фильтрацию по размерам на стороне клиента, + // так как API не поддерживает прямую фильтрацию по размерам - // Проверяем формат ответа и извлекаем продукты - if (Array.isArray(response)) { - // Старый формат - массив продуктов - productsData = response; - } else if (response && typeof response === 'object' && 'success' in response) { - // Новый формат - ApiResponse с данными в поле data - if (response.success && response.data) { - productsData = response.data; - } else { - setError(response.error || "Не удалось загрузить продукты"); - setLoading(false); - return; - } - } else { - setError("Не удалось загрузить продукты: неизвестный формат ответа"); + console.log('Параметры запроса:', params); + + // Получаем продукты через сервис + const response = await catalogService.getProducts(params) as ProductsResponse; + console.log('Полученный ответ:', response); + + if (!response || !response.products) { + setError("Товары не найдены"); setLoading(false); + setProducts([]); + setTotalProducts(0); return; } + let productsData = response.products; + setTotalProducts(response.total); + + // Фильтрация по размерам на стороне клиента, если выбраны размеры + if (selectedSizes.length > 0) { + productsData = productsData.filter(product => { + // Проверяем, есть ли у продукта варианты с выбранными размерами + if (!product.variants) return false; + + return product.variants.some(variant => + selectedSizes.includes(variant.size_id) && variant.is_active && variant.stock > 0 + ); + }); + } + // Сортировка полученных продуктов let sortedProducts = [...productsData]; switch (sortOption) { case 'price_asc': - sortedProducts.sort((a, b) => { - const priceA = a.price || (a.variants && a.variants[0]?.price) || 0; - const priceB = b.price || (b.variants && b.variants[0]?.price) || 0; - return priceA - priceB; - }); + sortedProducts.sort((a, b) => a.price - b.price); break; case 'price_desc': - sortedProducts.sort((a, b) => { - const priceA = a.price || (a.variants && a.variants[0]?.price) || 0; - const priceB = b.price || (b.variants && b.variants[0]?.price) || 0; - return priceB - priceA; - }); + sortedProducts.sort((a, b) => b.price - a.price); break; case 'newest': sortedProducts.sort((a, b) => { - const dateA = new Date((a as any).created_at || '').getTime(); - const dateB = new Date((b as any).created_at || '').getTime(); + const dateA = new Date(a.created_at || '').getTime(); + const dateB = new Date(b.created_at || '').getTime(); return dateB - dateA; }); break; @@ -180,7 +257,6 @@ export default function CatalogPage() { } setProducts(sortedProducts); - setTotalProducts(productsData.length); setLoading(false); } catch (err) { console.error("Ошибка при загрузке продуктов:", err); @@ -190,30 +266,39 @@ export default function CatalogPage() { }; loadProducts(); - }, [currentPage, selectedCategory, priceRange, searchQuery, sortOption]); - - // Функция для переключения фильтров - const toggleFilter = (filter: string) => { - if (activeFilters.includes(filter)) { - setActiveFilters(activeFilters.filter(f => f !== filter)) - } else { - setActiveFilters([...activeFilters, filter]) - } - } - - // Функция для очистки всех фильтров - const clearAllFilters = () => { - setActiveFilters([]) - setPriceRange([0, 15000]) - setSearchQuery("") - setSelectedCategory(null) - } + }, [currentPage, selectedCategory, selectedCollection, selectedSizes, searchQuery, sortOption]); // Обработчик выбора категории const handleCategorySelect = (categoryId: number) => { setSelectedCategory(categoryId === selectedCategory ? null : categoryId); setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории }; + + // Обработчик выбора коллекции + const handleCollectionSelect = (collectionId: number) => { + setSelectedCollection(collectionId === selectedCollection ? null : collectionId); + setCurrentPage(1); // Сбрасываем страницу на первую при изменении коллекции + }; + + // Обработчик выбора размера + const handleSizeSelect = (sizeId: number) => { + setSelectedSizes(prev => + prev.includes(sizeId) + ? prev.filter(id => id !== sizeId) + : [...prev, sizeId] + ); + setCurrentPage(1); // Сбрасываем страницу на первую при изменении размера + }; + + // Функция для очистки всех фильтров + const clearAllFilters = () => { + setActiveFilters([]) + setSearchQuery("") + setSelectedCategory(null) + setSelectedCollection(null) + setSelectedSizes([]) + setCurrentPage(1) + } // Компонент боковой панели фильтров const FilterSidebar = () => ( @@ -226,436 +311,388 @@ export default function CatalogPage() { placeholder="Поиск..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="pl-10 border-primary/20 focus:border-primary rounded-none" + className="pl-10 border-primary/20 focus:border-primary rounded-xl" />
- {/* Категории */} -
- - - Категории - -
- {categories.map((category) => ( -
- handleCategorySelect(category.id)} - className="mr-2" - /> - - - {category.products_count || 0} - -
- ))} -
-
-
-
-
- - {/* Цена */} -
- - - Цена - -
- setPriceRange(value as [number, number])} - className="mb-6" + {/* Фильтр по категориям */} + + + + Категории + + + {categories.map((category) => ( +
+ handleCategorySelect(category.id)} + className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary" /> -
-
- от - {priceRange[0]} ₽ -
-
- до - {priceRange[1]} ₽ -
-
+
-
-
-
-
- - {/* Цвета */} -
- - - Цвет - -
- {colors.map((color) => ( -
- - {color.name} -
- ))} -
-
-
-
-
- - {/* Размеры */} -
- - - Размер - -
- {sizes.map((size) => ( - + {collection.name} + +
+ ))} +
+
+
+ )} + + {/* Фильтр по размерам */} + {sizes.length > 0 && ( + + + + Размеры + + +
+ {sizes.map((size) => ( +
+ handleSizeSelect(size.id)} + className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary" + /> + +
))}
-
- - {/* Кнопка сброса фильтров */} - + )}
) - // Отображаем загрузку - if (loading && currentPage === 1) { + // Компонент активных фильтров + const ActiveFilters = () => { + const filters = []; + + if (selectedCategory) { + const category = categories.find(c => c.id === selectedCategory); + if (category) { + filters.push({ + id: `category-${category.id}`, + name: `Категория: ${category.name}`, + onRemove: () => setSelectedCategory(null) + }); + } + } + + if (selectedCollection) { + const collection = collections.find(c => c.id === selectedCollection); + if (collection) { + filters.push({ + id: `collection-${collection.id}`, + name: `Коллекция: ${collection.name}`, + onRemove: () => setSelectedCollection(null) + }); + } + } + + if (selectedSizes.length > 0) { + const sizeNames = selectedSizes + .map(id => sizes.find(s => s.id === id)?.value) + .filter(Boolean) + .join(', '); + + filters.push({ + id: 'sizes', + name: `Размеры: ${sizeNames}`, + onRemove: () => setSelectedSizes([]) + }); + } + + if (searchQuery) { + filters.push({ + id: 'search', + name: `Поиск: ${searchQuery}`, + onRemove: () => setSearchQuery('') + }); + } + + if (filters.length === 0) return null; + return ( -
-
-

Каталог товаров

-
-
- {Array.from({ length: 8 }).map((_, i) => ( -
- - - +
+ {filters.map(filter => ( +
+ {filter.name} + +
+ ))} + + {filters.length > 0 && ( + + )} +
+ ); + }; + + // Компонент сетки товаров + const ProductGrid = ({ products, loading }: { products: ExtendedProduct[], loading: boolean }) => { + if (loading) { + // Отображаем скелетон для загрузки + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ +
+
+ + + +
))}
-
- ); - } + ) + } - // Отображаем ошибку, если она есть - if (error) { - return ( -
-
-

Ошибка загрузки данных

-

{error}

- -
-
- ); - } - - return ( -
- {/* Заголовок и фильтры для десктопа */} -
-
-

Каталог товаров

-

Найдено товаров: {totalProducts}

-
- -
- {/* Сортировка */} - - - {/* Переключатель вид сетки/списка */} -
- - -
- - {/* Кнопка фильтров для мобильных */} - - - - - - - Фильтры - - Выберите параметры для фильтрации товаров - - -
- -
-
-
-
-
- - {/* Активные фильтры */} - {activeFilters.length > 0 && ( -
-
- {activeFilters.map((filter) => ( - - ))} -
- )} + ) + } - {/* Основное содержимое */} -
- {/* Боковая панель с фильтрами - десктоп */} -
- -
+ return ( +
+ {products.map((product) => ( + + ))} +
+ ) + } - {/* Список товаров */} -
- {/* Сетка товаров */} - {products.length > 0 ? ( -
+ {/* Заголовок и основные фильтры */} +
+
+
+

Каталог

+

+ {loading ? 'Загрузка...' : `${totalProducts} товаров`} +

+
+ +
+ {/* Сортировка */} + + + {/* Фильтр для мобильной версии */} + + + - {/* Номера страниц */} - {Array.from({ length: Math.ceil(totalProducts / 12) }).map((_, i) => ( - - ))} - + +
+ + +
+
+ + {/* Отображение активных фильтров */} + + +
+ {/* Фильтры (сайдбар) - только на десктопе */} +
+
+ +
+
+ + {/* Основной контент с товарами */} +
+ {loading ? ( + + ) : error ? ( +
+

Ошибка при загрузке товаров

+

{error}

+
-
- )} + ) : products.length === 0 ? ( +
+

Товары не найдены

+

+ Попробуйте изменить параметры фильтрации или поискать что-то другое. +

+ +
+ ) : ( + <> + + + {/* Пагинация */} + {totalProducts > 12 && ( +
+
+ + +
+ {Array.from({ length: Math.min(5, Math.ceil(totalProducts / 12)) }).map((_, i) => { + // Логика для отображения страниц вокруг текущей + let pageToShow = i + 1; + if (Math.ceil(totalProducts / 12) > 5 && currentPage > 3) { + pageToShow = currentPage - 3 + i; + if (pageToShow > Math.ceil(totalProducts / 12)) { + pageToShow = Math.ceil(totalProducts / 12) - (5 - i - 1); + } + } + + if (pageToShow > 0 && pageToShow <= Math.ceil(totalProducts / 12)) { + return ( + + ); + } + return null; + })} +
+ +
+ {currentPage} из {Math.ceil(totalProducts / 12)} +
+ + +
+
+ )} + + )} +
diff --git a/frontend/app/(main)/checkout/contact/page.tsx b/frontend/app/(main)/checkout/contact/page.tsx new file mode 100644 index 0000000..6633461 --- /dev/null +++ b/frontend/app/(main)/checkout/contact/page.tsx @@ -0,0 +1,178 @@ +"use client" + +import { useState, useEffect } from "react" +import Image from "next/image" +import Link from "next/link" +import { ShoppingBag, ChevronLeft, Phone } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { motion } from "framer-motion" +import { useInView } from "react-intersection-observer" +import { useCart } from "@/hooks/useCart" +import { formatPrice } from "@/lib/utils" + +export default function ContactPage() { + const { cart, loading } = useCart() + const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 }) + const [whatsappText, setWhatsappText] = useState("") + + // Формируем текст для WhatsApp при изменении корзины + useEffect(() => { + if (!loading && cart.items && cart.items.length > 0) { + const itemsList = cart.items.map(item => + `- ${item.product_name || item.name} (${item.size || 'No size'}) x ${item.quantity} - ${formatPrice(item.price * item.quantity)}` + ).join('\n'); + + const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_price)}`; + + setWhatsappText(encodeURIComponent(message)); + } + }, [cart, loading]); + + // Номер телефона для связи (можно изменить) + const phoneNumber = "+79236212432"; + // WhatsApp ссылка + const whatsappLink = `https://wa.me/${phoneNumber.replace(/\+/g, '')}?text=${whatsappText}`; + + if (loading) { + return ( +
+
+ +

Загрузка информации о заказе...

+
+
+ ); + } + + if (!cart.items || cart.items.length === 0) { + return ( +
+
+ +

Ваша корзина пуста

+

Добавьте товары в корзину, чтобы оформить заказ

+ +
+
+ ); + } + + return ( +
+
+
+ + + Вернуться в корзину + +

Оформление заказа

+
+
+ +
+ +

+ + Ваш заказ +

+ +
+ {cart.items.map((item) => ( +
+
+ {item.image || item.product_image ? ( + {item.name + ) : ( +
+ +
+ )} +
+
+

{item.product_name || item.name}

+

+ Размер: {item.size || 'Не указан'} | Количество: {item.quantity} +

+

{formatPrice(item.price * item.quantity)}

+
+
+ ))} +
+ + + +
+
+ Товары ({cart.total_items}): + {formatPrice(cart.total_price)} +
+
+ Итого: + {formatPrice(cart.total_price)} +
+
+
+ + +

Свяжитесь с нами для оформления заказа

+ +
+
+

+ Для оформления заказа вы можете: +

+
+ + + +
+
+ +
+

Наш специалист свяжется с вами для подтверждения заказа, уточнения деталей и согласования способа оплаты и доставки.

+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/(main)/checkout/page.tsx b/frontend/app/(main)/checkout/page.tsx index 710dd62..7dec4d9 100644 --- a/frontend/app/(main)/checkout/page.tsx +++ b/frontend/app/(main)/checkout/page.tsx @@ -1,514 +1,232 @@ "use client" import { useState } from "react" -import Image from "next/image" -import Link from "next/link" +import { useCart } from "@/hooks/useCart" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { formatPrice } from "@/lib/utils" +import UserInfoForm from "@/components/checkout/user-info-form" +import DeliveryMethodSelector from "@/components/checkout/delivery-method-selector" +import AddressForm from "@/components/checkout/address-form" +import PaymentMethodSelector from "@/components/checkout/payment-method-selector" +import OrderSummary from "@/components/checkout/order-summary" import { Separator } from "@/components/ui/separator" -import { Textarea } from "@/components/ui/textarea" -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { motion, AnimatePresence } from "framer-motion" -import { useInView } from "react-intersection-observer" -import { ChevronLeft, CreditCard, Truck, Check, ShieldCheck, Clock } from "lucide-react" +import OrderComment from "@/components/checkout/order-comment" +import { orderService } from "@/lib/order-service" +import { useToast } from "@/components/ui/use-toast" +import { Loader2 } from "lucide-react" + +// Типы для выбора способа доставки и оплаты +export type DeliveryMethod = "cdek" | "courier" +export type PaymentMethod = "sbp" | "card" export default function CheckoutPage() { - const [step, setStep] = useState(1) - const [formData, setFormData] = useState({ + const { cart, loading, clearCart } = useCart() + const { toast } = useToast() + const router = useRouter() + + // Состояния для обработки заказа + const [isSubmitting, setIsSubmitting] = useState(false) + + // Состояния для форм и выбранных опций + const [userInfo, setUserInfo] = useState({ firstName: "", lastName: "", email: "", - phone: "", - address: "", - city: "", - postalCode: "", - deliveryMethod: "courier", - paymentMethod: "card", - comment: "" + phone: "" }) - const [orderComplete, setOrderComplete] = useState(false) - const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 }) - const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 }) - - const handleChange = (e: React.ChangeEvent) => { - const { id, value } = e.target - setFormData(prev => ({ ...prev, [id]: value })) + + const [deliveryMethod, setDeliveryMethod] = useState("cdek") + + const [address, setAddress] = useState({ + city: "Новокузнецк", // По умолчанию + street: "", + house: "", + apartment: "", + postalCode: "" + }) + + const [paymentMethod, setPaymentMethod] = useState("sbp") + + const [orderComment, setOrderComment] = useState("") + + // Проверка валидности заполнения формы + const isFormValid = () => { + const isUserInfoValid = + userInfo.firstName && + userInfo.email && + userInfo.phone + + const isAddressValid = + address.city && + address.street && + address.house + + return isUserInfoValid && isAddressValid } - - const handleRadioChange = (field: string, value: string) => { - setFormData(prev => ({ ...prev, [field]: value })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - // В реальном приложении здесь был бы код для отправки заказа - console.log("Заказ оформлен:", formData) - setOrderComplete(true) - } - - // Mock cart items - const cartItems = [ - { - id: 1, - name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ", - price: 5990, - quantity: 1, - size: "M", - color: "Белый", - image: "/placeholder.svg?height=600&width=400", - }, - { - id: 2, - name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА", - price: 4990, - quantity: 2, - size: "S", - color: "Синий", - image: "/placeholder.svg?height=600&width=400", - }, - ] - - // Calculate totals - const subtotal = cartItems.reduce((total, item) => total + item.price * item.quantity, 0) - const shipping = formData.deliveryMethod === "courier" ? 500 : formData.deliveryMethod === "pickup" ? 300 : 400 - const total = subtotal + shipping - - // Анимационные варианты - const fadeIn = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.5 } } - } - - const staggerContainer = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1 + + // Обработчик оформления заказа + const handleSubmitOrder = async () => { + if (!isFormValid()) { + toast({ + variant: "destructive", + title: "Форма не заполнена", + description: "Пожалуйста, заполните все обязательные поля" + }) + return + } + + if (!cart.items || cart.items.length === 0) { + toast({ + variant: "destructive", + title: "Корзина пуста", + description: "Невозможно оформить заказ с пустой корзиной" + }) + return + } + + try { + setIsSubmitting(true) + + // Подготавливаем данные для заказа + const orderData = { + userInfo, + items: cart.items, + address, + deliveryMethod, + paymentMethod, + comment: orderComment } + + // Сохраняем состояние корзины для показа на странице успешного оформления + try { + // Создаем копию корзины + const orderCartCopy = JSON.stringify(cart); + localStorage.setItem('last_order_cart', orderCartCopy); + } catch (error) { + console.error("Ошибка при сохранении состояния корзины:", error); + } + + // Создаем заказ + const order = await orderService.createOrder(orderData) + + // Очищаем корзину + await clearCart() + + // Перенаправляем на страницу успешного оформления + router.push(`/checkout/success?order_id=${order.orderId}&total=${order.total}&email=${encodeURIComponent(userInfo.email)}`) + } catch (error) { + console.error("Ошибка при оформлении заказа:", error) + // Удаляем временные данные для предотвращения утечки + try { + localStorage.removeItem('last_order_cart'); + } catch (error) { + console.error("Ошибка при очистке данных заказа:", error); + } + + toast({ + variant: "destructive", + title: "Ошибка", + description: "Произошла ошибка при оформлении заказа. Пожалуйста, попробуйте снова." + }) + } finally { + setIsSubmitting(false) } } - - if (orderComplete) { + + // Проверка наличия товаров в корзине + if (!loading && (!cart.items || cart.items.length === 0)) { return ( - -
- -
-

Заказ успешно оформлен

-

- Спасибо за ваш заказ! Мы отправили подтверждение на вашу электронную почту. - Номер вашего заказа: ORD-{Math.floor(100000 + Math.random() * 900000)} -

-
+
+

Оформление заказа

+
+

Ваша корзина пуста

-
- +
) } return ( -
-
-
- - - Вернуться в корзину - +
+

Оформление заказа

+ +
+ {/* Левая колонка - формы и выбор опций */} +
+ {/* Информация о пользователе */} +
+

Информация о получателе

+ +
+ + {/* Способ доставки */} +
+

Способ доставки

+ +
+ + {/* Адрес доставки */} +
+

Адрес доставки

+ +
+ + {/* Комментарий к заказу */} +
+

Комментарий к заказу

+ +
+ + {/* Способ оплаты */} +
+

Способ оплаты

+ +
-

Оформление заказа

- -
- {/* Checkout Form */} - - {/* Step 1: Contact Information */} - +
+

Ваш заказ

+ + + + + - - )} - - - - {/* Step 2: Shipping */} - -
-
= 2 ? "bg-primary text-white" : "bg-gray-200 text-gray-500"}`}> - 2 -
-

Доставка

-
- - - {step === 2 && ( - - handleRadioChange("deliveryMethod", value)} - className="space-y-4" - > -
- -
- -

Доставка курьером по адресу

-

500 ₽

-
-
- -
- -
- -

Выберите удобный пункт выдачи

-

300 ₽

-
-
- -
- -
- -

Доставка в отделение Почты России

-

400 ₽

-
-
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
-
- )} -
-
- - {/* Step 3: Payment */} - -
-
= 3 ? "bg-primary text-white" : "bg-gray-200 text-gray-500"}`}> - 3 -
-

Оплата

-
- - - {step === 3 && ( - - handleRadioChange("paymentMethod", value)} - className="space-y-4" - > -
- -
- -

Visa, MasterCard, МИР

-
-
- -
- -
- -

Оплата курьеру или в пункте выдачи

-
-
-
- -
- -