diff --git a/.DS_Store b/.DS_Store index 4c3be68..69c8143 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.cursor/rules/.cursorrules b/.cursor/rules/.cursorrules new file mode 100644 index 0000000..f57ec0c --- /dev/null +++ b/.cursor/rules/.cursorrules @@ -0,0 +1,107 @@ +Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**. + +### Ключевые принципы + +- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript. +- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы. +- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода. +- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`). +- Следуйте правильным **соглашениям об именовании**: + - Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`). + - Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`). + +### Структура проекта + +- **Фронтенд**: + - **Язык**: 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/`: Служебные функции и утилиты + - `frontend/public/`: Статические файлы + - `frontend/styles/`: CSS стили + - **Конфигурационные файлы**: + - `next.config.mjs` + - `tsconfig.json` + - `tailwind.config.ts` + - `postcss.config.mjs` + +- **Бэкенд**: + - **Язык**: Python + - **Фреймворк**: FastAPI + - **База данных**: PostgreSQL + - **ORM**: SQLAlchemy 2.0 + - **Директории**: + - `backend/app/`: Основной код + - `models/`: Модели базы данных + - `repositories/`: Репозитории для работы с данными + - `schemas/`: Pydantic схемы + - `services/`: Бизнес-логика + - `routers/`: Endpoints API + - `backend/uploads/`: Загруженные файлы + - **Конфигурационные файлы**: + - `alembic.ini`: Конфигурация миграций + - `.env`: Переменные окружения + +### Стиль кода и структура + +**Бэкенд (Python/FastAPI)**: + +- Используйте `def` для чистых функций и `async def` для асинхронных операций. +- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных. +- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем. +- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций. +- **Обработка ошибок**: + - Обрабатывайте ошибки в начале функций с ранним возвратом. + - Используйте защитные условия и избегайте глубоко вложенных условий. + - Реализуйте правильное логирование и пользовательские типы ошибок. + +**Фронтенд (TypeScript/React/Next.js)**: + +- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них. +- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript. +- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии. +- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно: + - Предпочитайте серверные компоненты, где это возможно + - Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей + - Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности + +### Оптимизация производительности + +**Бэкенд**: + +- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции. +- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных. +- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API. + +**Фронтенд**: + +- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг. +- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image. +- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID). + +### Проектные соглашения + +**Бэкенд**: + +1. Следуйте **принципам проектирования RESTful API**. +2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами. +3. Используйте **SQLAlchemy 2.0** для функций ORM. +4. Обеспечьте правильную настройку **CORS** для локальной разработки. +5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API. + +**Фронтенд**: + +1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов. +2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами. +3. Используйте хуки React эффективно, избегая ненужных рендеров. +4. Реализуйте интернационализацию для поддержки русского языка. + +### Тестирование и развертывание + +- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда. +- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства. +- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении. diff --git a/.cursor/rules/fastapinextjs.mdc b/.cursor/rules/fastapinextjs.mdc new file mode 100644 index 0000000..9645761 --- /dev/null +++ b/.cursor/rules/fastapinextjs.mdc @@ -0,0 +1,131 @@ +--- +description: +globs: +alwaysApply: true +--- +Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**. + +### Ключевые принципы + +- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript. +- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы. +- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода. +- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`). +- Следуйте правильным **соглашениям об именовании**: + - Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`). + - Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`). + +- никогда не запускай сервера бекенд и фронтенд, я сам это делаю! +- комментируй что ты делаешь + +### Структура проекта + +- **Фронтенд**: + - **Язык**: 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` + + - `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-компоненты для аутентификации (контекст, провайдеры и т.д.). + + +- **Бэкенд**: + - **Язык**: Python + - **Фреймворк**: FastAPI + - **База данных**: PostgreSQL + - **ORM**: SQLAlchemy 2.0 + - **Директории**: + - `backend/app/`: Основной код + - `models/`: Модели базы данных + - `repositories/`: Репозитории для работы с данными + - `schemas/`: Pydantic схемы + - `services/`: Бизнес-логика + - `routers/`: Endpoints API + - `backend/uploads/`: Загруженные файлы + - `backend/docs/`: документация проекта + + - **Конфигурационные файлы**: + - `alembic.ini`: Конфигурация миграций + - `.env`: Переменные окружения + + +### Стиль кода и структура + +**Бэкенд (Python/FastAPI)**: + +- Используйте `def` для чистых функций и `async def` для асинхронных операций. +- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных. +- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем. +- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций. +- **Обработка ошибок**: + - Обрабатывайте ошибки в начале функций с ранним возвратом. + - Используйте защитные условия и избегайте глубоко вложенных условий. + - Реализуйте правильное логирование и пользовательские типы ошибок. + +**Фронтенд (TypeScript/React/Next.js)**: + +- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них. +- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript. Создавай компоненты где они нужны +- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии. +- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно: + - Предпочитайте серверные компоненты, где это возможно + - Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей + - Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности + +### Оптимизация производительности + +**Бэкенд**: + +- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции. +- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных. +- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API. + +**Фронтенд**: + +- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг. +- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image. +- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID). + +### Проектные соглашения + +**Бэкенд**: + +1. Следуйте **принципам проектирования RESTful API**. +2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами. +3. Используйте **SQLAlchemy 2.0** для функций ORM. +4. Обеспечьте правильную настройку **CORS** для локальной разработки. +5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API. + +**Фронтенд**: + +1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов. +2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами. +3. Используйте хуки React эффективно, избегая ненужных рендеров. +4. Реализуйте интернационализацию для поддержки русского языка. + +### Тестирование и развертывание + +- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда. +- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства. +- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении. diff --git a/backend/alembic/__pycache__/env.cpython-310.pyc b/backend/alembic/__pycache__/env.cpython-310.pyc index d2c8974..0fa48a2 100644 Binary files a/backend/alembic/__pycache__/env.cpython-310.pyc and b/backend/alembic/__pycache__/env.cpython-310.pyc differ diff --git a/backend/alembic/versions/9773b0186faa_add_size_table_and_update_product_.py b/backend/alembic/versions/9773b0186faa_add_size_table_and_update_product_.py new file mode 100644 index 0000000..b86b6d0 --- /dev/null +++ b/backend/alembic/versions/9773b0186faa_add_size_table_and_update_product_.py @@ -0,0 +1,57 @@ +"""Add size table and update product structure + +Revision ID: 9773b0186faa +Revises: ef40913679bd +Create Date: 2025-03-16 20:30:39.057254 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9773b0186faa' +down_revision: Union[str, None] = 'ef40913679bd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sizes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_index(op.f('ix_sizes_id'), 'sizes', ['id'], unique=False) + op.add_column('product_variants', sa.Column('size_id', sa.Integer(), nullable=False)) + op.create_foreign_key(None, 'product_variants', 'sizes', ['size_id'], ['id']) + op.drop_column('product_variants', 'discount_price') + op.drop_column('product_variants', 'name') + op.drop_column('product_variants', 'price') + op.add_column('products', sa.Column('price', sa.Float(), nullable=False)) + op.add_column('products', sa.Column('discount_price', sa.Float(), nullable=True)) + op.add_column('products', sa.Column('care_instructions', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('products', 'care_instructions') + op.drop_column('products', 'discount_price') + op.drop_column('products', 'price') + op.add_column('product_variants', sa.Column('price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False)) + op.add_column('product_variants', sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.add_column('product_variants', sa.Column('discount_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'product_variants', type_='foreignkey') + op.drop_column('product_variants', 'size_id') + op.drop_index(op.f('ix_sizes_id'), table_name='sizes') + op.drop_table('sizes') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc index db598a6..d1865ac 100644 Binary files a/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc and b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc differ diff --git a/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc b/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc new file mode 100644 index 0000000..806c2b1 Binary files /dev/null and b/backend/alembic/versions/__pycache__/9773b0186faa_add_size_table_and_update_product_.cpython-310.pyc differ diff --git a/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc index 16b070d..1365adf 100644 Binary files a/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc and b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc differ diff --git a/backend/app/__pycache__/__init__.cpython-310.pyc b/backend/app/__pycache__/__init__.cpython-310.pyc index 355953a..56882f0 100644 Binary files a/backend/app/__pycache__/__init__.cpython-310.pyc and b/backend/app/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc index f449340..6d7f6cc 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/__pycache__/core.cpython-310.pyc b/backend/app/__pycache__/core.cpython-310.pyc index 97aa23b..31943ba 100644 Binary files a/backend/app/__pycache__/core.cpython-310.pyc and b/backend/app/__pycache__/core.cpython-310.pyc differ diff --git a/backend/app/__pycache__/main.cpython-310.pyc b/backend/app/__pycache__/main.cpython-310.pyc index 2973c1c..b84afa7 100644 Binary files a/backend/app/__pycache__/main.cpython-310.pyc and b/backend/app/__pycache__/main.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/__init__.cpython-310.pyc b/backend/app/models/__pycache__/__init__.cpython-310.pyc index a091f91..12caadf 100644 Binary files a/backend/app/models/__pycache__/__init__.cpython-310.pyc and b/backend/app/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc index f490f20..1b8d227 100644 Binary files a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc and b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/content_models.cpython-310.pyc b/backend/app/models/__pycache__/content_models.cpython-310.pyc index a7ec942..af03adc 100644 Binary files a/backend/app/models/__pycache__/content_models.cpython-310.pyc and b/backend/app/models/__pycache__/content_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/order_models.cpython-310.pyc b/backend/app/models/__pycache__/order_models.cpython-310.pyc index f210656..38ae743 100644 Binary files a/backend/app/models/__pycache__/order_models.cpython-310.pyc and b/backend/app/models/__pycache__/order_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/review_models.cpython-310.pyc b/backend/app/models/__pycache__/review_models.cpython-310.pyc index ef987bd..58f7453 100644 Binary files a/backend/app/models/__pycache__/review_models.cpython-310.pyc and b/backend/app/models/__pycache__/review_models.cpython-310.pyc differ diff --git a/backend/app/models/__pycache__/user_models.cpython-310.pyc b/backend/app/models/__pycache__/user_models.cpython-310.pyc index 9283a83..830bcf9 100644 Binary files a/backend/app/models/__pycache__/user_models.cpython-310.pyc and b/backend/app/models/__pycache__/user_models.cpython-310.pyc differ diff --git a/backend/app/models/catalog_models.py b/backend/app/models/catalog_models.py index a204857..06fb7a0 100644 --- a/backend/app/models/catalog_models.py +++ b/backend/app/models/catalog_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -37,6 +37,20 @@ class Category(Base): products = relationship("Product", back_populates="category") +class Size(Base): + __tablename__ = "sizes" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) # S, M, L, XL и т.д. + code = Column(String, nullable=False, unique=True) # Уникальный код размера + description = Column(String, nullable=True) # Дополнительная информация + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Отношения + product_variants = relationship("ProductVariant", back_populates="size") + + class Product(Base): __tablename__ = "products" @@ -44,6 +58,9 @@ class Product(Base): name = Column(String, nullable=False) slug = Column(String, unique=True, index=True, nullable=False) description = Column(Text, nullable=True) + price = Column(Float, nullable=False) # Базовая цена продукта + discount_price = Column(Float, nullable=True) # Цена со скидкой + care_instructions = Column(JSON, nullable=True) # Инструкции по уходу и другие детали is_active = Column(Boolean, default=True) collection_id = Column(Integer, ForeignKey("collections.id")) @@ -64,17 +81,16 @@ class ProductVariant(Base): id = Column(Integer, primary_key=True, index=True) product_id = Column(Integer, ForeignKey("products.id"), nullable=False) - name = Column(String, nullable=False) - sku = Column(String, unique=True, nullable=False) # артикул - price = Column(Float, nullable=False) - discount_price = Column(Float, nullable=True) - stock = Column(Integer, default=0) + size_id = Column(Integer, ForeignKey("sizes.id"), nullable=False) + sku = Column(String, unique=True, nullable=False) # артикул + stock = Column(Integer, default=0) # количество на складе is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Отношения product = relationship("Product", back_populates="variants") + size = relationship("Size", back_populates="product_variants") class ProductImage(Base): diff --git a/backend/app/repositories/__pycache__/__init__.cpython-310.pyc b/backend/app/repositories/__pycache__/__init__.cpython-310.pyc index 8893841..d91307b 100644 Binary files a/backend/app/repositories/__pycache__/__init__.cpython-310.pyc and b/backend/app/repositories/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc index e6d1551..529d69e 100644 Binary files a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc index 768ed39..9d81f9f 100644 Binary files a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/order_repo.cpython-310.pyc index 00f147d..9eb2f7a 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/__pycache__/review_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc index 3913f55..a483185 100644 Binary files a/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc index 44a00c6..1498564 100644 Binary files a/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc differ diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py index e2c0b6b..f241f74 100644 --- a/backend/app/repositories/catalog_repo.py +++ b/backend/app/repositories/catalog_repo.py @@ -5,13 +5,14 @@ from typing import List, Optional, Dict, Any import re from datetime import datetime -from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection +from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection, Size from app.schemas.catalog_schemas import ( CategoryCreate, CategoryUpdate, ProductCreate, ProductUpdate, ProductVariantCreate, ProductVariantUpdate, ProductImageCreate, ProductImageUpdate, - CollectionCreate, CollectionUpdate + CollectionCreate, CollectionUpdate, + SizeCreate, SizeUpdate ) @@ -380,32 +381,34 @@ def create_product(db: Session, product: ProductCreate) -> Product: if not product.slug: product.slug = generate_slug(product.name) - # Проверяем, что продукт с таким slug не существует - if get_product_by_slug(db, product.slug): + # Проверяем, что slug уникален + if db.query(Product).filter(Product.slug == product.slug).first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Продукт с таким slug уже существует" + detail=f"Продукт с slug '{product.slug}' уже существует" ) # Проверяем, что категория существует if not get_category(db, product.category_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Категория не найдена" + detail=f"Категория с ID {product.category_id} не найдена" ) - # Проверяем, что коллекция существует, если указана + # Проверяем, что коллекция существует, если она указана if product.collection_id and not get_collection(db, product.collection_id): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Коллекция не найдена" + detail=f"Коллекция с ID {product.collection_id} не найдена" ) - # Создаем новый продукт db_product = Product( name=product.name, slug=product.slug, description=product.description, + price=product.price, + discount_price=product.discount_price, + care_instructions=product.care_instructions, is_active=product.is_active, category_id=product.category_id, collection_id=product.collection_id @@ -416,11 +419,11 @@ def create_product(db: Session, product: ProductCreate) -> Product: db.commit() db.refresh(db_product) return db_product - except IntegrityError: + except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ошибка при создании продукта" + detail=f"Ошибка при создании продукта: {str(e)}" ) @@ -429,57 +432,72 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod if not db_product: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + detail=f"Продукт с ID {product_id} не найден" ) - # Обновляем только предоставленные поля - update_data = product.dict(exclude_unset=True) - # Если slug изменяется, проверяем его уникальность - if "slug" in update_data and update_data["slug"] != db_product.slug: - if get_product_by_slug(db, update_data["slug"]): + if product.slug is not None and product.slug != db_product.slug: + if db.query(Product).filter(Product.slug == product.slug).first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Продукт с таким slug уже существует" + detail=f"Продукт с slug '{product.slug}' уже существует" ) - # Если имя изменяется и slug не предоставлен, генерируем новый slug - if "name" in update_data and "slug" not in update_data: - update_data["slug"] = generate_slug(update_data["name"]) - # Проверяем уникальность сгенерированного slug - if get_product_by_slug(db, update_data["slug"]) and get_product_by_slug(db, update_data["slug"]).id != product_id: + # Если имя изменяется и slug не предоставлен, обновляем slug + if product.name is not None and product.name != db_product.name and product.slug is None: + product.slug = generate_slug(product.name) + # Проверяем, что новый slug уникален + if db.query(Product).filter(Product.slug == product.slug).first(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Продукт с таким slug уже существует" + detail=f"Продукт с slug '{product.slug}' уже существует" ) - # Проверяем, что категория существует, если указана - if "category_id" in update_data and update_data["category_id"] and not get_category(db, update_data["category_id"]): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Категория не найдена" - ) + # Проверяем, что категория существует, если она изменяется + if product.category_id is not None and product.category_id != db_product.category_id: + if not get_category(db, product.category_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Категория с ID {product.category_id} не найдена" + ) - # Проверяем, что коллекция существует, если указана - if "collection_id" in update_data and update_data["collection_id"] and not get_collection(db, update_data["collection_id"]): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Коллекция не найдена" - ) + # Проверяем, что коллекция существует, если она изменяется + if product.collection_id is not None and product.collection_id != db_product.collection_id: + if product.collection_id and not get_collection(db, product.collection_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Коллекция с ID {product.collection_id} не найдена" + ) - # Применяем обновления - for key, value in update_data.items(): - setattr(db_product, key, value) + # Обновляем поля + if product.name is not None: + db_product.name = product.name + if product.slug is not None: + db_product.slug = product.slug + if product.description is not None: + db_product.description = product.description + if product.price is not None: + db_product.price = product.price + if product.discount_price is not None: + db_product.discount_price = product.discount_price + if product.care_instructions is not None: + db_product.care_instructions = product.care_instructions + if product.is_active is not None: + db_product.is_active = product.is_active + if product.category_id is not None: + db_product.category_id = product.category_id + if product.collection_id is not None: + db_product.collection_id = product.collection_id try: db.commit() db.refresh(db_product) return db_product - except IntegrityError: + except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ошибка при обновлении продукта" + detail=f"Ошибка при обновлении продукта: {str(e)}" ) @@ -514,26 +532,44 @@ def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]: def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant: # Проверяем, что продукт существует - if not get_product(db, variant.product_id): + db_product = get_product(db, variant.product_id) + if not db_product: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + detail=f"Продукт с ID {variant.product_id} не найден" + ) + + # Проверяем, что размер существует + db_size = get_size(db, variant.size_id) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant.size_id} не найден" ) # Проверяем, что вариант с таким SKU не существует - if db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first(): + existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first() + if existing_variant: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Вариант с таким SKU уже существует" + detail=f"Вариант продукта с SKU {variant.sku} уже существует" + ) + + # Проверяем, что вариант с таким размером для этого продукта не существует + existing_size_variant = db.query(ProductVariant).filter( + ProductVariant.product_id == variant.product_id, + ProductVariant.size_id == variant.size_id + ).first() + if existing_size_variant: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта" ) - # Создаем новый вариант продукта db_variant = ProductVariant( product_id=variant.product_id, - name=variant.name, + size_id=variant.size_id, sku=variant.sku, - price=variant.price, - discount_price=variant.discount_price, stock=variant.stock, is_active=variant.is_active ) @@ -543,11 +579,11 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc db.commit() db.refresh(db_variant) return db_variant - except IntegrityError: + except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ошибка при создании варианта продукта" + detail=f"Ошибка при создании варианта продукта: {str(e)}" ) @@ -556,41 +592,73 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant if not db_variant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Вариант продукта не найден" + detail=f"Вариант продукта с ID {variant_id} не найден" ) - # Обновляем только предоставленные поля - update_data = variant.dict(exclude_unset=True) - - # Если product_id изменяется, проверяем, что продукт существует - if "product_id" in update_data and update_data["product_id"] != db_variant.product_id: - if not get_product(db, update_data["product_id"]): + # Проверяем, что продукт существует, если ID продукта изменяется + if variant.product_id is not None and variant.product_id != db_variant.product_id: + db_product = get_product(db, variant.product_id) + if not db_product: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + detail=f"Продукт с ID {variant.product_id} не найден" ) - # Если SKU изменяется, проверяем его уникальность - if "sku" in update_data and update_data["sku"] != db_variant.sku: - if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first(): + # Проверяем, что размер существует, если ID размера изменяется + if variant.size_id is not None and variant.size_id != db_variant.size_id: + db_size = get_size(db, variant.size_id) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant.size_id} не найден" + ) + + # Проверяем, что вариант с таким размером для этого продукта не существует + product_id = variant.product_id if variant.product_id is not None else db_variant.product_id + existing_size_variant = db.query(ProductVariant).filter( + ProductVariant.product_id == product_id, + ProductVariant.size_id == variant.size_id, + ProductVariant.id != variant_id + ).first() + if existing_size_variant: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Вариант с таким SKU уже существует" + detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта" ) - # Применяем обновления - for key, value in update_data.items(): - setattr(db_variant, key, value) + # Проверяем, что SKU уникален, если он изменяется + if variant.sku is not None and variant.sku != db_variant.sku: + existing_variant = db.query(ProductVariant).filter( + ProductVariant.sku == variant.sku, + ProductVariant.id != variant_id + ).first() + if existing_variant: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Вариант продукта с SKU {variant.sku} уже существует" + ) + + # Обновляем поля + if variant.product_id is not None: + db_variant.product_id = variant.product_id + if variant.size_id is not None: + db_variant.size_id = variant.size_id + if variant.sku is not None: + db_variant.sku = variant.sku + if variant.stock is not None: + db_variant.stock = variant.stock + if variant.is_active is not None: + db_variant.is_active = variant.is_active try: db.commit() db.refresh(db_variant) return db_variant - except IntegrityError: + except IntegrityError as e: db.rollback() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ошибка при обновлении варианта продукта" + detail=f"Ошибка при обновлении варианта продукта: {str(e)}" ) @@ -706,4 +774,110 @@ def delete_product_image(db: Session, image_id: int) -> bool: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Ошибка при удалении изображения продукта" + ) + + +# Функции для работы с размерами +def get_size(db: Session, size_id: int) -> Optional[Size]: + return db.query(Size).filter(Size.id == size_id).first() + + +def get_size_by_code(db: Session, code: str) -> Optional[Size]: + return db.query(Size).filter(Size.code == code).first() + + +def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]: + return db.query(Size).offset(skip).limit(limit).all() + + +def create_size(db: Session, size: SizeCreate) -> Size: + # Проверяем, что размер с таким кодом не существует + existing_size = get_size_by_code(db, size.code) + if existing_size: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Размер с кодом {size.code} уже существует" + ) + + db_size = Size( + name=size.name, + code=size.code, + description=size.description + ) + + try: + db.add(db_size) + db.commit() + db.refresh(db_size) + return db_size + except IntegrityError as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Ошибка при создании размера: {str(e)}" + ) + + +def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: + db_size = get_size(db, size_id) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {size_id} не найден" + ) + + # Проверяем, что код размера уникален, если он изменяется + if size.code and size.code != db_size.code: + existing_size = get_size_by_code(db, size.code) + if existing_size: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Размер с кодом {size.code} уже существует" + ) + + # Обновляем поля + if size.name is not None: + db_size.name = size.name + if size.code is not None: + db_size.code = size.code + if size.description is not None: + db_size.description = size.description + + try: + db.commit() + db.refresh(db_size) + return db_size + except IntegrityError as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Ошибка при обновлении размера: {str(e)}" + ) + + +def delete_size(db: Session, size_id: int) -> bool: + db_size = get_size(db, size_id) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {size_id} не найден" + ) + + # Проверяем, используется ли размер в вариантах продуктов + variants_with_size = db.query(ProductVariant).filter(ProductVariant.size_id == size_id).count() + if variants_with_size > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов" + ) + + try: + db.delete(db_size) + db.commit() + return True + except IntegrityError as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Ошибка при удалении размера: {str(e)}" ) \ No newline at end of file diff --git a/backend/app/repositories/order_repo.py b/backend/app/repositories/order_repo.py index 195e0f8..af09846 100644 --- a/backend/app/repositories/order_repo.py +++ b/backend/app/repositories/order_repo.py @@ -419,74 +419,80 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A return result -def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]: - order = get_order(db, order_id) +def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]: + order = db.query(Order).filter(Order.id == order_id).first() if not order: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Заказ не найден" - ) - - # Получаем пользователя - user = db.query(User).filter(User.id == order.user_id).first() - + 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 + }) + # Получаем адрес доставки 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 } - - # Получаем элементы заказа с деталями - 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 - - # Формируем элемент заказа - items.append({ - "id": item.id, - "order_id": item.order_id, - "variant_id": item.variant_id, - "quantity": item.quantity, - "price": item.price, - "created_at": item.created_at, - "product_id": product.id, - "product_name": product.name, - "variant_name": variant.name, - "total_price": item.price * item.quantity - }) - - # Формируем результат - result = { + + 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, "status": order.status, - "total_amount": order.total_amount, - "shipping_address_id": order.shipping_address_id, - "payment_method": order.payment_method, - "payment_details": order.payment_details, - "tracking_number": order.tracking_number, - "notes": order.notes, + "total": order.total_amount, "created_at": order.created_at, "updated_at": order.updated_at, - "user_email": user.email if user else None, + "items_count": len(items), + "items": items, "shipping_address": shipping_address, - "items": items - } - - return result \ No newline at end of file + "payment_method": order.payment_method, + "tracking_number": order.tracking_number, + "notes": order.notes + } \ 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 8edd209..617da1e 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__/analytics_router.cpython-310.pyc b/backend/app/routers/__pycache__/analytics_router.cpython-310.pyc index d0707c9..f722883 100644 Binary files a/backend/app/routers/__pycache__/analytics_router.cpython-310.pyc and b/backend/app/routers/__pycache__/analytics_router.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/auth_router.cpython-310.pyc b/backend/app/routers/__pycache__/auth_router.cpython-310.pyc index ec953bf..d3b672d 100644 Binary files a/backend/app/routers/__pycache__/auth_router.cpython-310.pyc and b/backend/app/routers/__pycache__/auth_router.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 7635dc9..f272fec 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__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc index 1dac9db..79b249d 100644 Binary files a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc and b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/content_router.cpython-310.pyc b/backend/app/routers/__pycache__/content_router.cpython-310.pyc index bf24fb2..0b8208d 100644 Binary files a/backend/app/routers/__pycache__/content_router.cpython-310.pyc and b/backend/app/routers/__pycache__/content_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 1e5be83..591cfb3 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/__pycache__/review_router.cpython-310.pyc b/backend/app/routers/__pycache__/review_router.cpython-310.pyc index f8e8c3c..095fbc9 100644 Binary files a/backend/app/routers/__pycache__/review_router.cpython-310.pyc and b/backend/app/routers/__pycache__/review_router.cpython-310.pyc differ diff --git a/backend/app/routers/__pycache__/user_router.cpython-310.pyc b/backend/app/routers/__pycache__/user_router.cpython-310.pyc index e4d48d7..dc7e00e 100644 Binary files a/backend/app/routers/__pycache__/user_router.cpython-310.pyc and b/backend/app/routers/__pycache__/user_router.cpython-310.pyc differ diff --git a/backend/app/routers/catalog_router.py b/backend/app/routers/catalog_router.py index 8f69936..44af916 100644 --- a/backend/app/routers/catalog_router.py +++ b/backend/app/routers/catalog_router.py @@ -1,15 +1,16 @@ -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query from sqlalchemy.orm import Session from typing import List, Optional, Dict, Any from app.core import get_db, get_current_admin_user from app import services from app.schemas.catalog_schemas import ( - CategoryCreate, CategoryUpdate, Category, - ProductCreate, ProductUpdate, Product, + CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories, + ProductCreate, ProductUpdate, Product, ProductWithDetails, ProductVariantCreate, ProductVariantUpdate, ProductVariant, ProductImageCreate, ProductImageUpdate, ProductImage, - CollectionCreate, CollectionUpdate, Collection + CollectionCreate, CollectionUpdate, Collection, + SizeCreate, SizeUpdate, Size ) from app.models.user_models import User as UserModel from app.repositories.catalog_repo import get_products, get_product_by_slug @@ -90,18 +91,34 @@ async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)) @catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any]) -async def add_product_variant_endpoint(product_id: int, variant: ProductVariantCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): - variant.product_id = product_id +async def add_product_variant_endpoint( + product_id: int, + variant: ProductVariantCreate, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + # Убедимся, что product_id в пути совпадает с product_id в данных + if variant.product_id != product_id: + variant.product_id = product_id return services.add_product_variant(db, variant) @catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any]) -async def update_product_variant_endpoint(variant_id: int, variant: ProductVariantUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): +async def update_product_variant_endpoint( + variant_id: int, + variant: ProductVariantUpdate, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): return services.update_product_variant(db, variant_id, variant) @catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any]) -async def delete_product_variant_endpoint(variant_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): +async def delete_product_variant_endpoint( + variant_id: int, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): return services.delete_product_variant(db, variant_id) @@ -141,4 +158,53 @@ async def get_products_endpoint( ): products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants) # Преобразуем объекты SQLAlchemy в схемы Pydantic - return [Product.model_validate(product) for product in products] \ No newline at end of file + return [Product.model_validate(product) for product in products] + + +# Маршруты для размеров +@catalog_router.get("/sizes", response_model=List[Size]) +def get_sizes( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """Получить список всех размеров""" + return services.get_sizes(db, skip, limit) + + +@catalog_router.get("/sizes/{size_id}", response_model=Size) +def get_size( + size_id: int, + db: Session = Depends(get_db) +): + """Получить размер по ID""" + return services.get_size(db, size_id) + + +@catalog_router.post("/sizes", response_model=Size, status_code=status.HTTP_201_CREATED) +def create_size( + size: SizeCreate, + db: Session = Depends(get_db) +): + """Создать новый размер""" + return services.create_size(db, size) + + +@catalog_router.put("/sizes/{size_id}", response_model=Size) +def update_size( + size_id: int, + size: SizeUpdate, + db: Session = Depends(get_db) +): + """Обновить размер""" + return services.update_size(db, size_id, size) + + +@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, bool]) +def delete_size( + size_id: int, + db: Session = Depends(get_db) +): + """Удалить размер""" + success = services.delete_size(db, size_id) + return {"success": success} \ No newline at end of file diff --git a/backend/app/schemas/__pycache__/__init__.cpython-310.pyc b/backend/app/schemas/__pycache__/__init__.cpython-310.pyc index adb1322..494f0af 100644 Binary files a/backend/app/schemas/__pycache__/__init__.cpython-310.pyc and b/backend/app/schemas/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc index 1936503..89ba868 100644 Binary files a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc index 0806d7d..9aded37 100644 Binary files a/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc index 53cf3c4..266b4a2 100644 Binary files a/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc index 0b620ca..edb6c4b 100644 Binary files a/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc index d28c5e4..f531584 100644 Binary files a/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc differ diff --git a/backend/app/schemas/catalog_schemas.py b/backend/app/schemas/catalog_schemas.py index b604302..fa9c90b 100644 --- a/backend/app/schemas/catalog_schemas.py +++ b/backend/app/schemas/catalog_schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field, validator -from typing import Optional, List, Union +from typing import Optional, List, Union, Dict, Any from datetime import datetime @@ -62,14 +62,37 @@ class Category(CategoryBase): from_attributes = True +# Схемы для размеров +class SizeBase(BaseModel): + name: str + code: str + description: Optional[str] = None + + +class SizeCreate(SizeBase): + pass + + +class SizeUpdate(SizeBase): + name: Optional[str] = None + code: Optional[str] = None + description: Optional[str] = None + + +class Size(SizeBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + # Схемы для вариантов продуктов class ProductVariantBase(BaseModel): product_id: int - name: str + size_id: int sku: str - price: float - discount_price: Optional[float] = None stock: int = 0 is_active: bool = True @@ -78,12 +101,10 @@ class ProductVariantCreate(ProductVariantBase): pass -class ProductVariantUpdate(ProductVariantBase): +class ProductVariantUpdate(BaseModel): product_id: Optional[int] = None - name: Optional[str] = None + size_id: Optional[int] = None sku: Optional[str] = None - price: Optional[float] = None - discount_price: Optional[float] = None stock: Optional[int] = None is_active: Optional[bool] = None @@ -92,6 +113,7 @@ class ProductVariant(ProductVariantBase): id: int created_at: datetime updated_at: Optional[datetime] = None + size: Optional[Size] = None class Config: from_attributes = True @@ -123,12 +145,14 @@ class ProductImage(ProductImageBase): from_attributes = True - # Схемы для продуктов class ProductBase(BaseModel): name: str slug: Optional[str] = None description: Optional[str] = None + price: float + discount_price: Optional[float] = None + care_instructions: Optional[Dict[str, Any]] = None is_active: bool = True category_id: int collection_id: Optional[int] = None @@ -138,10 +162,13 @@ class ProductCreate(ProductBase): pass -class ProductUpdate(ProductBase): +class ProductUpdate(BaseModel): name: Optional[str] = None slug: Optional[str] = None description: Optional[str] = None + price: Optional[float] = None + discount_price: Optional[float] = None + care_instructions: Optional[Dict[str, Any]] = None is_active: Optional[bool] = None category_id: Optional[int] = None collection_id: Optional[int] = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 53c9f4b..450e493 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -9,7 +9,8 @@ from app.services.catalog_service import ( create_product, update_product, delete_product, get_product_details, add_product_variant, update_product_variant, delete_product_variant, upload_product_image, update_product_image, delete_product_image, - create_collection, update_collection, delete_collection, get_collections + create_collection, update_collection, delete_collection, get_collections, + get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size ) from app.services.order_service import ( diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc index 690a72e..73637b4 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__/catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc index 891c188..7182117 100644 Binary files a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc and b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/content_service.cpython-310.pyc b/backend/app/services/__pycache__/content_service.cpython-310.pyc index 38a296c..3a7ce19 100644 Binary files a/backend/app/services/__pycache__/content_service.cpython-310.pyc and b/backend/app/services/__pycache__/content_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 f65da0c..15c0542 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/__pycache__/review_service.cpython-310.pyc b/backend/app/services/__pycache__/review_service.cpython-310.pyc index 2a5ecb8..866b20d 100644 Binary files a/backend/app/services/__pycache__/review_service.cpython-310.pyc and b/backend/app/services/__pycache__/review_service.cpython-310.pyc differ diff --git a/backend/app/services/__pycache__/user_service.cpython-310.pyc b/backend/app/services/__pycache__/user_service.cpython-310.pyc index 93eaf27..1eede0d 100644 Binary files a/backend/app/services/__pycache__/user_service.cpython-310.pyc and b/backend/app/services/__pycache__/user_service.cpython-310.pyc differ diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py index d47fbc4..e2e2f4b 100644 --- a/backend/app/services/catalog_service.py +++ b/backend/app/services/catalog_service.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from fastapi import HTTPException, status, UploadFile -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional import os import uuid import shutil @@ -9,11 +9,12 @@ from pathlib import Path from app.config import settings from app.repositories import catalog_repo, review_repo from app.schemas.catalog_schemas import ( - CategoryCreate, CategoryUpdate, - ProductCreate, ProductUpdate, - ProductVariantCreate, ProductVariantUpdate, - ProductImageCreate, ProductImageUpdate, - CollectionCreate, CollectionUpdate + CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories, + ProductCreate, ProductUpdate, Product, ProductWithDetails, + ProductVariantCreate, ProductVariantUpdate, ProductVariant, + ProductImageCreate, ProductImageUpdate, ProductImage, + CollectionCreate, CollectionUpdate, Collection, + SizeCreate, SizeUpdate, Size ) @@ -137,21 +138,91 @@ def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any] def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: - from app.schemas.catalog_schemas import Product as ProductSchema - - new_product = catalog_repo.create_product(db, product) - # Преобразуем объект SQLAlchemy в схему Pydantic - product_schema = ProductSchema.model_validate(new_product) - return {"product": product_schema} + """Создать новый продукт""" + try: + # Проверяем, что категория существует + category = catalog_repo.get_category(db, product.category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Категория с ID {product.category_id} не найдена" + ) + + # Проверяем, что коллекция существует, если она указана + if product.collection_id: + collection = catalog_repo.get_collection(db, product.collection_id) + if not collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Коллекция с ID {product.collection_id} не найдена" + ) + + # Создаем продукт + db_product = catalog_repo.create_product(db, product) + + return { + "success": True, + "product": Product.from_orm(db_product) + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict[str, Any]: - from app.schemas.catalog_schemas import Product as ProductSchema - - updated_product = catalog_repo.update_product(db, product_id, product) - # Преобразуем объект SQLAlchemy в схему Pydantic - product_schema = ProductSchema.model_validate(updated_product) - return {"product": product_schema} + """Обновить продукт""" + try: + # Проверяем, что продукт существует + db_product = catalog_repo.get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт с ID {product_id} не найден" + ) + + # Если меняется категория, проверяем, что она существует + if product.category_id is not None: + category = catalog_repo.get_category(db, product.category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Категория с ID {product.category_id} не найдена" + ) + + # Если меняется коллекция, проверяем, что она существует + if product.collection_id is not None: + if product.collection_id: + collection = catalog_repo.get_collection(db, product.collection_id) + if not collection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Коллекция с ID {product.collection_id} не найдена" + ) + + # Обновляем продукт + updated_product = catalog_repo.update_product(db, product_id, product) + + return { + "success": True, + "product": Product.from_orm(updated_product) + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def delete_product(db: Session, product_id: int) -> Dict[str, Any]: @@ -160,68 +231,150 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]: def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: - from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema - from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema - from app.schemas.catalog_schemas import ProductImage as ProductImageSchema - from app.schemas.catalog_schemas import Collection as CollectionSchema - - product = catalog_repo.get_product(db, product_id) - if not product: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Продукт не найден" + """Получить детальную информацию о продукте""" + try: + # Получаем продукт + product = catalog_repo.get_product(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт с ID {product_id} не найден" + ) + + # Получаем варианты продукта + variants = catalog_repo.get_product_variants(db, product_id) + + # Получаем изображения продукта + images = catalog_repo.get_product_images(db, product_id) + + # Получаем категорию + category = catalog_repo.get_category(db, product.category_id) + + # Получаем коллекцию, если она указана + collection = None + if product.collection_id: + collection = catalog_repo.get_collection(db, product.collection_id) + + # Создаем детальное представление продукта + product_details = ProductWithDetails( + id=product.id, + name=product.name, + slug=product.slug, + description=product.description, + price=product.price, + discount_price=product.discount_price, + care_instructions=product.care_instructions, + is_active=product.is_active, + category_id=product.category_id, + collection_id=product.collection_id, + created_at=product.created_at, + updated_at=product.updated_at, + category=Category.from_orm(category), + collection=Collection.from_orm(collection) if collection else None, + variants=[ProductVariant.from_orm(variant) for variant in variants], + images=[ProductImage.from_orm(image) for image in images] ) - - # Получаем варианты продукта - variants = catalog_repo.get_product_variants(db, product_id) - - # Получаем изображения продукта - images = catalog_repo.get_product_images(db, product_id) - - # Получаем рейтинг продукта - rating = review_repo.get_product_rating(db, product_id) - - # Получаем отзывы продукта - reviews = review_repo.get_product_reviews(db, product_id, limit=5) - - # Преобразуем объекты SQLAlchemy в схемы Pydantic - product_schema = ProductSchema.model_validate(product) - variants_schema = [ProductVariantSchema.model_validate(variant) for variant in variants] - images_schema = [ProductImageSchema.model_validate(image) for image in images] - - # Добавляем информацию о коллекции, если она есть - collection_schema = None - if product.collection_id: - collection = catalog_repo.get_collection(db, product.collection_id) - if collection: - collection_schema = CollectionSchema.model_validate(collection) - - return { - "product": product_schema, - "variants": variants_schema, - "images": images_schema, - "collection": collection_schema, - "rating": rating, - "reviews": reviews - } + + return { + "success": True, + "product": product_details + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, Any]: - from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema - - new_variant = catalog_repo.create_product_variant(db, variant) - # Преобразуем объект SQLAlchemy в схему Pydantic - variant_schema = ProductVariantSchema.model_validate(new_variant) - return {"variant": variant_schema} + """Добавить вариант продукта""" + try: + # Проверяем, что продукт существует + product = catalog_repo.get_product(db, variant.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт с ID {variant.product_id} не найден" + ) + + # Проверяем, что размер существует + size = catalog_repo.get_size(db, variant.size_id) + if not size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant.size_id} не найден" + ) + + # Создаем вариант продукта + db_variant = catalog_repo.create_product_variant(db, variant) + + return { + "success": True, + "variant": ProductVariant.from_orm(db_variant) + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> Dict[str, Any]: - from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema - - updated_variant = catalog_repo.update_product_variant(db, variant_id, variant) - # Преобразуем объект SQLAlchemy в схему Pydantic - variant_schema = ProductVariantSchema.model_validate(updated_variant) - return {"variant": variant_schema} + """Обновить вариант продукта""" + try: + # Проверяем, что вариант существует + db_variant = catalog_repo.get_product_variant(db, variant_id) + if not db_variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {variant_id} не найден" + ) + + # Если меняется продукт, проверяем, что он существует + if variant.product_id is not None: + product = catalog_repo.get_product(db, variant.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт с ID {variant.product_id} не найден" + ) + + # Если меняется размер, проверяем, что он существует + if variant.size_id is not None: + size = catalog_repo.get_size(db, variant.size_id) + if not size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {variant.size_id} не найден" + ) + + # Обновляем вариант продукта + updated_variant = catalog_repo.update_product_variant(db, variant_id, variant) + + return { + "success": True, + "variant": ProductVariant.from_orm(updated_variant) + } + except HTTPException as e: + return { + "success": False, + "error": e.detail + } + except Exception as e: + return { + "success": False, + "error": str(e) + } def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]: @@ -308,4 +461,42 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: # В реальном приложении здесь должно быть логирование pass - return {"success": success} \ No newline at end of file + return {"success": success} + + +# Функции для работы с размерами +def get_size(db: Session, size_id: int) -> Size: + db_size = catalog_repo.get_size(db, size_id) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с ID {size_id} не найден" + ) + return Size.from_orm(db_size) + + +def get_size_by_code(db: Session, code: str) -> Size: + db_size = catalog_repo.get_size_by_code(db, code) + if not db_size: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Размер с кодом {code} не найден" + ) + return Size.from_orm(db_size) + + +def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]: + db_sizes = catalog_repo.get_sizes(db, skip, limit) + return [Size.from_orm(size) for size in db_sizes] + + +def create_size(db: Session, size: SizeCreate) -> Size: + return Size.from_orm(catalog_repo.create_size(db, size)) + + +def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size: + return Size.from_orm(catalog_repo.update_size(db, size_id, size)) + + +def delete_size(db: Session, size_id: int) -> bool: + return catalog_repo.delete_size(db, size_id) \ No newline at end of file diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 0000000..1374486 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,117 @@ +# Документация бэкенда интернет-магазина одежды + +## Содержание + +1. [API документация](api_documentation.md) - Подробное описание всех API эндпоинтов +2. [Структура базы данных](database_structure.md) - Описание таблиц и связей в базе данных + +## Технологический стек + +- **Язык программирования**: Python 3.10+ +- **Фреймворк**: FastAPI +- **ORM**: SQLAlchemy 2.0 +- **База данных**: PostgreSQL +- **Аутентификация**: JWT (JSON Web Tokens) + +## Архитектура приложения + +Приложение построено по принципу многослойной архитектуры: + +1. **Роутеры (Routers)** - Обрабатывают HTTP-запросы и ответы +2. **Сервисы (Services)** - Содержат бизнес-логику приложения +3. **Репозитории (Repositories)** - Отвечают за взаимодействие с базой данных +4. **Модели (Models)** - Описывают структуру таблиц базы данных +5. **Схемы (Schemas)** - Описывают структуру данных для API (Pydantic модели) + +## Структура проекта + +``` +backend/ +├── app/ +│ ├── __init__.py +│ ├── main.py # Точка входа в приложение +│ ├── config.py # Конфигурация приложения +│ ├── core.py # Основные функции и зависимости +│ ├── models/ # SQLAlchemy модели +│ │ ├── __init__.py +│ │ ├── user_models.py +│ │ ├── catalog_models.py +│ │ ├── order_models.py +│ │ ├── review_models.py +│ │ └── content_models.py +│ ├── schemas/ # Pydantic схемы +│ │ ├── __init__.py +│ │ ├── user_schemas.py +│ │ ├── catalog_schemas.py +│ │ ├── order_schemas.py +│ │ ├── review_schemas.py +│ │ └── content_schemas.py +│ ├── repositories/ # Репозитории для работы с БД +│ │ ├── __init__.py +│ │ ├── user_repo.py +│ │ ├── catalog_repo.py +│ │ ├── order_repo.py +│ │ ├── review_repo.py +│ │ └── content_repo.py +│ ├── services/ # Бизнес-логика +│ │ ├── __init__.py +│ │ ├── user_service.py +│ │ ├── catalog_service.py +│ │ ├── order_service.py +│ │ ├── review_service.py +│ │ └── content_service.py +│ └── routers/ # API эндпоинты +│ ├── __init__.py +│ ├── auth_router.py +│ ├── user_router.py +│ ├── catalog_router.py +│ ├── cart_router.py +│ ├── order_router.py +│ ├── review_router.py +│ ├── content_router.py +│ └── analytics_router.py +├── alembic/ # Миграции базы данных +├── tests/ # Тесты +└── docs/ # Документация + ├── README.md + ├── api_documentation.md + └── database_structure.md +``` + +## Запуск приложения + +### Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +### Настройка переменных окружения + +Создайте файл `.env` в корне проекта со следующими переменными: + +``` +DATABASE_URL=postgresql://user:password@localhost:5432/db_name +SECRET_KEY=your_secret_key +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +``` + +### Запуск сервера разработки + +```bash +uvicorn app.main:app --reload +``` + +### Запуск миграций + +```bash +alembic upgrade head +``` + +## Документация API + +После запуска приложения, документация API доступна по адресу: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc \ No newline at end of file diff --git a/backend/docs/api_documentation.md b/backend/docs/api_documentation.md new file mode 100644 index 0000000..a6fd9bd --- /dev/null +++ b/backend/docs/api_documentation.md @@ -0,0 +1,191 @@ +# Документация API интернет-магазина одежды + +## Содержание +1. [Аутентификация](#аутентификация) +2. [Пользователи](#пользователи) +3. [Каталог](#каталог) +4. [Корзина](#корзина) +5. [Заказы](#заказы) +6. [Отзывы](#отзывы) +7. [Контент](#контент) +8. [Аналитика](#аналитика) + +## Аутентификация + +Базовый URL: `/auth` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет | +| POST | `/login` | Вход в систему | `username` (email), `password` | Нет | +| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет | +| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет | +| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да | + +## Пользователи + +Базовый URL: `/users` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/me` | Получение профиля текущего пользователя | - | Да | +| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да | +| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да | +| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да | +| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да | +| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) | + +## Каталог + +Базовый URL: `/catalog` + +### Коллекции + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/collections` | Получение списка коллекций | `skip`, `limit` | Нет | +| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) | +| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) | +| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) | + +### Категории + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/categories` | Получение дерева категорий | - | Нет | +| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) | +| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) | +| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) | + +### Размеры + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/sizes` | Получение списка размеров | `skip`, `limit` | Нет | +| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет | +| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) | +| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) | +| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) | + +### Продукты + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `include_variants` | Нет | +| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет | +| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет | +| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | +| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | +| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) | + +### Варианты продуктов + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) | +| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) | +| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) | + +### Изображения продуктов + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file`, `is_primary` | Да (админ) | +| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) | +| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) | + +## Корзина + +Базовый URL: `/cart` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/` | Получение корзины пользователя | - | Да | +| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (product_variant_id, quantity) | Да | +| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да | +| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да | +| DELETE | `/clear` | Очистка корзины | - | Да | + +## Заказы + +Базовый URL: `/orders` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да | +| GET | `/{order_id}` | Получение информации о заказе | - | Да | +| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method) | Да | +| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, tracking_number) | Да (админ) | +| POST | `/{order_id}/cancel` | Отмена заказа | - | Да | + +## Отзывы + +Базовый URL: `/reviews` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет | +| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да | +| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да | +| DELETE | `/{review_id}` | Удаление отзыва | - | Да | +| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) | + +## Контент + +Базовый URL: `/content` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет | +| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет | +| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет | +| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) | +| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) | +| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) | + +## Аналитика + +Базовый URL: `/analytics` + +| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | +|-------|----------|----------|-------------------|------------------------| +| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет | +| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) | +| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) | + +## Модели данных + +### Пользователи +- `UserCreate`: email, password, first_name, last_name +- `UserUpdate`: first_name, last_name, phone +- `AddressCreate`: city, street, house, apartment, postal_code, is_default +- `AddressUpdate`: city, street, house, apartment, postal_code, is_default + +### Каталог +- `CollectionCreate`: name, slug, description, is_active +- `CollectionUpdate`: name, slug, description, is_active +- `CategoryCreate`: name, slug, description, parent_id, is_active +- `CategoryUpdate`: name, slug, description, parent_id, is_active +- `SizeCreate`: name, code, description +- `SizeUpdate`: name, code, description +- `ProductCreate`: name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id +- `ProductUpdate`: name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id +- `ProductVariantCreate`: product_id, size_id, sku, stock, is_active +- `ProductVariantUpdate`: product_id, size_id, sku, stock, is_active +- `ProductImageCreate`: product_id, image_url, alt_text, is_primary +- `ProductImageUpdate`: alt_text, is_primary + +### Корзина и заказы +- `CartItemCreate`: product_variant_id, quantity +- `CartItemUpdate`: quantity +- `OrderCreate`: shipping_address_id, payment_method +- `OrderUpdate`: status, tracking_number + +### Отзывы +- `ReviewCreate`: product_id, rating, text +- `ReviewUpdate`: rating, text + +### Контент +- `PageCreate`: title, slug, content, is_published +- `PageUpdate`: title, slug, content, is_published +- `AnalyticsLogCreate`: event_type, event_data, user_id \ No newline at end of file diff --git a/backend/docs/database_structure.md b/backend/docs/database_structure.md new file mode 100644 index 0000000..d6146a3 --- /dev/null +++ b/backend/docs/database_structure.md @@ -0,0 +1,210 @@ +# Структура базы данных интернет-магазина одежды + +## Содержание +1. [Пользователи](#пользователи) +2. [Каталог](#каталог) +3. [Заказы](#заказы) +4. [Отзывы](#отзывы) +5. [Контент](#контент) + +## Пользователи + +### Таблица `users` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| email | String | Email пользователя (уникальный) | +| hashed_password | String | Хешированный пароль | +| first_name | String | Имя пользователя | +| last_name | String | Фамилия пользователя | +| phone | String | Телефон пользователя (опционально) | +| is_active | Boolean | Активен ли пользователь | +| is_admin | Boolean | Является ли пользователь администратором | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `addresses` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| user_id | Integer | Внешний ключ на таблицу users | +| city | String | Город | +| street | String | Улица | +| house | String | Номер дома | +| apartment | String | Номер квартиры (опционально) | +| postal_code | String | Почтовый индекс | +| is_default | Boolean | Является ли адрес адресом по умолчанию | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +## Каталог + +### Таблица `collections` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| name | String | Название коллекции | +| slug | String | URL-совместимое имя (уникальное) | +| description | Text | Описание коллекции (опционально) | +| is_active | Boolean | Активна ли коллекция | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `categories` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| name | String | Название категории | +| slug | String | URL-совместимое имя (уникальное) | +| description | Text | Описание категории (опционально) | +| parent_id | Integer | Внешний ключ на родительскую категорию (опционально) | +| is_active | Boolean | Активна ли категория | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `sizes` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| name | String | Название размера (S, M, L, XL и т.д.) | +| code | String | Уникальный код размера | +| description | String | Дополнительная информация о размере (опционально) | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `products` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| name | String | Название продукта | +| slug | String | URL-совместимое имя (уникальное) | +| description | Text | Описание продукта (опционально) | +| price | Float | Базовая цена продукта | +| discount_price | Float | Цена со скидкой (опционально) | +| care_instructions | JSON | Инструкции по уходу и другие детали (опционально) | +| is_active | Boolean | Активен ли продукт | +| category_id | Integer | Внешний ключ на таблицу categories | +| collection_id | Integer | Внешний ключ на таблицу collections (опционально) | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `product_variants` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| product_id | Integer | Внешний ключ на таблицу products | +| size_id | Integer | Внешний ключ на таблицу sizes | +| sku | String | Артикул (уникальный) | +| stock | Integer | Количество на складе | +| is_active | Boolean | Активен ли вариант | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `product_images` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| product_id | Integer | Внешний ключ на таблицу products | +| image_url | String | URL изображения | +| alt_text | String | Альтернативный текст (опционально) | +| is_primary | Boolean | Является ли изображение основным | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +## Заказы + +### Таблица `cart_items` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| user_id | Integer | Внешний ключ на таблицу users | +| product_variant_id | Integer | Внешний ключ на таблицу product_variants | +| quantity | Integer | Количество товара | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `orders` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| user_id | Integer | Внешний ключ на таблицу users | +| status | String | Статус заказа (новый, оплачен, отправлен, доставлен, отменен) | +| total_amount | Float | Общая сумма заказа | +| shipping_address_id | Integer | Внешний ключ на таблицу addresses | +| payment_method | String | Способ оплаты | +| tracking_number | String | Номер отслеживания (опционально) | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `order_items` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| order_id | Integer | Внешний ключ на таблицу orders | +| product_variant_id | Integer | Внешний ключ на таблицу product_variants | +| quantity | Integer | Количество товара | +| price | Float | Цена товара на момент заказа | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +## Отзывы + +### Таблица `reviews` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| product_id | Integer | Внешний ключ на таблицу products | +| user_id | Integer | Внешний ключ на таблицу users | +| rating | Integer | Оценка (1-5) | +| text | Text | Текст отзыва (опционально) | +| is_approved | Boolean | Одобрен ли отзыв | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +## Контент + +### Таблица `pages` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| title | String | Заголовок страницы | +| slug | String | URL-совместимое имя (уникальное) | +| content | Text | Содержимое страницы | +| is_published | Boolean | Опубликована ли страница | +| created_at | DateTime | Дата и время создания | +| updated_at | DateTime | Дата и время обновления | + +### Таблица `analytics_logs` +| Поле | Тип | Описание | +|------|-----|----------| +| id | Integer | Первичный ключ | +| event_type | String | Тип события | +| event_data | JSON | Данные события | +| user_id | Integer | Внешний ключ на таблицу users (опционально) | +| ip_address | String | IP-адрес (опционально) | +| user_agent | String | User-Agent (опционально) | +| created_at | DateTime | Дата и время создания | + +## Связи между таблицами + +1. `users` 1:N `addresses` (один пользователь может иметь несколько адресов) +2. `users` 1:N `cart_items` (один пользователь может иметь несколько товаров в корзине) +3. `users` 1:N `orders` (один пользователь может иметь несколько заказов) +4. `users` 1:N `reviews` (один пользователь может оставить несколько отзывов) + +5. `categories` 1:N `categories` (категория может иметь подкатегории) +6. `categories` 1:N `products` (категория может содержать несколько продуктов) + +7. `collections` 1:N `products` (коллекция может содержать несколько продуктов) + +8. `sizes` 1:N `product_variants` (размер может быть использован в нескольких вариантах продуктов) + +9. `products` 1:N `product_variants` (продукт может иметь несколько вариантов) +10. `products` 1:N `product_images` (продукт может иметь несколько изображений) +11. `products` 1:N `reviews` (продукт может иметь несколько отзывов) + +12. `product_variants` 1:N `cart_items` (вариант продукта может быть в нескольких корзинах) +13. `product_variants` 1:N `order_items` (вариант продукта может быть в нескольких заказах) + +14. `orders` 1:N `order_items` (заказ может содержать несколько товаров) +15. `addresses` 1:N `orders` (адрес может быть использован в нескольких заказах) \ No newline at end of file diff --git a/backend/uploads/products/2/ea1e8526-b67b-40ba-b971-b87694223a4a.png b/backend/uploads/products/2/ea1e8526-b67b-40ba-b971-b87694223a4a.png new file mode 100644 index 0000000..e368ed9 Binary files /dev/null and b/backend/uploads/products/2/ea1e8526-b67b-40ba-b971-b87694223a4a.png differ diff --git a/backend/uploads/products/2/eb3edbd1-b0ec-4a3a-9458-93e867ac2bd4.png b/backend/uploads/products/2/eb3edbd1-b0ec-4a3a-9458-93e867ac2bd4.png new file mode 100644 index 0000000..e368ed9 Binary files /dev/null and b/backend/uploads/products/2/eb3edbd1-b0ec-4a3a-9458-93e867ac2bd4.png differ diff --git a/backend/uploads/products/9/035b2663-53a2-4581-9d7d-eb9282ba359b.png b/backend/uploads/products/9/035b2663-53a2-4581-9d7d-eb9282ba359b.png new file mode 100644 index 0000000..e368ed9 Binary files /dev/null and b/backend/uploads/products/9/035b2663-53a2-4581-9d7d-eb9282ba359b.png differ diff --git a/backend/uploads/products/9/4ac4d7ff-4d44-4ac8-950b-5d9b1802e88a.png b/backend/uploads/products/9/4ac4d7ff-4d44-4ac8-950b-5d9b1802e88a.png new file mode 100644 index 0000000..e368ed9 Binary files /dev/null and b/backend/uploads/products/9/4ac4d7ff-4d44-4ac8-950b-5d9b1802e88a.png differ diff --git a/backend/uploads/products/9/e15e0382-c28f-4858-a6e8-b0db39e8ea35.png b/backend/uploads/products/9/e15e0382-c28f-4858-a6e8-b0db39e8ea35.png new file mode 100644 index 0000000..e368ed9 Binary files /dev/null and b/backend/uploads/products/9/e15e0382-c28f-4858-a6e8-b0db39e8ea35.png differ diff --git a/frontend/.DS_Store b/frontend/.DS_Store index d9c61e9..86a179d 100644 Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ diff --git a/frontend/app/about/page.tsx b/frontend/app/(main)/about/page.tsx similarity index 100% rename from frontend/app/about/page.tsx rename to frontend/app/(main)/about/page.tsx diff --git a/frontend/app/blog/loading.tsx b/frontend/app/(main)/blog/loading.tsx similarity index 100% rename from frontend/app/blog/loading.tsx rename to frontend/app/(main)/blog/loading.tsx diff --git a/frontend/app/blog/page.tsx b/frontend/app/(main)/blog/page.tsx similarity index 100% rename from frontend/app/blog/page.tsx rename to frontend/app/(main)/blog/page.tsx diff --git a/frontend/app/cart/page.tsx b/frontend/app/(main)/cart/page.tsx similarity index 100% rename from frontend/app/cart/page.tsx rename to frontend/app/(main)/cart/page.tsx diff --git a/frontend/app/(main)/catalog/[slug]/page.tsx b/frontend/app/(main)/catalog/[slug]/page.tsx new file mode 100644 index 0000000..af05b4d --- /dev/null +++ b/frontend/app/(main)/catalog/[slug]/page.tsx @@ -0,0 +1,142 @@ +"use client" + +import { useEffect, useState } from "react" +import { notFound, useRouter } from "next/navigation" +import { Product } from "@/lib/catalog" +import { Skeleton } from "@/components/ui/skeleton" +import { fetchProduct } from "@/lib/api" +import { ImageSlider } from "@/components/product/ImageSlider" +import { ProductDetails } from "@/components/product/ProductDetails" + +export default function ProductPage({ params }: { params: { slug: string } }) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [product, setProduct] = useState(null) + const router = useRouter() + + useEffect(() => { + const loadProduct = async () => { + setLoading(true) + try { + // fetchProduct возвращает объект Product напрямую, а не обертку ApiResponse + const productData = await fetchProduct(params.slug) + + // Проверяем, что получены данные + if (!productData) { + setError("Продукт не найден") + return + } + + // Преобразуем тип API Product в тип из catalog.ts + setProduct(productData as unknown as Product) + } catch (err) { + console.error("Error fetching product:", err) + setError("Ошибка при загрузке продукта") + } finally { + setLoading(false) + } + } + + loadProduct() + }, [params.slug]) + + if (error && !loading) { + return ( +
+

Ошибка

+

{error}

+ +
+ ) + } + + if (loading) { + return + } + + if (!product) { + return notFound() + } + + return ( +
+ + +
+
+ +
+
+ +
+
+
+ ) +} + +function ProductSkeleton() { + return ( +
+
+ +
+ +
+
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ + + +
+ + +
+ +
+ + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/catalog/loading.tsx b/frontend/app/(main)/catalog/loading.tsx similarity index 100% rename from frontend/app/catalog/loading.tsx rename to frontend/app/(main)/catalog/loading.tsx diff --git a/frontend/app/(main)/catalog/page.tsx b/frontend/app/(main)/catalog/page.tsx new file mode 100644 index 0000000..1fc17df --- /dev/null +++ b/frontend/app/(main)/catalog/page.tsx @@ -0,0 +1,627 @@ +"use client" + +import { useState, useEffect } from "react" +import Image from "next/image" +import Link from "next/link" +import { ChevronDown, ChevronUp, X, Sliders, Search, ArrowUpRight } from "lucide-react" +import { ProductCard } from "@/components/ui/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 { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetClose, +} from "@/components/ui/sheet" +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 { Skeleton } from "@/components/ui/skeleton" + +export default function CatalogPage() { + const [isFilterOpen, setIsFilterOpen] = useState(false) + const [priceRange, setPriceRange] = useState([0, 15000]) + 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 [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [totalProducts, setTotalProducts] = useState(0) + const [selectedCategory, setSelectedCategory] = useState(null) + + // Цвета (оставляем как есть, так как они не меняются) + 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"] + + // Проверка размера экрана + useEffect(() => { + const checkIsMobile = () => { + setIsMobile(window.innerWidth < 768) + // Автоматически переключаем на сетку на мобильных устройствах + if (window.innerWidth < 640 && viewMode === 'list') { + setViewMode('grid') + } + } + + checkIsMobile() + window.addEventListener('resize', checkIsMobile) + + return () => { + window.removeEventListener('resize', checkIsMobile) + } + }, [viewMode]) + + // Загрузка категорий + useEffect(() => { + const loadCategories = async () => { + try { + const categoriesData = await categoryService.getCategories(); + setCategories(categoriesData); + } catch (err) { + console.error("Ошибка при загрузке категорий:", err); + setError("Не удалось загрузить категории"); + } + }; + + loadCategories(); + }, []); + + // Загрузка продуктов с учетом фильтров + useEffect(() => { + const loadProducts = async () => { + try { + setLoading(true); + + // Формируем параметры запроса + const params: any = { + limit: 12, + skip: (currentPage - 1) * 12, + include_variants: true + }; + + // Добавляем фильтр по категории + if (selectedCategory) { + params.category_id = selectedCategory; + } + + // Добавляем фильтр по цене + if (priceRange[0] > 0) { + params.min_price = priceRange[0]; + } + if (priceRange[1] < 15000) { + params.max_price = priceRange[1]; + } + + // Добавляем поисковый запрос + if (searchQuery) { + params.search = searchQuery; + } + + const productsData = await productService.getProducts(params); + + // Сортировка полученных продуктов + let sortedProducts = [...productsData]; + + switch (sortOption) { + case 'price_asc': + sortedProducts.sort((a, b) => { + const priceA = a.variants?.[0]?.price || 0; + const priceB = b.variants?.[0]?.price || 0; + return priceA - priceB; + }); + break; + case 'price_desc': + sortedProducts.sort((a, b) => { + const priceA = a.variants?.[0]?.price || 0; + const priceB = b.variants?.[0]?.price || 0; + return priceB - priceA; + }); + 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(); + return dateB - dateA; + }); + break; + default: // popular - оставляем как есть + break; + } + + setProducts(sortedProducts); + setTotalProducts(productsData.length); + setLoading(false); + } catch (err) { + console.error("Ошибка при загрузке продуктов:", err); + setError("Не удалось загрузить продукты"); + setLoading(false); + } + }; + + 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) + } + + // Обработчик выбора категории + const handleCategorySelect = (categoryId: number) => { + setSelectedCategory(categoryId === selectedCategory ? null : categoryId); + setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории + }; + + // Компонент боковой панели фильтров + const FilterSidebar = () => ( +
+ {/* Поиск */} +
+
+ setSearchQuery(e.target.value)} + className="pl-10 border-primary/20 focus:border-primary rounded-none" + /> + +
+
+ + {/* Категории */} +
+ + + Категории + +
+ {categories.map((category) => ( +
+ handleCategorySelect(category.id)} + className="mr-2" + /> + + + {category.products_count || 0} + +
+ ))} +
+
+
+
+
+ + {/* Цена */} +
+ + + Цена + +
+ setPriceRange(value as [number, number])} + className="mb-6" + /> +
+
+ от + {priceRange[0]} ₽ +
+
+ до + {priceRange[1]} ₽ +
+
+
+
+
+
+
+ + {/* Цвета */} +
+ + + Цвет + +
+ {colors.map((color) => ( +
+ + {color.name} +
+ ))} +
+
+
+
+
+ + {/* Размеры */} +
+ + + Размер + +
+ {sizes.map((size) => ( + + ))} +
+
+
+
+
+ + {/* Кнопка сброса фильтров */} + +
+ ) + + // Отображаем загрузку + if (loading && currentPage === 1) { + return ( +
+
+

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

+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); + } + + // Отображаем ошибку, если она есть + if (error) { + return ( +
+
+

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

+

{error}

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

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

+

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

+
+ +
+ {/* Сортировка */} + + + {/* Переключатель вид сетки/списка */} +
+ + +
+ + {/* Кнопка фильтров для мобильных */} + + + + + + + Фильтры + + Выберите параметры для фильтрации товаров + + +
+ +
+
+
+
+
+ + {/* Активные фильтры */} + {activeFilters.length > 0 && ( +
+
+ {activeFilters.map((filter) => ( + + ))} + +
+
+ )} + + {/* Основное содержимое */} +
+ {/* Боковая панель с фильтрами - десктоп */} +
+ +
+ + {/* Список товаров */} +
+ {/* Сетка товаров */} + {products.length > 0 ? ( +
+ {products.map((product) => ( + + + {viewMode === "grid" ? ( + // Карточка товара в режиме сетки + 0 ? + product.images[0].url : "/placeholder.svg?height=600&width=400"} + slug={product.slug} + isNew={false} // Нужно добавить поле в API + isOnSale={product.variants?.[0]?.discount_price ? true : false} + category={product.category?.name || ""} + /> + ) : ( + // Карточка товара в режиме списка +
+
+ +
+ 0 ? + (product.images[0].url.startsWith('http') ? + product.images[0].url : + `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:8000'}${product.images[0].url}`) : + "/placeholder.svg?height=600&width=400"} + alt={product.name} + className="object-cover w-full h-full" + width={400} + height={600} + /> +
+ + {product.variants?.[0]?.discount_price && ( +
+ Скидка +
+ )} +
+
+
+ +

{product.name}

+ +

+ {product.category?.name || ''} +

+

{product.description.length > 150 + ? `${product.description.substring(0, 150)}...` + : product.description}

+
+
+
+ {product.variants?.[0]?.discount_price ? ( +
+ {product.variants[0]?.discount_price} ₽ + {product.variants[0]?.price} ₽ +
+ ) : ( + {product.variants?.[0]?.price || 0} ₽ + )} +
+ + + +
+
+
+ )} +
+
+ ))} +
+ ) : ( + // Если товары не найдены +
+

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

+

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

+ +
+ )} + + {/* Пагинация */} + {totalProducts > 0 && ( +
+
+ + {/* Номера страниц */} + {Array.from({ length: Math.ceil(totalProducts / 12) }).map((_, i) => ( + + ))} + +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/app/checkout/page.tsx b/frontend/app/(main)/checkout/page.tsx similarity index 100% rename from frontend/app/checkout/page.tsx rename to frontend/app/(main)/checkout/page.tsx diff --git a/frontend/app/collections/page.tsx b/frontend/app/(main)/collections/page.tsx similarity index 100% rename from frontend/app/collections/page.tsx rename to frontend/app/(main)/collections/page.tsx diff --git a/frontend/app/contact/page.tsx b/frontend/app/(main)/contact/page.tsx similarity index 100% rename from frontend/app/contact/page.tsx rename to frontend/app/(main)/contact/page.tsx diff --git a/frontend/app/faq/loading.tsx b/frontend/app/(main)/faq/loading.tsx similarity index 100% rename from frontend/app/faq/loading.tsx rename to frontend/app/(main)/faq/loading.tsx diff --git a/frontend/app/faq/page.tsx b/frontend/app/(main)/faq/page.tsx similarity index 100% rename from frontend/app/faq/page.tsx rename to frontend/app/(main)/faq/page.tsx diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx new file mode 100644 index 0000000..d148113 --- /dev/null +++ b/frontend/app/(main)/layout.tsx @@ -0,0 +1,16 @@ +import { SiteHeader } from "@/components/layout/site-header" +import { SiteFooter } from "@/components/layout/site-footer" + +interface MainLayoutProps { + children: React.ReactNode +} + +export default function MainLayout({ children }: MainLayoutProps) { + return ( +
+ +
{children}
+ +
+ ) +} \ No newline at end of file diff --git a/frontend/app/order-tracking/loading.tsx b/frontend/app/(main)/order-tracking/loading.tsx similarity index 100% rename from frontend/app/order-tracking/loading.tsx rename to frontend/app/(main)/order-tracking/loading.tsx diff --git a/frontend/app/order-tracking/page.tsx b/frontend/app/(main)/order-tracking/page.tsx similarity index 100% rename from frontend/app/order-tracking/page.tsx rename to frontend/app/(main)/order-tracking/page.tsx diff --git a/frontend/app/page.tsx b/frontend/app/(main)/page.tsx similarity index 100% rename from frontend/app/page.tsx rename to frontend/app/(main)/page.tsx diff --git a/frontend/app/product/[id]/page.tsx b/frontend/app/(main)/product/[id]/page.tsx similarity index 86% rename from frontend/app/product/[id]/page.tsx rename to frontend/app/(main)/product/[id]/page.tsx index d410b25..91dae0f 100644 --- a/frontend/app/product/[id]/page.tsx +++ b/frontend/app/(main)/product/[id]/page.tsx @@ -30,87 +30,6 @@ export default function ProductPage({ params }: { params: { id: string } }) { const [isZoomed, setIsZoomed] = useState(false) const [zoomPosition, setZoomPosition] = useState({ x: 0, y: 0 }) - // Мок-данные для продукта - const product = { - id: params.id, - name: "ШЕЛКОВАЯ БЛУЗА С ДЛИННЫМ РУКАВОМ", - price: 4990, - oldPrice: 6990, - discount: 29, - rating: 4.8, - reviewCount: 24, - description: - "Элегантная шелковая блуза с длинным рукавом и свободным силуэтом. Идеально подходит для создания как повседневных, так и деловых образов. Изготовлена из 100% натурального шелка высшего качества.", - details: [ - "100% натуральный шелк", - "Свободный силуэт", - "Длинный рукав с манжетами", - "Застежка на пуговицы", - "Машинная стирка при 30°C", - "Сделано в Италии", - ], - sizes: ["XS", "S", "M", "L", "XL"], - colors: [ - { name: "Белый", code: "#FFFFFF", border: true }, - { name: "Черный", code: "#000000" }, - { name: "Бежевый", code: "#F5F5DC", border: true }, - { name: "Голубой", code: "#ADD8E6" }, - ], - images: [ - "/placeholder.svg?height=800&width=600", - "/placeholder.svg?height=800&width=600&text=Image+2", - "/placeholder.svg?height=800&width=600&text=Image+3", - "/placeholder.svg?height=800&width=600&text=Image+4", - ], - sku: "BL-12345", - inStock: true, - category: "Блузы", - composition: "100% шелк", - care: "Машинная стирка при 30°C, не отбеливать, гладить при низкой температуре, не подвергать химической чистке", - delivery: "Доставка по России от 3 до 7 рабочих дней", - returns: "Бесплатный возврат в течение 14 дней", - } - - // Рекомендуемые товары - const recommendedProducts = [ - { - id: 1, - name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ", - price: 3990, - image: "/placeholder.svg?height=600&width=400", - isNew: false, - isOnSale: false, - category: "Брюки", - }, - { - id: 2, - name: "ЖАКЕТ ИЗ ИТАЛЬЯНСКОЙ ШЕРСТИ", - price: 12900, - salePrice: 9900, - image: "/placeholder.svg?height=600&width=400", - isNew: false, - isOnSale: true, - category: "Жакеты", - }, - { - id: 3, - name: "ЮБКА МИДИ ПЛИССЕ", - price: 5290, - image: "/placeholder.svg?height=600&width=400", - isNew: true, - isOnSale: false, - category: "Юбки", - }, - { - id: 4, - name: "ТОПП С ДРАПИРОВКОЙ", - price: 3990, - image: "/placeholder.svg?height=600&width=400", - isNew: false, - isOnSale: false, - category: "Топы", - }, - ] const incrementQuantity = () => { setQuantity(quantity + 1) diff --git a/frontend/app/(main)/product/layout.tsx b/frontend/app/(main)/product/layout.tsx new file mode 100644 index 0000000..a14e64f --- /dev/null +++ b/frontend/app/(main)/product/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/frontend/app/size-guide/page.tsx b/frontend/app/(main)/size-guide/page.tsx similarity index 100% rename from frontend/app/size-guide/page.tsx rename to frontend/app/(main)/size-guide/page.tsx diff --git a/frontend/app/wishlist/page.tsx b/frontend/app/(main)/wishlist/page.tsx similarity index 100% rename from frontend/app/wishlist/page.tsx rename to frontend/app/(main)/wishlist/page.tsx diff --git a/frontend/app/admin/categories/page.tsx b/frontend/app/admin/categories/page.tsx new file mode 100644 index 0000000..4bb3f90 --- /dev/null +++ b/frontend/app/admin/categories/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Plus, Edit, Trash, ChevronRight, ChevronDown } from 'lucide-react'; + +// Типы для категорий +interface Category { + id: number; + name: string; + slug: string; + parent_id: number | null; + is_active: boolean; + subcategories?: Category[]; +} + +// Временные данные до реализации API +const mockCategories: Category[] = [ + { + id: 1, + name: 'Женская одежда', + slug: 'womens-clothing', + parent_id: null, + is_active: true, + subcategories: [ + { id: 2, name: 'Платья', slug: 'dresses', parent_id: 1, is_active: true }, + { id: 3, name: 'Блузки', slug: 'blouses', parent_id: 1, is_active: true }, + { id: 4, name: 'Юбки', slug: 'skirts', parent_id: 1, is_active: false } + ] + }, + { + id: 5, + name: 'Мужская одежда', + slug: 'mens-clothing', + parent_id: null, + is_active: true, + subcategories: [ + { id: 6, name: 'Рубашки', slug: 'shirts', parent_id: 5, is_active: true }, + { id: 7, name: 'Брюки', slug: 'pants', parent_id: 5, is_active: true } + ] + }, + { + id: 8, + name: 'Аксессуары', + slug: 'accessories', + parent_id: null, + is_active: true, + subcategories: [] + } +]; + +// Компонент для отображения категории в дереве +const CategoryTreeItem = ({ + category, + level = 0, + onEdit, + onDelete, + expandedCategories, + toggleCategory +}: { + category: Category; + level?: number; + onEdit: (id: number) => void; + onDelete: (id: number) => void; + expandedCategories: number[]; + toggleCategory: (id: number) => void; +}) => { + const isExpanded = expandedCategories.includes(category.id); + const hasSubcategories = category.subcategories && category.subcategories.length > 0; + + return ( +
+
+
+ {hasSubcategories && ( + + )} + {!hasSubcategories &&
} + {category.name} +
+
+ {category.slug} + + +
+
+ {isExpanded && hasSubcategories && ( +
+ {category.subcategories!.map(subcategory => ( + + ))} +
+ )} +
+ ); +}; + +export default function CategoriesPage() { + const [categories, setCategories] = useState([]); + const [expandedCategories, setExpandedCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Загрузка категорий при монтировании + useEffect(() => { + // Здесь должен быть запрос к API + // В будущем заменить на реальный запрос + setLoading(true); + + setTimeout(() => { + setCategories(mockCategories); + setExpandedCategories([1, 5]); // По умолчанию раскрываем первые категории + setLoading(false); + }, 500); + }, []); + + // Обработчик редактирования категории + const handleEdit = (id: number) => { + // В будущем реализовать с переходом на страницу редактирования + console.log('Редактирование категории:', id); + alert(`Редактирование категории с ID: ${id}`); + }; + + // Обработчик удаления категории + const handleDelete = (id: number) => { + if (window.confirm('Вы уверены, что хотите удалить эту категорию?')) { + console.log('Удаление категории:', id); + // В будущем реализовать запрос к API + // Временная реализация для демонстрации + const removeCategory = (cats: Category[]): Category[] => { + return cats.filter(cat => { + if (cat.id === id) return false; + if (cat.subcategories) { + cat.subcategories = removeCategory(cat.subcategories); + } + return true; + }); + }; + + setCategories(removeCategory([...categories])); + } + }; + + // Обработчик раскрытия/скрытия категории + const toggleCategory = (id: number) => { + setExpandedCategories(prev => + prev.includes(id) + ? prev.filter(catId => catId !== id) + : [...prev, id] + ); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Ошибка! + {error} +
+ ); + } + + return ( +
+
+

Управление категориями

+ + + Добавить категорию + +
+ +
+
+

Дерево категорий

+
+ +
+ {categories.length === 0 ? ( +
+ Категории не найдены +
+ ) : ( + categories.map(category => ( + + )) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/collections/page.tsx b/frontend/app/admin/collections/page.tsx new file mode 100644 index 0000000..a3e81d4 --- /dev/null +++ b/frontend/app/admin/collections/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Plus, Edit, Trash, Search } from 'lucide-react'; + +// Типы для коллекций +interface Collection { + id: number; + name: string; + slug: string; + description: string | null; + is_active: boolean; +} + +// Временные данные до реализации API +const mockCollections: Collection[] = [ + { + id: 1, + name: 'Весна-Лето 2023', + slug: 'spring-summer-2023', + description: 'Коллекция весна-лето 2023 года. Яркие цвета и легкие ткани.', + is_active: true + }, + { + id: 2, + name: 'Осень-Зима 2022/23', + slug: 'autumn-winter-2022-23', + description: 'Теплые и стильные вещи для холодного сезона.', + is_active: true + }, + { + id: 3, + name: 'Базовая коллекция', + slug: 'basic-collection', + description: 'Базовые модели, которые всегда в тренде.', + is_active: true + }, + { + id: 4, + name: 'Спортивная линия', + slug: 'sport-line', + description: 'Спортивная одежда для активного образа жизни.', + is_active: false + } +]; + +export default function CollectionsPage() { + const [collections, setCollections] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredCollections, setFilteredCollections] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Загрузка коллекций при монтировании + useEffect(() => { + // Здесь должен быть запрос к API + // В будущем заменить на реальный запрос + setLoading(true); + + setTimeout(() => { + setCollections(mockCollections); + setFilteredCollections(mockCollections); + setLoading(false); + }, 500); + }, []); + + // Обработчик поиска + useEffect(() => { + if (searchTerm.trim() === '') { + setFilteredCollections(collections); + } else { + const filtered = collections.filter(collection => + collection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) || + (collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); + setFilteredCollections(filtered); + } + }, [searchTerm, collections]); + + // Обработчик редактирования коллекции + const handleEdit = (id: number) => { + // В будущем реализовать с переходом на страницу редактирования + console.log('Редактирование коллекции:', id); + alert(`Редактирование коллекции с ID: ${id}`); + }; + + // Обработчик удаления коллекции + const handleDelete = (id: number) => { + if (window.confirm('Вы уверены, что хотите удалить эту коллекцию?')) { + console.log('Удаление коллекции:', id); + // В будущем реализовать запрос к API + // Временная реализация для демонстрации + const updatedCollections = collections.filter(collection => collection.id !== id); + setCollections(updatedCollections); + setFilteredCollections(updatedCollections.filter(collection => + collection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) || + (collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase())) + )); + } + }; + + // Обработчик изменения статуса коллекции + const handleToggleStatus = (id: number) => { + const updatedCollections = collections.map(collection => + collection.id === id + ? { ...collection, is_active: !collection.is_active } + : collection + ); + setCollections(updatedCollections); + setFilteredCollections(updatedCollections.filter(collection => + collection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) || + (collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase())) + )); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Ошибка! + {error} +
+ ); + } + + return ( +
+
+

Управление коллекциями

+ + + Добавить коллекцию + +
+ +
+
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+
+ +
+ + + + + + + + + + + + {filteredCollections.map((collection) => ( + + + + + + + + ))} + {filteredCollections.length === 0 && ( + + + + )} + +
НазваниеSlugОписаниеСтатусДействия
{collection.name}{collection.slug} + {collection.description ? + (collection.description.length > 60 ? + `${collection.description.substring(0, 60)}...` : + collection.description) : + '—'} + + + {collection.is_active ? 'Активна' : 'Неактивна'} + + +
+ + + +
+
+ Коллекции не найдены +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/customers/page.tsx b/frontend/app/admin/customers/page.tsx new file mode 100644 index 0000000..d91ee22 --- /dev/null +++ b/frontend/app/admin/customers/page.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Search, Mail, Phone, Calendar, User } from 'lucide-react'; + +// Типы для клиентов +interface Customer { + id: number; + email: string; + first_name: string; + last_name: string; + phone?: string; + created_at: string; + orders_count: number; + total_spent: number; + last_order_date?: string; + is_active: boolean; +} + +// Временные данные до реализации API +const mockCustomers: Customer[] = [ + { + id: 101, + email: 'ivan@example.com', + first_name: 'Иван', + last_name: 'Иванов', + phone: '+7 (999) 123-45-67', + created_at: '2023-01-10T14:30:00Z', + orders_count: 8, + total_spent: 45700, + last_order_date: '2023-03-15T14:30:00Z', + is_active: true + }, + { + id: 102, + email: 'anna@example.com', + first_name: 'Анна', + last_name: 'Петрова', + phone: '+7 (999) 765-43-21', + created_at: '2023-02-05T10:15:00Z', + orders_count: 3, + total_spent: 12500, + last_order_date: '2023-03-14T10:15:00Z', + is_active: true + }, + { + id: 103, + email: 'sergey@example.com', + first_name: 'Сергей', + last_name: 'Сидоров', + phone: '+7 (999) 555-55-55', + created_at: '2023-01-20T18:45:00Z', + orders_count: 5, + total_spent: 28900, + last_order_date: '2023-03-13T18:45:00Z', + is_active: true + }, + { + id: 104, + email: 'elena@example.com', + first_name: 'Елена', + last_name: 'Смирнова', + created_at: '2023-02-15T09:20:00Z', + orders_count: 1, + total_spent: 6300, + last_order_date: '2023-03-12T09:20:00Z', + is_active: true + }, + { + id: 105, + email: 'dmitry@example.com', + first_name: 'Дмитрий', + last_name: 'Кузнецов', + phone: '+7 (999) 888-77-66', + created_at: '2023-01-05T12:10:00Z', + orders_count: 4, + total_spent: 19800, + last_order_date: '2023-03-11T12:10:00Z', + is_active: false + } +]; + +export default function CustomersPage() { + const [customers, setCustomers] = useState([]); + const [filteredCustomers, setFilteredCustomers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Загрузка клиентов при монтировании + useEffect(() => { + // Здесь должен быть запрос к API + // В будущем заменить на реальный запрос + setLoading(true); + + setTimeout(() => { + setCustomers(mockCustomers); + setFilteredCustomers(mockCustomers); + setLoading(false); + }, 500); + }, []); + + // Обработчик поиска + useEffect(() => { + if (searchTerm.trim() === '') { + setFilteredCustomers(customers); + } else { + const search = searchTerm.toLowerCase(); + const filtered = customers.filter(customer => + customer.email.toLowerCase().includes(search) || + customer.first_name.toLowerCase().includes(search) || + customer.last_name.toLowerCase().includes(search) || + (customer.phone && customer.phone.toLowerCase().includes(search)) + ); + setFilteredCustomers(filtered); + } + }, [searchTerm, customers]); + + // Форматирование даты + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('ru-RU'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Ошибка! + {error} +
+ ); + } + + return ( +
+
+

Управление клиентами

+
+ +
+
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+
+ +
+ + + + + + + + + + + + + + {filteredCustomers.map((customer) => ( + + + + + + + + + + ))} + {filteredCustomers.length === 0 && ( + + + + )} + +
КлиентКонтактыРегистрацияЗаказыСуммаПоследний заказДействия
+
+
+ +
+
+
+ {customer.first_name} {customer.last_name} +
+
+ ID: {customer.id} +
+
+
+
+
+ + {customer.email} +
+ {customer.phone && ( +
+ + {customer.phone} +
+ )} +
+
+ + {formatDate(customer.created_at)} +
+
+ {customer.orders_count} + + {customer.total_spent.toLocaleString('ru-RU')} ₽ + + {customer.last_order_date ? formatDate(customer.last_order_date) : '—'} + + + Профиль + + + Заказы + +
+ Клиенты не найдены +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/dashboard/page.tsx b/frontend/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..bd46b43 --- /dev/null +++ b/frontend/app/admin/dashboard/page.tsx @@ -0,0 +1,522 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { BarChart3, Package, Tag, Users, ShoppingBag } from 'lucide-react'; +import { fetchDashboardStats, fetchRecentOrders, fetchPopularProducts, Order, Product } from '@/lib/api'; + +// Компонент статистической карточки +interface StatCardProps { + title: string; + value: string | number; + icon: React.ReactNode; + color: string; +} + +const StatCard = ({ title, value, icon, color }: StatCardProps) => { + return ( +
+
+ {icon} +
+
+

{title}

+

{value}

+
+
+ ); +}; + +// Компонент последних заказов +interface RecentOrdersProps { + orders: Order[]; + loading: boolean; + error: string | null; +} + +const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => { + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
Ошибка при загрузке заказов: {error}
+
+ ); + } + + return ( +
+
+

Последние заказы

+
+
+ + + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + ))} + {orders.length === 0 && ( + + + + )} + +
IDКлиентДатаСтатусСуммаДействия
#{order.id}{order.user_name || `Пользователь #${order.user_id}`} + {new Date(order.created_at).toLocaleDateString('ru-RU')} + + + {order.status === 'delivered' ? 'Доставлен' : + order.status === 'processing' ? 'В обработке' : + order.status === 'shipped' ? 'Отправлен' : + order.status === 'paid' ? 'Оплачен' : + order.status} + + + {order.total !== undefined && order.total !== null + ? `${order.total.toLocaleString('ru-RU')} ₽` + : 'Н/Д'} + + + Подробнее + +
+ Заказы не найдены +
+
+
+ + Посмотреть все заказы → + +
+
+ ); +}; + +// Компонент популярных товаров +interface PopularProductsProps { + products: Product[]; + loading: boolean; + error: string | null; +} + +const PopularProducts = ({ products, loading, error }: PopularProductsProps) => { + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
Ошибка при загрузке товаров: {error}
+
+ ); + } + + return ( +
+
+

Популярные товары

+
+
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + {products.length === 0 && ( + + + + )} + +
ТоварКатегорияПродажиОстатокДействия
{product.name || 'Без названия'}{product.category?.name || 'Без категории'}{typeof product.sales === 'number' ? product.sales : 0} + 20 ? 'bg-green-100 text-green-800' : + typeof product.stock === 'number' && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' : + 'bg-red-100 text-red-800'}`}> + {typeof product.stock === 'number' ? product.stock : 'Н/Д'} + + + + Редактировать + +
+ Товары не найдены +
+
+
+ + Посмотреть все товары → + +
+
+ ); +}; + +export default function AdminDashboard() { + const [stats, setStats] = useState({ + ordersCount: 0, + totalSales: 0, + customersCount: 0, + productsCount: 0 + }); + const [recentOrders, setRecentOrders] = useState([]); + const [popularProducts, setPopularProducts] = useState([]); + const [loading, setLoading] = useState({ + stats: true, + orders: true, + products: true + }); + const [error, setError] = useState<{ + stats: string | null; + orders: string | null; + products: string | null; + }>({ + stats: null, + orders: null, + products: null + }); + + // Загрузка данных при монтировании компонента + useEffect(() => { + const fetchDashboardData = async () => { + try { + setLoading(prev => ({ ...prev, stats: true })); + let statsData; + + try { + // Пытаемся получить данные от API + statsData = await fetchDashboardStats(); + console.log('Полученные данные статистики от API:', JSON.stringify(statsData, null, 2)); + + // Проверяем наличие данных + if (!statsData.data) { + throw new Error('Данные статистики отсутствуют в ответе API'); + } + + // Проверяем структуру данных + let statsObject = statsData.data; + if (statsData.data && 'data' in statsData.data) { + statsObject = (statsData.data as any).data; + } + + // Устанавливаем объект статистики + statsData = { + data: statsObject, + status: statsData.status + }; + } catch (apiError) { + console.warn('Ошибка при получении данных от API, используем моковые данные:', apiError); + + // Если API недоступен, используем моковые данные + await new Promise(resolve => setTimeout(resolve, 500)); + statsData = { + data: { + ordersCount: 1248, + totalSales: 2456789, + customersCount: 3456, + productsCount: 867 + }, + status: 200 + }; + } + + if (statsData.data) { + // Проверяем, что все нужные поля существуют в данных + const safeStats = { + ordersCount: typeof statsData.data.ordersCount === 'number' ? statsData.data.ordersCount : 0, + totalSales: typeof statsData.data.totalSales === 'number' ? statsData.data.totalSales : 0, + customersCount: typeof statsData.data.customersCount === 'number' ? statsData.data.customersCount : 0, + productsCount: typeof statsData.data.productsCount === 'number' ? statsData.data.productsCount : 0 + }; + setStats(safeStats); + console.log('Установленные данные статистики:', safeStats); + } else { + console.warn('Данные статистики отсутствуют или имеют неверный формат:', statsData); + } + setLoading(prev => ({ ...prev, stats: false })); + } catch (err) { + console.error('Ошибка при загрузке статистики:', err); + setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' })); + setLoading(prev => ({ ...prev, stats: false })); + } + + try { + setLoading(prev => ({ ...prev, orders: true })); + let ordersData; + + try { + ordersData = await fetchRecentOrders({ limit: 4 }); + console.log('Получены данные заказов:', JSON.stringify(ordersData, null, 2)); + + // Проверяем структуру данных + if (ordersData.data === null || ordersData.data === undefined) { + throw new Error('Данные заказов отсутствуют в ответе API'); + } + + // Проверяем, если данные приходят в другой структуре + let ordersArray: Order[] = []; + if (Array.isArray(ordersData.data)) { + ordersArray = ordersData.data; + } else if (ordersData.data && typeof ordersData.data === 'object' && 'items' in ordersData.data) { + ordersArray = (ordersData.data as any).items; + } + + console.log('Массив заказов:', ordersArray); + + // Если есть данные, но пустой массив - используем моковые данные + if (ordersArray.length === 0) { + throw new Error('Массив заказов пуст'); + } + + // Устанавливаем массив заказов + ordersData = { + data: ordersArray, + status: ordersData.status + }; + } catch (apiError) { + console.warn('Ошибка API заказов, используем моковые данные:', apiError); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Моковые данные заказов + ordersData = { + data: [ + { id: 1, user_id: 101, user_name: 'Иван Иванов', created_at: '2023-03-15T14:30:00Z', status: 'delivered', total: 12500 }, + { id: 2, user_id: 102, user_name: 'Анна Петрова', created_at: '2023-03-14T10:15:00Z', status: 'shipped', total: 8750 }, + { id: 3, user_id: 103, user_name: 'Сергей Сидоров', created_at: '2023-03-13T18:45:00Z', status: 'processing', total: 15200 }, + { id: 4, user_id: 104, user_name: 'Елена Смирнова', created_at: '2023-03-12T09:20:00Z', status: 'paid', total: 6300 } + ], + status: 200 + }; + } + + if (ordersData && ordersData.data && Array.isArray(ordersData.data)) { + // Нормализуем данные заказов, убедившись, что все необходимые поля существуют + const normalizedOrders = ordersData.data.map(order => ({ + id: order.id || 0, + user_id: order.user_id || 0, + user_name: order.user_name || '', + created_at: order.created_at || new Date().toISOString(), + status: order.status || 'processing', + total: typeof order.total === 'number' ? order.total : 0 + })); + + setRecentOrders(normalizedOrders); + } else { + console.warn('Некорректные данные заказов:', ordersData); + setRecentOrders([]); + } + + setLoading(prev => ({ ...prev, orders: false })); + } catch (err) { + console.error('Ошибка при загрузке заказов:', err); + setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' })); + setLoading(prev => ({ ...prev, orders: false })); + } + + try { + setLoading(prev => ({ ...prev, products: true })); + let productsData; + + try { + productsData = await fetchPopularProducts({ limit: 4 }); + console.log('Получены данные товаров:', JSON.stringify(productsData, null, 2)); + + // Проверяем структуру данных + if (!productsData.data && !Array.isArray(productsData)) { + throw new Error('Данные товаров отсутствуют в ответе API'); + } + + // Проверяем формат ответа - может быть как data.items, data[] или просто [] + let productsArray: any[] = []; + + if (Array.isArray(productsData)) { + // Ответ сразу в виде массива товаров без обертки + productsArray = productsData; + } else if (productsData.data) { + if (Array.isArray(productsData.data)) { + // Ответ в виде { data: [...] } + productsArray = productsData.data; + } else if (typeof productsData.data === 'object' && productsData.data !== null && + 'items' in (productsData.data as Record) && + Array.isArray((productsData.data as Record).items)) { + // Ответ в виде { data: { items: [...] } } + productsArray = (productsData.data as Record).items; + } + } + + console.log('Обработанный массив товаров:', productsArray); + + // Если массив пустой, используем моковые данные + if (!productsArray || productsArray.length === 0) { + throw new Error('Массив товаров пуст'); + } + + // Преобразуем данные в нужный формат для отображения + const formattedProducts = productsArray.map(product => { + // Определяем stock как сумму остатков всех вариантов, если они есть + let totalStock = 0; + if (product.variants && Array.isArray(product.variants)) { + totalStock = product.variants.reduce((sum: number, variant: any) => + sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0); + } else if (typeof product.stock === 'number') { + totalStock = product.stock; + } + + // Создаем корректный объект Product + return { + id: product.id || 0, + name: product.name || 'Без названия', + category: product.category || + (product.category_id ? { id: product.category_id, name: 'Категория ' + product.category_id } : + { id: 0, name: 'Без категории' }), + category_id: product.category_id, + sales: typeof product.sales === 'number' ? product.sales : 0, + stock: totalStock, + price: typeof product.price === 'number' ? product.price : 0, + images: product.images && Array.isArray(product.images) ? + product.images.map((img: any) => img.image_url) : [], + description: product.description || '' + }; + }); + + setPopularProducts(formattedProducts); + } catch (apiError) { + console.warn('Ошибка API товаров, используем моковые данные:', apiError); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Моковые данные товаров + const mockProducts = [ + { id: 1, name: 'Платье летнее', category: { id: 2, name: 'Платья' }, sales: 42, stock: 28, price: 3500 }, + { id: 2, name: 'Брюки классические', category: { id: 7, name: 'Брюки' }, sales: 38, stock: 15, price: 4200 }, + { id: 3, name: 'Блузка шелковая', category: { id: 3, name: 'Блузки' }, sales: 35, stock: 10, price: 2800 }, + { id: 4, name: 'Рубашка льняная', category: { id: 6, name: 'Рубашки' }, sales: 30, stock: 22, price: 3100 } + ]; + + setPopularProducts(mockProducts); + } + + setLoading(prev => ({ ...prev, products: false })); + } catch (err) { + console.error('Ошибка при загрузке товаров:', err); + setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' })); + setLoading(prev => ({ ...prev, products: false })); + } + }; + + fetchDashboardData(); + }, []); + + return ( +
+

Дашборд

+ + {/* Статистические карточки */} +
+ } + color="bg-blue-500" + /> + } + color="bg-green-500" + /> + } + color="bg-purple-500" + /> + } + color="bg-orange-500" + /> +
+ + {/* Последние заказы и популярные товары */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..94c5c72 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,99 @@ +import { ReactNode } from 'react'; +import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings } from 'lucide-react'; +import Link from 'next/link'; + +// Интерфейс для компонента MenuItem +interface MenuItemProps { + href: string; + icon: React.ReactNode; + label: string; + active?: boolean; +} + +// Компонент элемента меню +const MenuItem = ({ href, icon, label, active = false }: MenuItemProps) => { + return ( + + {icon} + {label} + + ); +}; + +export default function AdminLayout({ children }: { children: ReactNode }) { + // В реальном приложении здесь будет проверка авторизации + + return ( +
+ {/* Боковое меню */} +
+
+

Админ-панель

+
+
+ +
+
+ + {/* Основной контент */} +
+
+
+

Админ-панель

+
+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/login/page.tsx b/frontend/app/admin/login/page.tsx new file mode 100644 index 0000000..204e03e --- /dev/null +++ b/frontend/app/admin/login/page.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Eye, EyeOff } from 'lucide-react'; + +export default function AdminLoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email || !password) { + setError('Пожалуйста, заполните все поля'); + return; + } + + setLoading(true); + setError(null); + + try { + // Здесь должен быть запрос к API для аутентификации + // В будущем заменить на реальный запрос + + // Имитация запроса + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Для демо: проверяем email и пароль + if (email === 'admin@example.com' && password === 'password') { + // Сохраняем токен (в реальном приложении будет получен от сервера) + localStorage.setItem('token', 'demo-token'); + + // Перенаправляем на дашборд + router.push('/admin/dashboard'); + } else { + setError('Неверные учетные данные'); + } + } catch (err) { + console.error('Ошибка входа:', err); + setError('Ошибка аутентификации. Пожалуйста, попробуйте позже.'); + } finally { + setLoading(false); + } + }; + + const toggleShowPassword = () => { + setShowPassword(!showPassword); + }; + + return ( +
+
+
+

+ Вход в админ-панель +

+

+ Введите свои учетные данные для входа +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> + +
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+ +
+

Демо-доступ: admin@example.com / password

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/orders/[id]/page.tsx b/frontend/app/admin/orders/[id]/page.tsx new file mode 100644 index 0000000..db116a3 --- /dev/null +++ b/frontend/app/admin/orders/[id]/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState, useEffect, use } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; +import { fetchOrder, Order } from '@/lib/api'; + +// Импортируем компоненты +import { OrderHeader } from '@/components/admin/orders/OrderHeader'; +import { OrderStatus } from '@/components/admin/orders/OrderStatus'; +import { CustomerInfo } from '@/components/admin/orders/CustomerInfo'; +import { ShippingAddress } from '@/components/admin/orders/ShippingAddress'; +import { PaymentShipping } from '@/components/admin/orders/PaymentShipping'; +import { OrderItems } from '@/components/admin/orders/OrderItems'; +import { OrderNotes } from '@/components/admin/orders/OrderNotes'; +import { OrderActions } from '@/components/admin/orders/OrderActions'; + +interface OrderDetailsPageProps { + params: Promise<{ + id: string; + }>; +} + +export default function OrderDetailsPage({ params }: OrderDetailsPageProps) { + const router = useRouter(); + const resolvedParams = use(params); + const orderId = parseInt(resolvedParams.id); + + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Проверка токена при монтировании компонента + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/admin/login'); + return; + } + }, [router]); + + // Загрузка данных заказа + useEffect(() => { + const loadOrder = async () => { + try { + setLoading(true); + setError(null); + const token = localStorage.getItem('token'); + + if (!token) { + router.push('/admin/login'); + return; + } + + const response = await fetchOrder(orderId); + + if (response.data) { + // Проверяем, содержит ли ответ вложенное свойство order + const orderData = response.data as any; + if (orderData.order) { + setOrder(orderData.order); + } else { + setOrder(response.data); + } + } else { + throw new Error('Не удалось получить данные заказа'); + } + } catch (err) { + console.error('Ошибка при загрузке заказа:', err); + if (err instanceof Error && err.message.includes('Not authenticated')) { + router.push('/admin/login'); + } else { + setError('Не удалось загрузить заказ'); + } + } finally { + setLoading(false); + } + }; + + loadOrder(); + }, [orderId, router]); + + // Обработчик изменения статуса заказа + const handleStatusChange = (newStatus: string) => { + if (order) { + setOrder({ ...order, status: newStatus }); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Ошибка! + {error} +
+ ); + } + + if (!order) { + return ( +
+

Заказ не найден

+

Заказ с указанным ID не существует

+
+ ); + } + + return ( +
+
+ + + Назад к списку заказов + + + +
+ +
+ {/* Заголовок заказа */} + + + {/* Информация о заказе */} +
+
+ {/* Информация о клиенте */} + + + {/* Адрес доставки */} + + + {/* Информация об оплате и доставке */} + +
+
+ + {/* Товары в заказе */} + {order.items && order.items.length > 0 && ( + + )} + + {/* Примечания к заказу */} + {order.notes && } + + {/* Кнопки действий */} + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/orders/page.tsx b/frontend/app/admin/orders/page.tsx new file mode 100644 index 0000000..07f2f35 --- /dev/null +++ b/frontend/app/admin/orders/page.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Search, Filter, ChevronDown } from 'lucide-react'; +import { fetchOrders, Order } from '@/lib/api'; +import { formatDate, formatPrice } from '@/lib/utils'; + +// Компонент для фильтрации заказов +interface FilterDropdownProps { + options: { value: string; label: string }[]; + value: string; + onChange: (value: string) => void; + label: string; +} + +const FilterDropdown = ({ options, value, onChange, label }: FilterDropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( +
+
+ {options.map(option => ( + + ))} +
+
+ )} +
+ ); +}; + +export default function OrdersPage() { + const [orders, setOrders] = useState([]); + const [filteredOrders, setFilteredOrders] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFilter, setDateFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortField, setSortField] = useState('created_at'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + + // Массив статусов заказов + const statusOptions = [ + { value: '', label: 'Все статусы' }, + { value: 'pending', label: 'Ожидает оплаты' }, + { value: 'processing', label: 'В обработке' }, + { value: 'shipped', label: 'Отправлен' }, + { value: 'delivered', label: 'Доставлен' }, + { value: 'cancelled', label: 'Отменен' }, + { value: 'refunded', label: 'Возвращен' } + ]; + + // Массив фильтров по дате + const dateOptions = [ + { value: '', label: 'За все время' }, + { value: 'today', label: 'Сегодня' }, + { value: 'yesterday', label: 'Вчера' }, + { value: 'week', label: 'За неделю' }, + { value: 'month', label: 'За месяц' } + ]; + + // Функция сортировки заказов + const sortOrders = (orders: Order[], field: string, direction: 'asc' | 'desc') => { + return [...orders].sort((a, b) => { + let valueA, valueB; + + switch (field) { + case 'id': + valueA = a.id; + valueB = b.id; + break; + case 'user_name': + valueA = a.user_name || ''; + valueB = b.user_name || ''; + break; + case 'created_at': + valueA = new Date(a.created_at).getTime(); + valueB = new Date(b.created_at).getTime(); + break; + case 'items_count': + valueA = a.items_count || 0; + valueB = b.items_count || 0; + break; + case 'total': + valueA = a.total || 0; + valueB = b.total || 0; + break; + default: + valueA = a[field as keyof Order] || ''; + valueB = b[field as keyof Order] || ''; + } + + if (valueA < valueB) return direction === 'asc' ? -1 : 1; + if (valueA > valueB) return direction === 'asc' ? 1 : -1; + return 0; + }); + }; + + // Обработчик клика по заголовку таблицы для сортировки + const handleSort = (field: string) => { + const newDirection = sortField === field && sortDirection === 'asc' ? 'desc' : 'asc'; + setSortField(field); + setSortDirection(newDirection); + + // Сортируем заказы + const sortedOrders = sortOrders(filteredOrders, field, newDirection); + setFilteredOrders(sortedOrders); + }; + + // Получить иконку направления сортировки + const getSortIcon = (field: string) => { + if (sortField !== field) return null; + + return ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + ); + }; + + // Загрузка заказов + useEffect(() => { + const loadOrders = async () => { + try { + setLoading(true); + setError(null); + + // Формируем параметры запроса + const params: any = { + limit: 100 // Максимальное количество заказов + }; + + // Добавляем фильтр по статусу + if (statusFilter) { + params.status = statusFilter; + } + + // Добавляем фильтр по дате + if (dateFilter) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const monthAgo = new Date(today); + monthAgo.setMonth(monthAgo.getMonth() - 1); + + switch (dateFilter) { + case 'today': + params.dateFrom = today.toISOString(); + break; + case 'yesterday': + params.dateFrom = yesterday.toISOString(); + params.dateTo = today.toISOString(); + break; + case 'week': + params.dateFrom = weekAgo.toISOString(); + break; + case 'month': + params.dateFrom = monthAgo.toISOString(); + break; + } + } + + // Добавляем поисковый запрос + if (searchTerm) { + params.search = searchTerm; + } + + const response = await fetchOrders(params); + + if (response.data) { + // Обработка возможных форматов ответа API + let orderData: Order[] = []; + + if (Array.isArray(response.data)) { + orderData = response.data; + } else { + // Если response.data - это объект, проверяем наличие свойства orders + const data = response.data as any; + if (data.orders && Array.isArray(data.orders)) { + orderData = data.orders; + } + } + + // Применяем сортировку к загруженным данным + const sortedOrders = sortOrders(orderData, sortField, sortDirection); + + setOrders(orderData); + setFilteredOrders(sortedOrders); + } else { + throw new Error('Не удалось получить данные заказов'); + } + } catch (err) { + console.error('Ошибка при загрузке заказов:', err); + setError('Не удалось загрузить заказы'); + } finally { + setLoading(false); + } + }; + + loadOrders(); + }, [statusFilter, dateFilter, searchTerm, sortField, sortDirection]); + + // Получить строковое представление статуса заказа + const getStatusLabel = (status: string) => { + switch (status) { + case 'pending': + return 'Ожидает оплаты'; + case 'processing': + return 'В обработке'; + case 'shipped': + return 'Отправлен'; + case 'delivered': + return 'Доставлен'; + case 'cancelled': + return 'Отменен'; + case 'refunded': + return 'Возвращен'; + default: + return status; + } + }; + + // Получить класс для стиля статуса заказа + const getStatusClass = (status: string) => { + switch (status) { + case 'pending': + return 'bg-yellow-100 text-yellow-800'; + case 'processing': + return 'bg-blue-100 text-blue-800'; + case 'shipped': + return 'bg-purple-100 text-purple-800'; + case 'delivered': + return 'bg-green-100 text-green-800'; + case 'cancelled': + return 'bg-red-100 text-red-800'; + case 'refunded': + return 'bg-gray-100 text-gray-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Ошибка! + {error} +
+ ); + } + + return ( +
+
+

Управление заказами

+ {filteredOrders.length > 0 && ( +
+ Найдено заказов: {filteredOrders.length} +
+ )} +
+ +
+
+
+
+ +
+ setSearchTerm(e.target.value)} + /> +
+ +
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + + {filteredOrders.length > 0 ? ( + filteredOrders.map((order) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
handleSort('id')} + > + № заказа {getSortIcon('id')} + handleSort('user_name')} + > + Клиент {getSortIcon('user_name')} + handleSort('created_at')} + > + Дата {getSortIcon('created_at')} + handleSort('items_count')} + > + Товары {getSortIcon('items_count')} + handleSort('status')} + > + Статус {getSortIcon('status')} + handleSort('total')} + > + Сумма {getSortIcon('total')} + + Действия +
#{order.id} +
{order.user_name || `Пользователь #${order.user_id}`}
+ {order.user_email && ( +
{order.user_email}
+ )} +
+
+ {formatDate(order.created_at)} +
+ {order.updated_at && order.updated_at !== order.created_at && ( +
+ изменен: {new Date(order.updated_at).toLocaleDateString('ru-RU')} +
+ )} +
+
+ {order.items_count || (order.items && order.items.length) || 0} + шт. +
+
+ + {getStatusLabel(order.status)} + + + {formatPrice(order.total || 0)} + + + Подробнее + +
+
+

Заказы не найдены

+

Попробуйте изменить параметры поиска или фильтрации

+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..922e752 --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function AdminPage() { + redirect('/admin/dashboard'); +} \ No newline at end of file diff --git a/frontend/app/admin/products/[id]/page.tsx b/frontend/app/admin/products/[id]/page.tsx new file mode 100644 index 0000000..77490d6 --- /dev/null +++ b/frontend/app/admin/products/[id]/page.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { fetchProduct, updateProduct, deleteProduct, Product, ApiResponse } from '@/lib/api'; +import { fetchCategories, Category } from '@/lib/catalog-admin'; +import ProductForm from '@/components/admin/ProductForm'; + +export default function EditProductPage({ params }: { params: { id: string } }) { + const router = useRouter(); + const productId = parseInt(params.id); + + const [product, setProduct] = useState(null); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingCategories, setLoadingCategories] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Загрузка данных товара + useEffect(() => { + const loadProduct = async () => { + try { + setLoading(true); + const response = await fetchProduct(productId); + + if (response.success && response.data) { + setProduct(response.data); + } else { + throw new Error(response.error || 'Не удалось загрузить товар'); + } + } catch (err) { + console.error('Ошибка при загрузке товара:', err); + setError('Не удалось загрузить товар'); + } finally { + setLoading(false); + } + }; + + if (productId) { + loadProduct(); + } + }, [productId]); + + // Загрузка категорий + useEffect(() => { + const loadCategories = async () => { + try { + setLoadingCategories(true); + const response = await fetchCategories(); + + if (response.data && Array.isArray(response.data)) { + setCategories(response.data); + } + } catch (err) { + console.error('Ошибка при загрузке категорий:', err); + } finally { + setLoadingCategories(false); + } + }; + + loadCategories(); + }, []); + + // Обработчик сохранения товара + const handleSubmit = async (formData: Partial) => { + try { + setSaving(true); + + // Подготавливаем данные для обновления + const updateData: Partial = { + name: formData.name, + slug: formData.slug || undefined, + description: formData.description, + price: formData.price, + discount_price: formData.discount_price, + is_active: formData.is_active, + category_id: formData.category_id ? parseInt(formData.category_id.toString()) : undefined, + collection_id: formData.collection_id ? parseInt(formData.collection_id.toString()) : undefined, + care_instructions: formData.care_instructions + }; + + console.log('Отправляем данные для обновления:', updateData); + + const response = await updateProduct(productId, updateData); + + if (response.success) { + console.log('Товар успешно обновлен'); + router.push('/admin/products'); + } else { + throw new Error(`Ошибка обновления: ${response.error}`); + } + } catch (err) { + console.error('Ошибка при сохранении товара:', err); + setError('Не удалось сохранить товар'); + } finally { + setSaving(false); + } + }; + + // Обработчик удаления товара + const handleDelete = async () => { + if (window.confirm('Вы уверены, что хотите удалить этот товар?')) { + try { + setSaving(true); + const response = await deleteProduct(productId); + if (response.success) { + router.push('/admin/products'); + } + } catch (err) { + console.error('Ошибка при удалении товара:', err); + setError('Не удалось удалить товар'); + } finally { + setSaving(false); + } + } + }; + + return ( +
+
+
+ + + +

+ {loading ? 'Загрузка...' : `Редактирование: ${product?.name}`} +

+
+
+ + {error && ( +
+
+
+

{error}

+
+
+
+ )} + + {loading || loadingCategories ? ( +
+
+
+ ) : product ? ( + + ) : ( +
+

Продукт не найден

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/admin/products/components/EditProductDialog.tsx b/frontend/app/admin/products/components/EditProductDialog.tsx new file mode 100644 index 0000000..8251ff5 --- /dev/null +++ b/frontend/app/admin/products/components/EditProductDialog.tsx @@ -0,0 +1,965 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from '@/components/ui/dialog'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent } from '@/components/ui/card'; +import { + fetchProduct, + updateProduct, + createProduct, + Product, + fetchSizes, + Size, + getImageUrl, + uploadProductImage, + deleteProductImage, + setProductImageAsPrimary +} from '@/lib/api'; +import { fetchCategories, Category } from '@/lib/catalog-admin'; +import { Upload, X, Plus, Minus, Star, StarOff } from 'lucide-react'; + +// Типы для работы с изображениями +interface ProductImage { + id: number; + image_url: string; + is_primary?: boolean; + alt_text?: string; +} + +// Тип для локального изображения, ожидающего загрузки +interface LocalImage { + file: File; + preview: string; + isLocal: true; + isPrimary?: boolean; +} + +interface EditProductDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + productId?: number; // Если не указан, то это создание нового товара + onComplete: () => void; +} + +export default function EditProductDialog({ + isOpen, + onOpenChange, + productId, + onComplete +}: EditProductDialogProps) { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('general'); + + // Данные + const [product, setProduct] = useState(null); + const [categories, setCategories] = useState([]); + const [sizes, setSizes] = useState([]); + + // Основные данные формы + const [formData, setFormData] = useState({ + name: '', + slug: '', + description: '', + price: 0, + discount_price: null as number | null, + care_instructions: {} as Record, + is_active: true, + category_id: '', + collection_id: '', + images: [] as Array + }); + + // Состояние для загрузки файлов + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + // Варианты размеров + const [variantSizes, setVariantSizes] = useState<{ + size_id: number; + stock: number; + checked: boolean; + }[]>([]); + + // Загрузка данных товара + useEffect(() => { + if (isOpen && productId) { + const loadProduct = async () => { + try { + setLoading(true); + const response = await fetchProduct(productId); + + // Проверяем структуру ответа + console.log('Ответ API при загрузке товара:', response); + + // Получаем данные товара в зависимости от структуры ответа + let productData: Product | null = null; + if (response.data) { + if (typeof response.data === 'object' && 'product' in response.data) { + // Новая структура API: { data: { product: {...}, success: true } } + productData = response.data.product as Product; + } else { + // Старая структура API: { data: {...} } + productData = response.data as Product; + } + } + + if (productData) { + console.log('Загруженные данные товара:', productData); + + // Обработка изображений + let productImages: Array = []; + if (productData.images) { + productImages = Array.isArray(productData.images) + ? productData.images.map((img: any) => { + // Сохраняем объект изображения как есть + if (typeof img === 'object' && img !== null && img.image_url) { + return { + id: img.id || 0, + image_url: img.image_url, + is_primary: img.is_primary || false, + alt_text: img.alt_text || '' + } as ProductImage; + } + // Для строк возвращаем строку + return typeof img === 'string' ? img : ''; + }).filter(Boolean) + : []; + } + + // Обработка care_instructions + let careInstructions: Record = {}; + if (productData.care_instructions) { + if (typeof productData.care_instructions === 'string') { + try { + // Пробуем распарсить строку как JSON + careInstructions = JSON.parse(productData.care_instructions); + } catch (e) { + // Если не получилось, сохраняем как текст + careInstructions = { text: productData.care_instructions }; + } + } else if (typeof productData.care_instructions === 'object') { + careInstructions = productData.care_instructions as Record; + } + } + + setProduct(productData); + setFormData({ + name: productData.name || '', + slug: productData.slug || '', + description: productData.description || '', + price: typeof productData.price === 'number' ? productData.price : 0, + discount_price: productData.discount_price || null, + care_instructions: careInstructions, + is_active: productData.is_active !== false, // По умолчанию true + category_id: productData.category?.id?.toString() || + (productData.category_id ? productData.category_id.toString() : 'none'), + collection_id: productData.collection?.id?.toString() || + (productData.collection_id ? productData.collection_id.toString() : 'none'), + images: productImages + }); + + // Если есть варианты размеров, загрузим их + if (productData.variants && Array.isArray(productData.variants)) { + // Инициализируем варианты при загрузке размеров + console.log('Загружены варианты товара:', productData.variants); + } + } + } catch (err) { + console.error('Ошибка при загрузке товара:', err); + setError('Не удалось загрузить товар'); + } finally { + setLoading(false); + } + }; + + loadProduct(); + } else if (isOpen) { + // Сброс формы для создания нового товара + setFormData({ + name: '', + slug: '', + description: '', + price: 0, + discount_price: null, + care_instructions: {}, + is_active: true, + category_id: 'none', + collection_id: 'none', + images: [] + }); + setProduct(null); + } + }, [isOpen, productId]); + + // Загрузка категорий и размеров + useEffect(() => { + if (isOpen) { + const loadData = async () => { + try { + // Загрузка категорий + const categoriesResponse = await fetchCategories(); + if (categoriesResponse.data && Array.isArray(categoriesResponse.data)) { + setCategories(categoriesResponse.data); + } + + // Загрузка размеров + const sizesResponse = await fetchSizes(); + if (sizesResponse.data && Array.isArray(sizesResponse.data)) { + setSizes(sizesResponse.data); + + // Подготовка вариантов размеров + const initialVariants = sizesResponse.data.map(size => { + // Если редактируем товар и у него есть варианты + let existingVariant = null; + if (product?.variants && Array.isArray(product.variants)) { + existingVariant = product.variants.find(v => v.size_id === size.id); + } + + return { + size_id: size.id, + stock: existingVariant?.stock || 0, + checked: !!existingVariant // Отмечаем, если вариант существует + }; + }); + + console.log('Инициализированы варианты размеров:', initialVariants); + setVariantSizes(initialVariants); + } + } catch (err) { + console.error('Ошибка при загрузке данных:', err); + } + }; + + loadData(); + } + }, [isOpen, product]); + + // Обработчик изменения полей формы + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target; + + if (name === 'care_instructions') { + try { + // Пытаемся распарсить JSON если пользователь ввел валидный JSON + const jsonValue = value.trim() ? JSON.parse(value) : {}; + setFormData(prev => ({ + ...prev, + [name]: jsonValue + })); + } catch (error) { + // Иначе сохраняем как строку в объекте + setFormData(prev => ({ + ...prev, + [name]: { text: value } + })); + } + } else { + setFormData(prev => ({ + ...prev, + [name]: type === 'number' + ? (value ? parseFloat(value) : 0) + : value + })); + } + }; + + // Обработчик изменения чекбокса + const handleCheckboxChange = (name: string, checked: boolean) => { + setFormData(prev => ({ + ...prev, + [name]: checked + })); + }; + + // Обработчик изменения variant size + const handleVariantSizeChange = (sizeId: number, checked: boolean) => { + setVariantSizes(prev => + prev.map(item => + item.size_id === sizeId + ? { ...item, checked } + : item + ) + ); + }; + + // Обработчик изменения stock варианта + const handleVariantStockChange = (sizeId: number, stock: number) => { + setVariantSizes(prev => + prev.map(item => + item.size_id === sizeId + ? { ...item, stock } + : item + ) + ); + }; + + // Обработчик загрузки изображения + const handleImageUpload = () => { + console.log('Открытие диалога выбора файла'); + if (fileInputRef.current) { + fileInputRef.current.click(); + } else { + console.error('Отсутствует ссылка на элемент для загрузки файла'); + } + }; + + // Обработчик выбора файла - с локальным предпросмотром перед отправкой + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + console.log('Выбраны файлы:', files); + + if (!files || files.length === 0) { + console.error('Не выбраны файлы'); + return; + } + + try { + setError(null); + + // Создаем локальные копии изображений для предпросмотра + Array.from(files).forEach((file, index) => { + // Создаем URL для предпросмотра + const previewUrl = URL.createObjectURL(file); + + // Добавляем изображение в состояние с пометкой, что оно локальное + const localImage: LocalImage = { + file, + preview: previewUrl, + isLocal: true, + isPrimary: formData.images.length === 0 && index === 0 // Первое изображение будет основным, если нет других + }; + + setFormData(prev => ({ + ...prev, + images: [...prev.images, localImage] + })); + + console.log(`Добавлен предпросмотр ${index + 1}:`, file.name); + }); + } catch (error) { + console.error('Ошибка при добавлении файлов:', error); + setError('Не удалось добавить выбранные изображения'); + } finally { + // Сбрасываем input для возможности повторной загрузки тех же файлов + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + // Обработчик удаления изображения + const handleRemoveImage = async (index: number) => { + const image = formData.images[index]; + + // Удаление с сервера если это серверное изображение (ProductImage) + if (productId && typeof image === 'object' && 'id' in image) { + try { + const response = await deleteProductImage(productId, image.id); + if (response.status !== 200 && response.status !== 204) { + console.error('Ошибка при удалении изображения с сервера'); + return; + } + } catch (err) { + console.error('Ошибка при удалении изображения:', err); + return; + } + } + + // Для локальных изображений освобождаем URL + if (typeof image === 'object' && 'isLocal' in image && image.isLocal) { + URL.revokeObjectURL(image.preview); + } + + // Обновление состояния + setFormData(prev => ({ + ...prev, + images: prev.images.filter((_, i) => i !== index) + })); + }; + + // Обработчик установки изображения как основного + const handleSetPrimary = async (index: number) => { + const image = formData.images[index]; + + // Для серверных изображений делаем API-запрос + if (productId && typeof image === 'object' && 'id' in image) { + try { + const response = await setProductImageAsPrimary(productId, image.id); + + if (response.status !== 200 && response.status !== 204) { + console.error('Ошибка при установке основного изображения'); + return; + } + } catch (err) { + console.error('Ошибка при установке основного изображения:', err); + return; + } + } + + // Обновляем все изображения, устанавливая is_primary/isPrimary только для выбранного + setFormData(prev => ({ + ...prev, + images: prev.images.map((img, i) => { + if (typeof img === 'object') { + if ('isLocal' in img && img.isLocal) { + // Локальное изображение + return { + ...img, + isPrimary: i === index + }; + } else if ('id' in img) { + // Серверное изображение + return { + ...img, + is_primary: i === index + }; + } + } + return img; + }) + })); + }; + + // Функция для загрузки локальных файлов на сервер + const uploadLocalImages = async (newProductId: number) => { + const localImages = formData.images.filter( + (img): img is LocalImage => typeof img === 'object' && 'isLocal' in img && img.isLocal + ); + + if (localImages.length === 0) { + return { success: true }; + } + + console.log(`Загрузка ${localImages.length} локальных изображений на сервер`); + setIsUploading(true); + + try { + for (const image of localImages) { + console.log(`Загрузка файла ${image.file.name}`); + + const response = await uploadProductImage(newProductId, image.file); + + if (response.status !== 201 || !response.data) { + console.error('Ошибка при загрузке изображения:', response); + return { + success: false, + error: response.error || `Не удалось загрузить изображение (код ${response.status})` + }; + } + + // Если изображение было отмечено как основное, устанавливаем его основным на сервере + const uploadedImageData = response.data; + if (image.isPrimary && uploadedImageData && typeof uploadedImageData === 'object') { + // Получаем ID изображения в зависимости от структуры ответа + const imageId = 'id' in uploadedImageData ? + uploadedImageData.id : + (uploadedImageData.product && typeof uploadedImageData.product === 'object' && 'id' in uploadedImageData.product ? + uploadedImageData.product.id : null); + + if (imageId) { + await setProductImageAsPrimary(newProductId, imageId); + } + } + } + + return { success: true }; + } catch (err: any) { + console.error('Ошибка при загрузке изображений:', err); + return { + success: false, + error: err.message || 'Ошибка при загрузке изображений' + }; + } finally { + setIsUploading(false); + } + }; + + // Обработчик сохранения + const handleSave = async () => { + try { + setSaving(true); + setError(null); + + // Подготовка данных для API + const productData = { + name: formData.name, + slug: formData.slug || formData.name.toLowerCase().replace(/\s+/g, '-'), + description: formData.description, + price: formData.price, + discount_price: formData.discount_price, + care_instructions: formData.care_instructions, + is_active: formData.is_active, + category_id: formData.category_id && formData.category_id !== "none" + ? parseInt(formData.category_id) + : undefined, + collection_id: formData.collection_id && formData.collection_id !== "none" + ? parseInt(formData.collection_id) + : undefined + // Изображения обрабатываются отдельно через API загрузки изображений + }; + + let response; + if (productId) { + // Обновление существующего товара + response = await updateProduct(productId, productData); + } else { + // Создание нового товара + response = await createProduct(productData); + } + + if (response.status === 200 || response.status === 201) { + // Успешное сохранение + console.log('Товар успешно сохранен:', response.data); + + // Получаем ID созданного или обновленного товара + const savedProductId = productId || + (response.data && typeof response.data === 'object' ? + ('id' in response.data ? + (response.data as Product).id : + (response.data.product && typeof response.data.product === 'object' && 'id' in response.data.product ? + response.data.product.id : null)) + : null); + + if (savedProductId) { + // Загружаем локальные изображения на сервер + const uploadResult = await uploadLocalImages(savedProductId); + + if (!uploadResult.success) { + setError(uploadResult.error || 'Не удалось загрузить изображения'); + return; + } + } + + // Успешно сохранено и загружены изображения + onComplete(); + onOpenChange(false); + } else { + throw new Error(`Ошибка сохранения: ${response.status}`); + } + } catch (err) { + console.error('Ошибка при сохранении товара:', err); + setError('Не удалось сохранить товар'); + } finally { + setSaving(false); + } + }; + + // Очистка локальных превью при закрытии диалога + useEffect(() => { + if (!isOpen) { + // Освобождаем URL-ы для предпросмотра при закрытии диалога + formData.images.forEach(image => { + if (typeof image === 'object' && 'isLocal' in image && image.isLocal) { + URL.revokeObjectURL(image.preview); + } + }); + } + }, [isOpen, formData.images]); + + return ( + + + + + {productId ? 'Редактирование товара' : 'Создание нового товара'} + + + Заполните информацию о товаре и его вариантах + + + + {loading ? ( +
+
+

Загрузка данных...

+
+ ) : ( + <> + + + Основная информация + Варианты и размеры + Изображения + + + {/* Основная информация */} + +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ +