diff --git a/.DS_Store b/.DS_Store
index 99840b4..5ba35b8 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/backend/.DS_Store b/backend/.DS_Store
index 5918e07..3c06a03 100644
Binary files a/backend/.DS_Store and b/backend/.DS_Store differ
diff --git a/backend/.env b/backend/.env
new file mode 100644
index 0000000..b7329b9
--- /dev/null
+++ b/backend/.env
@@ -0,0 +1,13 @@
+# Настройки базы данных
+DATABASE_URL=postgresql://postgres:postgres@localhost:5434/shop_db
+
+# Настройки безопасности
+SECRET_KEY=supersecretkey
+DEBUG=1
+
+# Настройки загрузки файлов
+UPLOAD_DIRECTORY=uploads
+
+# Настройки Meilisearch
+MEILISEARCH_URL=http://localhost:7700
+MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
diff --git a/backend/.env.docker b/backend/.env.docker
index 76e4300..e8c8b23 100644
--- a/backend/.env.docker
+++ b/backend/.env.docker
@@ -7,4 +7,8 @@ SECRET_KEY=supersecretkey
# UPLOAD_DIRECTORY=/app/uploads
# Настройки CORS
-FRONTEND_URL=http://frontend:3000
\ No newline at end of file
+FRONTEND_URL=http://frontend:3000
+
+# Настройки Meilisearch
+MEILISEARCH_URL=http://meilisearch:7700
+MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
diff --git a/backend/app/.DS_Store b/backend/app/.DS_Store
index b3916a8..55d04c0 100644
Binary files a/backend/app/.DS_Store and b/backend/app/.DS_Store differ
diff --git a/backend/app/.env b/backend/app/.env
index da639d8..133860c 100644
--- a/backend/app/.env
+++ b/backend/app/.env
@@ -7,4 +7,7 @@ MINIO_ENDPOINT_URL = http://45.129.128.113:9000
MINIO_ACCESS_KEY = ZIK_DressedForSuccess
MINIO_SECRET_KEY = ZIK_DressedForSuccess_/////ZIK_DressedForSuccess_!
MINIO_BUCKET_NAME = dressedforsuccess
-MINIO_USE_SSL = false
\ No newline at end of file
+MINIO_USE_SSL = false
+
+MEILISEARCH_KEY = dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
+MEILISEARCH_URL = http://localhost:7700
\ No newline at end of file
diff --git a/backend/app/__pycache__/cache_with_logging.cpython-310.pyc b/backend/app/__pycache__/cache_with_logging.cpython-310.pyc
new file mode 100644
index 0000000..698b126
Binary files /dev/null and b/backend/app/__pycache__/cache_with_logging.cpython-310.pyc differ
diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc
index 7160e95..0200aea 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 315f529..7647237 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 9756554..dddbef9 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/config.py b/backend/app/config.py
index 3324835..5bde74e 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -8,7 +8,7 @@ load_dotenv()
# Базовые настройки
API_PREFIX = "/api"
-DEBUG = True
+DEBUG = False
# Настройки безопасности
SECRET_KEY = os.getenv("SECRET_KEY", "supersecretkey") # Для JWT
@@ -53,15 +53,16 @@ class Settings(BaseSettings):
APP_NAME: str = "Интернет-магазин API"
APP_VERSION: str = "0.1.0"
APP_DESCRIPTION: str = "API для интернет-магазина на FastAPI"
-
+ DEBUG: bool = False
+
# Настройки базы данных
DATABASE_URL: str = "postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db"
-
+
# Настройки безопасности
SECRET_KEY: str = SECRET_KEY
ALGORITHM: str = ALGORITHM
ACCESS_TOKEN_EXPIRE_MINUTES: int = ACCESS_TOKEN_EXPIRE_MINUTES
-
+
# Настройки CORS
CORS_ORIGINS: list = [
"http://localhost",
@@ -69,39 +70,44 @@ class Settings(BaseSettings):
"http://localhost:8000",
"http://localhost:8080",
]
-
- # Старые настройки для загрузки файлов (удалить или закомментировать)
- # UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY
+
+ # Настройки для загрузки файлов
+ UPLOAD_DIRECTORY: str = "uploads"
MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE
ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS)
-
+
# Настройки MinIO/S3
MINIO_ENDPOINT_URL: str = MINIO_ENDPOINT_URL
MINIO_ACCESS_KEY: str = MINIO_ACCESS_KEY
MINIO_SECRET_KEY: str = MINIO_SECRET_KEY
MINIO_BUCKET_NAME: str = MINIO_BUCKET_NAME
MINIO_USE_SSL: bool = MINIO_USE_SSL
-
+
# Настройки для платежных систем (пример)
PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "")
PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "")
-
+
# Настройки для CDEK API
CDEK_LOGIN: str = os.getenv("CDEK_LOGIN", "cdek-login")
CDEK_PASSWORD: str = os.getenv("CDEK_PASSWORD", "cdek-pass")
CDEK_BASE_URL: str = os.getenv("CDEK_BASE_URL", "https://api.cdek.ru/v2")
-
+
# Настройки для отправки email (пример)
SMTP_SERVER: str = MAIL_SERVER
SMTP_PORT: int = MAIL_PORT
SMTP_USERNAME: str = MAIL_USERNAME
SMTP_PASSWORD: str = MAIL_PASSWORD
EMAIL_FROM: str = MAIL_FROM
-
+
+ # Настройки Meilisearch
+ MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://localhost:7700")
+ MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey")
+
+
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Создаем экземпляр настроек
-settings = Settings()
\ No newline at end of file
+settings = Settings()
\ No newline at end of file
diff --git a/backend/app/main.py b/backend/app/main.py
index 7eb0260..9153a09 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,23 +1,57 @@
import os
+import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import SQLAlchemyError
+from contextlib import asynccontextmanager
from app.config import settings
from app.routers import router
-from app.core import Base, engine
+from app.core import Base, engine, SessionLocal
+from app.services import meilisearch_service
+from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
+
+logging.basicConfig(level=logging.INFO)
# Создаем таблицы в базе данных
Base.metadata.create_all(bind=engine)
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # Инициализируем Meilisearch
+ logging.info("Инициализация Meilisearch...")
+ try:
+ # Инициализируем индексы
+ meilisearch_service.initialize_indexes()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+ try:
+ # Синхронизируем данные с Meilisearch
+ sync_categories(db)
+ sync_collections(db)
+ sync_sizes(db)
+ sync_products(db)
+ logging.info("Синхронизация с Meilisearch завершена успешно")
+ except Exception as e:
+ logging.error(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}")
+ finally:
+ db.close()
+ except Exception as e:
+ logging.error(f"Ошибка при инициализации Meilisearch: {str(e)}")
+
+ yield
+
# Создаем экземпляр приложения FastAPI
app = FastAPI(
title="Dressed for Success API",
description="API для интернет-магазина одежды",
- version="1.0.0"
+ version="1.0.0",
+ lifespan=lifespan
)
# Настраиваем CORS
@@ -40,7 +74,10 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
# Подключаем роутеры
app.include_router(router, prefix="/api")
+
+
+
# Корневой маршрут
@app.get("/")
async def root():
- return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
\ No newline at end of file
+ return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
diff --git a/backend/app/repositories/__pycache__/async_catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/async_catalog_repo.cpython-310.pyc
new file mode 100644
index 0000000..f89ab80
Binary files /dev/null and b/backend/app/repositories/__pycache__/async_catalog_repo.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 b7e6ecd..6b2be9e 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__/user_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/user_repo.cpython-310.pyc
index 4dbfdeb..8296209 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 b5b7d88..aa35244 100644
--- a/backend/app/repositories/catalog_repo.py
+++ b/backend/app/repositories/catalog_repo.py
@@ -28,7 +28,7 @@ def generate_slug(name: str) -> str:
slug = re.sub(r'-+', '-', slug)
# Удаляем дефисы в начале и конце
slug = slug.strip('-')
-
+
return slug
@@ -42,16 +42,16 @@ def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]:
def get_collections(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
is_active: Optional[bool] = True
) -> List[Collection]:
query = db.query(Collection)
-
+
if is_active is not None:
query = query.filter(Collection.is_active == is_active)
-
+
return query.offset(skip).limit(limit).all()
@@ -59,14 +59,14 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
# Если slug не предоставлен, генерируем его из имени
if not collection.slug:
collection.slug = generate_slug(collection.name)
-
+
# Проверяем, что коллекция с таким slug не существует
if get_collection_by_slug(db, collection.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
-
+
# Создаем новую коллекцию
db_collection = Collection(
name=collection.name,
@@ -74,7 +74,7 @@ def create_collection(db: Session, collection: CollectionCreate) -> Collection:
description=collection.description,
is_active=collection.is_active
)
-
+
try:
db.add(db_collection)
db.commit()
@@ -95,10 +95,10 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
-
+
# Обновляем только предоставленные поля
update_data = collection.dict(exclude_unset=True)
-
+
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_collection.slug:
if get_collection_by_slug(db, update_data["slug"]):
@@ -106,7 +106,7 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
-
+
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
@@ -116,11 +116,11 @@ def update_collection(db: Session, collection_id: int, collection: CollectionUpd
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_collection, key, value)
-
+
try:
db.commit()
db.refresh(db_collection)
@@ -140,14 +140,14 @@ def delete_collection(db: Session, collection_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
-
+
# Проверяем, есть ли у коллекции продукты
if db.query(Product).filter(Product.collection_id == collection_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить коллекцию, у которой есть продукты"
)
-
+
try:
db.delete(db_collection)
db.commit()
@@ -170,18 +170,18 @@ def get_category_by_slug(db: Session, slug: str) -> Optional[Category]:
def get_categories(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
parent_id: Optional[int] = None
) -> List[Category]:
query = db.query(Category)
-
+
if parent_id is not None:
query = query.filter(Category.parent_id == parent_id)
else:
query = query.filter(Category.parent_id == None)
-
+
return query.offset(skip).limit(limit).all()
@@ -189,21 +189,21 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
# Если slug не предоставлен, генерируем его из имени
if not category.slug:
category.slug = generate_slug(category.name)
-
+
# Проверяем, что категория с таким slug не существует
if get_category_by_slug(db, category.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
-
+
# Проверяем, что родительская категория существует, если указана
if category.parent_id and not get_category(db, category.parent_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Родительская категория не найдена"
)
-
+
# Создаем новую категорию
db_category = Category(
name=category.name,
@@ -212,7 +212,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
parent_id=category.parent_id,
is_active=category.is_active
)
-
+
try:
db.add(db_category)
db.commit()
@@ -233,10 +233,10 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
-
+
# Обновляем только предоставленные поля
update_data = category.dict(exclude_unset=True)
-
+
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_category.slug:
if get_category_by_slug(db, update_data["slug"]):
@@ -244,7 +244,7 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
-
+
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
@@ -254,25 +254,25 @@ def update_category(db: Session, category_id: int, category: CategoryUpdate) ->
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория с таким slug уже существует"
)
-
+
# Проверяем, что родительская категория существует, если указана
if "parent_id" in update_data and update_data["parent_id"] and not get_category(db, update_data["parent_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Родительская категория не найдена"
)
-
+
# Проверяем, что категория не становится своим собственным родителем
if "parent_id" in update_data and update_data["parent_id"] == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Категория не может быть своим собственным родителем"
)
-
+
# Применяем обновления
for key, value in update_data.items():
setattr(db_category, key, value)
-
+
try:
db.commit()
db.refresh(db_category)
@@ -292,21 +292,21 @@ def delete_category(db: Session, category_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
-
+
# Проверяем, есть ли у категории подкатегории
if db.query(Category).filter(Category.parent_id == category_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить категорию, у которой есть подкатегории"
)
-
+
# Проверяем, есть ли у категории продукты
if db.query(Product).filter(Product.category_id == category_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить категорию, у которой есть продукты"
)
-
+
try:
db.delete(db_category)
db.commit()
@@ -329,50 +329,52 @@ def get_product_by_slug(db: Session, slug: str) -> Optional[Product]:
def get_products(
- db: Session,
- skip: int = 0,
- limit: int = 100,
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
category_id: Optional[int] = None,
collection_id: Optional[int] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
- is_active: Optional[bool] = True,
- include_variants: Optional[bool] = False
+ is_active: Optional[bool] = True
) -> List[Product]:
query = db.query(Product)
-
+
# Применяем фильтры
if category_id:
query = query.filter(Product.category_id == category_id)
-
+
if collection_id:
query = query.filter(Product.collection_id == collection_id)
-
+
if search:
query = query.filter(Product.name.ilike(f"%{search}%"))
-
+
if is_active is not None:
query = query.filter(Product.is_active == is_active)
-
+
# Фильтрация по цене теперь должна быть через варианты продукта
if min_price is not None or max_price is not None:
query = query.join(ProductVariant)
-
+
if min_price is not None:
query = query.filter(ProductVariant.price >= min_price)
-
+
if max_price is not None:
query = query.filter(ProductVariant.price <= max_price)
-
+
products = query.offset(skip).limit(limit).all()
-
- # Если нужно включить варианты, загружаем их для каждого продукта
- if include_variants:
- for product in products:
- product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
- product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
-
+
+ # Всегда загружаем варианты и изображения для каждого продукта
+ for product in products:
+ # Загружаем варианты с размерами
+ variants = db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product.id).all()
+ product.variants = variants
+
+ # Загружаем изображения
+ product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
+
return products
@@ -380,28 +382,28 @@ def create_product(db: Session, product: ProductCreate) -> Product:
# Если slug не предоставлен, генерируем его из имени
if not product.slug:
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=f"Продукт с slug '{product.slug}' уже существует"
)
-
+
# Проверяем, что категория существует
if not get_category(db, product.category_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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=f"Коллекция с ID {product.collection_id} не найдена"
)
-
+
db_product = Product(
name=product.name,
slug=product.slug,
@@ -413,7 +415,7 @@ def create_product(db: Session, product: ProductCreate) -> Product:
category_id=product.category_id,
collection_id=product.collection_id
)
-
+
try:
db.add(db_product)
db.commit()
@@ -434,7 +436,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт с ID {product_id} не найден"
)
-
+
# Если slug изменяется, проверяем его уникальность
if product.slug is not None and product.slug != db_product.slug:
if db.query(Product).filter(Product.slug == product.slug).first():
@@ -442,7 +444,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Продукт с slug '{product.slug}' уже существует"
)
-
+
# Если имя изменяется и 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)
@@ -452,7 +454,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Продукт с slug '{product.slug}' уже существует"
)
-
+
# Проверяем, что категория существует, если она изменяется
if product.category_id is not None and product.category_id != db_product.category_id:
if not get_category(db, product.category_id):
@@ -460,7 +462,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Категория с ID {product.category_id} не найдена"
)
-
+
# Проверяем, что коллекция существует, если она изменяется
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):
@@ -468,7 +470,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product.collection_id} не найдена"
)
-
+
# Обновляем поля
if product.name is not None:
db_product.name = product.name
@@ -488,7 +490,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
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)
@@ -508,7 +510,7 @@ def delete_product(db: Session, product_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
-
+
try:
db.delete(db_product)
db.commit()
@@ -527,7 +529,8 @@ def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant
def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]:
- return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all()
+ # Загружаем варианты с размерами
+ return db.query(ProductVariant).join(Size, ProductVariant.size_id == Size.id).filter(ProductVariant.product_id == product_id).all()
def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant:
@@ -538,7 +541,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт с ID {variant.product_id} не найден"
)
-
+
# Проверяем, что размер существует
db_size = get_size(db, variant.size_id)
if not db_size:
@@ -546,7 +549,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {variant.size_id} не найден"
)
-
+
# Проверяем, что вариант с таким SKU не существует
existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first()
if existing_variant:
@@ -554,7 +557,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
)
-
+
# Проверяем, что вариант с таким размером для этого продукта не существует
existing_size_variant = db.query(ProductVariant).filter(
ProductVariant.product_id == variant.product_id,
@@ -565,7 +568,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
)
-
+
db_variant = ProductVariant(
product_id=variant.product_id,
size_id=variant.size_id,
@@ -573,7 +576,7 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
stock=variant.stock,
is_active=variant.is_active
)
-
+
try:
db.add(db_variant)
db.commit()
@@ -594,7 +597,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Вариант продукта с ID {variant_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)
@@ -603,7 +606,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт с ID {variant.product_id} не найден"
)
-
+
# Проверяем, что размер существует, если ID размера изменяется
if variant.size_id is not None and variant.size_id != db_variant.size_id:
db_size = get_size(db, variant.size_id)
@@ -612,7 +615,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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(
@@ -625,7 +628,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
)
-
+
# Проверяем, что SKU уникален, если он изменяется
if variant.sku is not None and variant.sku != db_variant.sku:
existing_variant = db.query(ProductVariant).filter(
@@ -637,7 +640,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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
@@ -649,7 +652,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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)
@@ -669,7 +672,7 @@ def delete_product_variant(db: Session, variant_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Вариант продукта не найден"
)
-
+
try:
db.delete(db_variant)
db.commit()
@@ -698,14 +701,14 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
-
+
# Если изображение отмечено как основное, сбрасываем флаг у других изображений
if image.is_primary:
db.query(ProductImage).filter(
ProductImage.product_id == image.product_id,
ProductImage.is_primary == True
).update({"is_primary": False})
-
+
# Создаем новое изображение продукта
db_image = ProductImage(
product_id=image.product_id,
@@ -713,7 +716,7 @@ def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage
alt_text=image.alt_text,
is_primary=image.is_primary
)
-
+
try:
db.add(db_image)
db.commit()
@@ -734,20 +737,20 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение продукта не найдено"
)
-
+
# Если изображение отмечается как основное, сбрасываем флаг у других изображений
if image.is_primary and not db_image.is_primary:
db.query(ProductImage).filter(
ProductImage.product_id == db_image.product_id,
ProductImage.is_primary == True
).update({"is_primary": False})
-
+
# Обновляем поля
if image.alt_text is not None:
db_image.alt_text = image.alt_text
if image.is_primary is not None:
db_image.is_primary = image.is_primary
-
+
try:
db.commit()
db.refresh(db_image)
@@ -767,7 +770,7 @@ def delete_product_image(db: Session, image_id: int) -> bool:
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение продукта не найдено"
)
-
+
try:
db.delete(db_image)
db.commit()
@@ -801,13 +804,13 @@ def create_size(db: Session, size: SizeCreate) -> Size:
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()
@@ -828,7 +831,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
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)
@@ -837,7 +840,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Размер с кодом {size.code} уже существует"
)
-
+
# Обновляем поля
if size.name is not None:
db_size.name = size.name
@@ -845,7 +848,7 @@ def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
db_size.code = size.code
if size.description is not None:
db_size.description = size.description
-
+
try:
db.commit()
db.refresh(db_size)
@@ -865,7 +868,7 @@ def delete_size(db: Session, size_id: int) -> bool:
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:
@@ -873,7 +876,7 @@ def delete_size(db: Session, size_id: int) -> bool:
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов"
)
-
+
try:
db.delete(db_size)
db.commit()
@@ -883,4 +886,4 @@ def delete_size(db: Session, size_id: int) -> bool:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ошибка при удалении размера: {str(e)}"
- )
\ No newline at end of file
+ )
\ No newline at end of file
diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py
index d39cd5b..3c67287 100644
--- a/backend/app/repositories/user_repo.py
+++ b/backend/app/repositories/user_repo.py
@@ -14,11 +14,11 @@ def get_user(db: Session, user_id: int) -> Optional[User]:
def get_user_by_email(db: Session, email: str) -> Optional[User]:
- print(email)
+ # print(email)
users = db.query(User).all()
- print(users)
- for user in users:
- print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}")
+ # print(users)
+ # for user in users:
+ # print(f"ID: {user.id}, Email: {user.email}, Name: {user.first_name} {user.last_name}")
return db.query(User).filter(User.email == email).first()
diff --git a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc
index 97c36cf..4de452d 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/catalog_router.py b/backend/app/routers/catalog_router.py
index 856c4b6..9b2090b 100644
--- a/backend/app/routers/catalog_router.py
+++ b/backend/app/routers/catalog_router.py
@@ -1,4 +1,4 @@
-from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
+from fastapi import APIRouter, Depends, HTTPException, Request, status, UploadFile, File, Form, Query
from sqlalchemy.orm import Session
from typing import List, Optional, Dict, Any
from fastapi.responses import JSONResponse
@@ -17,6 +17,7 @@ from app.schemas.catalog_schemas import (
ProductCreateComplete, ProductUpdateComplete
)
from app.models.user_models import User as UserModel
+from app.models.catalog_models import Category, Size, Collection
from app.repositories.catalog_repo import get_products, get_product_by_slug
# Роутер для каталога
@@ -42,8 +43,64 @@ async def delete_collection_endpoint(collection_id: int, current_user: UserModel
@catalog_router.get("/collections", response_model=Dict[str, Any])
-async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
- return services.get_collections(db, skip, limit)
+async def get_collections_endpoint(
+ *,
+ skip: int = 0,
+ limit: int = 100,
+ search: Optional[str] = None,
+ is_active: Optional[bool] = None,
+ db: Session = Depends(get_db)
+):
+ # Формируем фильтры для Meilisearch
+ filters = []
+
+ if is_active is not None:
+ filters.append(f"is_active = {str(is_active).lower()}")
+
+ # Используем Meilisearch для поиска коллекций
+ from app.services import meilisearch_service
+
+ # Объединяем фильтры в строку
+ filter_str = " AND ".join(filters) if filters else None
+
+ # Выполняем поиск в Meilisearch
+ result = meilisearch_service.search_collections(
+ query=search or "",
+ filters=filter_str,
+ limit=limit,
+ offset=skip
+ )
+
+ # Если поиск в Meilisearch успешен, возвращаем результаты
+ if result["success"]:
+ return {
+ "success": True,
+ "collections": result["collections"],
+ "total": result["total"]
+ }
+
+ # Если поиск в Meilisearch не удался, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+
+ # Получаем коллекции из базы данных
+ collections_db = db.query(Collection).all()
+
+ # Преобразуем коллекции в список словарей
+ from app.schemas.catalog_schemas import Collection as CollectionSchema
+ collections_list = [CollectionSchema.model_validate(collection).model_dump() for collection in collections_db]
+
+ # Синхронизируем коллекции с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_collections
+ sync_collections(db)
+ except Exception as e:
+ logging.error(f"Failed to sync collections with Meilisearch: {str(e)}")
+
+ return {
+ "success": True,
+ "collections": collections_list,
+ "total": len(collections_list)
+ }
#########################
@@ -65,9 +122,72 @@ async def delete_category_endpoint(category_id: int, current_user: UserModel = D
return services.delete_category(db, category_id)
-@catalog_router.get("/categories", response_model=List[Dict[str, Any]])
-async def get_categories_tree(db: Session = Depends(get_db)):
- return services.get_category_tree(db)
+@catalog_router.get("/categories", response_model=Dict[str, Any])
+async def get_categories_endpoint(
+ *,
+ skip: int = 0,
+ limit: int = 100,
+ search: Optional[str] = None,
+ parent_id: Optional[int] = None,
+ is_active: Optional[bool] = None,
+ db: Session = Depends(get_db)
+):
+ # Формируем фильтры для Meilisearch
+ filters = []
+
+ if parent_id is not None:
+ filters.append(f"parent_id = {parent_id}")
+
+ if is_active is not None:
+ filters.append(f"is_active = {str(is_active).lower()}")
+
+ # Используем Meilisearch для поиска категорий
+ from app.services import meilisearch_service
+
+ # Объединяем фильтры в строку
+ filter_str = " AND ".join(filters) if filters else None
+
+ # Выполняем поиск в Meilisearch
+ result = meilisearch_service.search_categories(
+ query=search or "",
+ filters=filter_str,
+ limit=limit,
+ offset=skip
+ )
+
+ # Добавляем логирование для отладки
+ logging.info(f"Meilisearch search result: {result}")
+
+ # Если поиск в Meilisearch успешен, возвращаем результаты
+ if result["success"] and result["categories"]:
+ return {
+ "success": True,
+ "categories": result["categories"],
+ "total": result["total"]
+ }
+
+ # Если поиск в Meilisearch не удался, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+
+ # Получаем категории из базы данных
+ categories = db.query(Category).all()
+
+ # Преобразуем категории в список словарей
+ from app.schemas.catalog_schemas import Category as CategorySchema
+ categories_list = [CategorySchema.model_validate(category).model_dump() for category in categories]
+
+ # Синхронизируем категории с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_categories
+ sync_categories(db)
+ except Exception as e:
+ logging.error(f"Failed to sync categories with Meilisearch: {str(e)}")
+
+ return {
+ "success": True,
+ "categories": categories_list,
+ "total": len(categories_list)
+ }
#########################
@@ -76,40 +196,73 @@ async def get_categories_tree(db: Session = Depends(get_db)):
@catalog_router.post("/products", response_model=Dict[str, Any])
async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
+ # Используем синхронную версию для создания продукта
return services.create_product(db, product)
@catalog_router.put("/products/{product_id}", response_model=Dict[str, Any])
async def update_product_endpoint(product_id: int, product: ProductUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
+ # Используем синхронную версию для обновления продукта
return services.update_product(db, product_id, product)
@catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any])
async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
+ # Используем синхронную версию для удаления продукта
return services.delete_product(db, product_id)
@catalog_router.get("/products/{product_id}", response_model=Dict[str, Any])
-async def get_product_details_endpoint(product_id: int, db: Session = Depends(get_db)):
- return services.get_product_details(db, product_id)
+async def get_product_details_endpoint(*, product_id: int, db: Session = Depends(get_db)):
+ # Используем синхронную версию для получения деталей продукта
+ product = services.get_product_details(db, product_id)
+ return {"success": True, "product": product}
@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any])
-async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)):
+async def get_product_by_slug_endpoint(*, slug: str, db: Session = Depends(get_db)):
+ # Сначала пробуем найти продукт в Meilisearch
+ from app.services import meilisearch_service
+
+ # Используем поиск по slug
+ result = meilisearch_service.get_product_by_slug(slug)
+
+ # Если продукт найден в Meilisearch, возвращаем его
+ if result["success"]:
+ return {
+ "success": True,
+ "product": result["product"]
+ }
+
+ # Если продукт не найден в Meilisearch, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+
+ # Используем синхронную версию для получения продукта по slug
product = get_product_by_slug(db, slug)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail="Продукт не найден"
+ detail=f"Продукт с slug {slug} не найден"
)
- return services.get_product_details(db, product.id)
+
+ # Получаем детали продукта
+ product_details = services.get_product_details(db, product.id)
+
+ # Синхронизируем продукт с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_products
+ sync_products(db)
+ except Exception as e:
+ logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
+
+ return {"success": True, "product": product_details}
@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),
+ product_id: int,
+ variant: ProductVariantCreate,
+ current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
# Убедимся, что product_id в пути совпадает с product_id в данных
@@ -120,9 +273,9 @@ async def add_product_variant_endpoint(
@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),
+ 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)
@@ -130,8 +283,8 @@ async def update_product_variant_endpoint(
@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),
+ variant_id: int,
+ current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
return services.delete_product_variant(db, variant_id)
@@ -180,7 +333,7 @@ async def upload_product_image_endpoint(
logging.info(f"Начало обработки загрузки изображения для продукта {product_id}")
logging.info(f"Данные файла: имя={file.filename}, тип={file.content_type}, размер={file.size if hasattr(file, 'size') else 'unknown'}")
logging.info(f"is_primary: {is_primary}")
-
+
# Удостоверяемся, что продукт существует
from app.models.catalog_models import Product
product = db.query(Product).filter(Product.id == product_id).first()
@@ -196,7 +349,7 @@ async def upload_product_image_endpoint(
logging.info("Вызов сервиса upload_product_image")
result = services.upload_product_image(
db, product_id, file, is_primary, alt_text="")
-
+
logging.info(f"Результат загрузки изображения: {result}")
# Возвращаем успешный ответ с данными изображения
@@ -216,7 +369,7 @@ async def upload_product_image_endpoint(
error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
-
+
# Возвращаем ошибку с кодом 400
return JSONResponse(
status_code=400,
@@ -226,9 +379,9 @@ async def upload_product_image_endpoint(
@catalog_router.put("/images/{image_id}", response_model=Dict[str, Any])
async def update_product_image_endpoint(
- image_id: int,
- image: ProductImageUpdate,
- current_user: UserModel = Depends(get_current_admin_user),
+ image_id: int,
+ image: ProductImageUpdate,
+ current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
return services.update_product_image(db, image_id, image)
@@ -239,78 +392,231 @@ async def delete_product_image_endpoint(image_id: int, current_user: UserModel =
return services.delete_product_image(db, image_id)
-@catalog_router.get("/products", response_model=List[Product])
+@catalog_router.get("/products", response_model=Dict[str, Any])
async def get_products_endpoint(
- skip: int = 0,
- limit: int = 100,
+ *,
+ skip: int = 0,
+ limit: int = 100,
category_id: Optional[int] = None,
collection_id: Optional[int] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
is_active: Optional[bool] = True,
- include_variants: Optional[bool] = False,
+ sort_by: Optional[str] = None,
+ sort_order: Optional[str] = "asc",
db: Session = Depends(get_db)
):
- products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants)
- # Преобразуем объекты SQLAlchemy в схемы Pydantic
- return [Product.from_orm(product) for product in products]
+ # Формируем фильтры для Meilisearch
+ filters = []
+
+ if category_id is not None:
+ filters.append(f"category_id = {category_id}")
+
+ if collection_id is not None:
+ filters.append(f"collection_id = {collection_id}")
+
+ if is_active is not None:
+ filters.append(f"is_active = {str(is_active).lower()}")
+
+ if min_price is not None:
+ filters.append(f"price >= {min_price}")
+
+ if max_price is not None:
+ filters.append(f"price <= {max_price}")
+
+ # Формируем параметры сортировки
+ sort = None
+ if sort_by:
+ sort = [f"{sort_by}:{sort_order}"]
+
+ # Используем Meilisearch для поиска продуктов
+ from app.services import meilisearch_service
+
+ # Объединяем фильтры в строку
+ filter_str = " AND ".join(filters) if filters else None
+
+ # Выполняем поиск в Meilisearch
+ result = meilisearch_service.search_products(
+ query=search or "",
+ filters=filter_str,
+ sort=sort,
+ limit=limit,
+ offset=skip
+ )
+
+ # Если поиск в Meilisearch успешен, возвращаем результаты
+ if result["success"]:
+ return {
+ "success": True,
+ "products": result["products"],
+ "total": result["total"],
+ "skip": skip,
+ "limit": limit
+ }
+
+ # Если поиск в Meilisearch не удался, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+ products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active)
+ products_list = [services.format_product(product) for product in products]
+
+ # Синхронизируем продукты с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_products
+ sync_products(db)
+ except Exception as e:
+ logging.error(f"Failed to sync products with Meilisearch: {str(e)}")
+
+ return {
+ "success": True,
+ "products": products_list,
+ "total": len(products_list),
+ "skip": skip,
+ "limit": limit
+ }
# Маршруты для размеров
-@catalog_router.get("/sizes", response_model=List[Size])
-def get_sizes(
- skip: int = 0,
+@catalog_router.get("/sizes", response_model=Dict[str, Any])
+async def get_sizes(*,
+ skip: int = 0,
limit: int = 100,
+ search: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Получить список всех размеров"""
- return services.get_sizes(db, skip, limit)
+ # Используем Meilisearch для поиска размеров
+ from app.services import meilisearch_service
+
+ # Выполняем поиск в Meilisearch
+ result = meilisearch_service.search_sizes(
+ query=search or "",
+ filters=None,
+ limit=limit,
+ offset=skip
+ )
+
+ # Если поиск в Meilisearch успешен, возвращаем результаты
+ if result["success"]:
+ return {
+ "success": True,
+ "sizes": result["sizes"],
+ "total": result["total"]
+ }
+
+ # Если поиск в Meilisearch не удался, используем старый метод
+ logging.warning(f"Meilisearch search failed: {result.get('error')}. Falling back to database search.")
+
+ # Получаем размеры из базы данных
+ from app.models.catalog_models import Size
+ sizes_db = db.query(Size).all()
+
+ # Преобразуем размеры в список словарей
+ from app.schemas.catalog_schemas import Size as SizeSchema
+ sizes_list = [SizeSchema.model_validate(size).model_dump() for size in sizes_db]
+
+ # Синхронизируем размеры с Meilisearch для будущих запросов
+ try:
+ from app.scripts.sync_meilisearch import sync_sizes
+ sync_sizes(db)
+ except Exception as e:
+ logging.error(f"Failed to sync sizes with Meilisearch: {str(e)}")
+
+ return {
+ "success": True,
+ "sizes": sizes_list,
+ "total": len(sizes_list)
+ }
-@catalog_router.get("/sizes/{size_id}", response_model=Size)
-def get_size(
+@catalog_router.get("/sizes/{size_id}", response_model=Dict[str, Any])
+async def get_size(
size_id: int,
db: Session = Depends(get_db)
):
"""Получить размер по ID"""
- return services.get_size(db, size_id)
+ # Пробуем получить размер из Meilisearch
+ from app.services import meilisearch_service
+ result = meilisearch_service.get_size(size_id)
+
+ if result["success"]:
+ return {
+ "success": True,
+ "size": result["size"]
+ }
+
+ # Если размер не найден в Meilisearch, получаем его из базы данных
+ size = services.get_size(db, size_id)
+
+ # Преобразуем объект SQLAlchemy в словарь
+ from app.schemas.catalog_schemas import Size as SizeSchema
+ size_dict = SizeSchema.model_validate(size).model_dump()
+
+ return {
+ "success": True,
+ "size": size_dict
+ }
-@catalog_router.post("/sizes", response_model=Size, status_code=status.HTTP_201_CREATED)
+@catalog_router.post("/sizes", response_model=Dict[str, Any], status_code=status.HTTP_201_CREATED)
def create_size(
size: SizeCreate,
db: Session = Depends(get_db)
):
"""Создать новый размер"""
- return services.create_size(db, size)
+ new_size = services.create_size(db, size)
+
+ # Преобразуем объект SQLAlchemy в словарь
+ from app.schemas.catalog_schemas import Size as SizeSchema
+ size_dict = SizeSchema.model_validate(new_size).model_dump()
+
+ return {
+ "success": True,
+ "size": size_dict
+ }
-@catalog_router.put("/sizes/{size_id}", response_model=Size)
+@catalog_router.put("/sizes/{size_id}", response_model=Dict[str, Any])
def update_size(
size_id: int,
size: SizeUpdate,
db: Session = Depends(get_db)
):
"""Обновить размер"""
- return services.update_size(db, size_id, size)
+ updated_size = services.update_size(db, size_id, size)
+
+ # Преобразуем объект SQLAlchemy в словарь
+ from app.schemas.catalog_schemas import Size as SizeSchema
+ size_dict = SizeSchema.model_validate(updated_size).model_dump()
+
+ return {
+ "success": True,
+ "size": size_dict
+ }
-@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, bool])
+@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, Any])
def delete_size(
size_id: int,
db: Session = Depends(get_db)
):
"""Удалить размер"""
+ # Удаляем размер из базы данных
success = services.delete_size(db, size_id)
+
+ # Если удаление прошло успешно, удаляем размер из Meilisearch
+ if success:
+ from app.services import meilisearch_service
+ meilisearch_service.delete_size(size_id)
+
return {"success": success}
# Маршруты для комплексного создания и обновления продуктов
@catalog_router.post("/products/complete", response_model=Dict[str, Any])
async def create_product_complete_endpoint(
- product: ProductCreateComplete,
- current_user: UserModel = Depends(get_current_admin_user),
+ product: ProductCreateComplete,
+ current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
@@ -321,12 +627,13 @@ async def create_product_complete_endpoint(
@catalog_router.put("/products/{product_id}/complete", response_model=Dict[str, Any])
async def update_product_complete_endpoint(
- product_id: int,
- product: ProductUpdateComplete,
- current_user: UserModel = Depends(get_current_admin_user),
+ product_id: int,
+ product: ProductUpdateComplete,
+ current_user: UserModel = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Обновление продукта вместе с его вариантами и изображениями в одном запросе.
"""
- return services.update_product_complete(db, product_id, product)
\ No newline at end of file
+ result = services.update_product_complete(db, product_id, product)
+ return result
diff --git a/backend/app/scripts/__init__.py b/backend/app/scripts/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/scripts/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/scripts/__pycache__/__init__.cpython-310.pyc b/backend/app/scripts/__pycache__/__init__.cpython-310.pyc
new file mode 100644
index 0000000..fbaedd0
Binary files /dev/null and b/backend/app/scripts/__pycache__/__init__.cpython-310.pyc differ
diff --git a/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc b/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc
new file mode 100644
index 0000000..abb170f
Binary files /dev/null and b/backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc differ
diff --git a/backend/app/scripts/sync_meilisearch.py b/backend/app/scripts/sync_meilisearch.py
new file mode 100644
index 0000000..baefc21
--- /dev/null
+++ b/backend/app/scripts/sync_meilisearch.py
@@ -0,0 +1,249 @@
+import sys
+import os
+import logging
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent.parent.parent))
+
+from sqlalchemy.orm import Session
+from app.core import SessionLocal, engine
+from app.models.catalog_models import Product, Category, Collection, Size, ProductVariant, ProductImage
+from app.services import meilisearch_service
+from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema
+from app.schemas.catalog_schemas import Collection as CollectionSchema, Size as SizeSchema
+
+# Настройка логгера
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def format_product_for_meilisearch(product, variants, images):
+ """
+ Форматирует продукт для индексации в Meilisearch.
+ """
+ # Преобразуем объект SQLAlchemy в словарь
+ product_dict = {
+ "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.isoformat() if product.created_at else None,
+ "updated_at": product.updated_at.isoformat() if product.updated_at else None,
+ "variants": [],
+ "images": []
+ }
+
+ # Добавляем варианты продукта
+ for variant in variants:
+ variant_dict = {
+ "id": variant.id,
+ "size_id": variant.size_id,
+ "sku": variant.sku,
+ "stock": variant.stock,
+ "is_active": variant.is_active
+ }
+
+ # Добавляем информацию о размере, если она доступна
+ if variant.size:
+ variant_dict["size"] = {
+ "id": variant.size.id,
+ "name": variant.size.name,
+ "code": variant.size.code
+ }
+
+ product_dict["variants"].append(variant_dict)
+
+ # Добавляем изображения продукта
+ for image in images:
+ image_dict = {
+ "id": image.id,
+ "image_url": image.image_url,
+ "alt_text": image.alt_text,
+ "is_primary": image.is_primary
+ }
+ product_dict["images"].append(image_dict)
+
+ # Добавляем основное изображение в корень продукта для удобства
+ if image.is_primary:
+ product_dict["primary_image"] = image.image_url
+
+ return product_dict
+
+
+def format_category_for_meilisearch(category):
+ """
+ Форматирует категорию для индексации в Meilisearch.
+ """
+ return {
+ "id": category.id,
+ "name": category.name,
+ "slug": category.slug,
+ "description": category.description,
+ "parent_id": category.parent_id,
+ "is_active": category.is_active,
+ "created_at": category.created_at.isoformat() if category.created_at else None,
+ "updated_at": category.updated_at.isoformat() if category.updated_at else None
+ }
+
+
+def format_collection_for_meilisearch(collection):
+ """
+ Форматирует коллекцию для индексации в Meilisearch.
+ """
+ return {
+ "id": collection.id,
+ "name": collection.name,
+ "slug": collection.slug,
+ "description": collection.description,
+ "is_active": collection.is_active,
+ "created_at": collection.created_at.isoformat() if collection.created_at else None,
+ "updated_at": collection.updated_at.isoformat() if collection.updated_at else None
+ }
+
+
+def format_size_for_meilisearch(size):
+ """
+ Форматирует размер для индексации в Meilisearch.
+ """
+ return {
+ "id": size.id,
+ "name": size.name,
+ "code": size.code,
+ "description": size.description,
+ "created_at": size.created_at.isoformat() if size.created_at else None,
+ "updated_at": size.updated_at.isoformat() if size.updated_at else None
+ }
+
+
+def sync_products(db: Session):
+ """
+ Синхронизирует все продукты с Meilisearch.
+ """
+ logger.info("Syncing products...")
+
+ # Получаем все продукты из базы данных
+ products = db.query(Product).all()
+
+ # Форматируем продукты для Meilisearch
+ products_data = []
+ for product in products:
+ variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
+ images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
+ product_data = format_product_for_meilisearch(product, variants, images)
+ products_data.append(product_data)
+
+ # Синхронизируем продукты с Meilisearch
+ success = meilisearch_service.sync_all_products(products_data)
+
+ if success:
+ logger.info(f"Successfully synced {len(products_data)} products")
+ else:
+ logger.error("Failed to sync products")
+
+ return success
+
+
+def sync_categories(db: Session):
+ """
+ Синхронизирует все категории с Meilisearch.
+ """
+ logger.info("Syncing categories...")
+
+ # Получаем все категории из базы данных
+ categories = db.query(Category).all()
+
+ # Форматируем категории для Meilisearch
+ categories_data = [format_category_for_meilisearch(category) for category in categories]
+
+ # Синхронизируем категории с Meilisearch
+ success = meilisearch_service.sync_all_categories(categories_data)
+
+ if success:
+ logger.info(f"Successfully synced {len(categories_data)} categories")
+ else:
+ logger.error("Failed to sync categories")
+
+ return success
+
+
+def sync_collections(db: Session):
+ """
+ Синхронизирует все коллекции с Meilisearch.
+ """
+ logger.info("Syncing collections...")
+
+ # Получаем все коллекции из базы данных
+ collections = db.query(Collection).all()
+
+ # Форматируем коллекции для Meilisearch
+ collections_data = [format_collection_for_meilisearch(collection) for collection in collections]
+
+ # Синхронизируем коллекции с Meilisearch
+ success = meilisearch_service.sync_all_collections(collections_data)
+
+ if success:
+ logger.info(f"Successfully synced {len(collections_data)} collections")
+ else:
+ logger.error("Failed to sync collections")
+
+ return success
+
+
+def sync_sizes(db: Session):
+ """
+ Синхронизирует все размеры с Meilisearch.
+ """
+ logger.info("Syncing sizes...")
+
+ # Получаем все размеры из базы данных
+ sizes = db.query(Size).all()
+
+ # Форматируем размеры для Meilisearch
+ sizes_data = [format_size_for_meilisearch(size) for size in sizes]
+
+ # Синхронизируем размеры с Meilisearch
+ success = meilisearch_service.sync_all_sizes(sizes_data)
+
+ if success:
+ logger.info(f"Successfully synced {len(sizes_data)} sizes")
+ else:
+ logger.error("Failed to sync sizes")
+
+ return success
+
+
+def main():
+ """
+ Основная функция для синхронизации всех данных с Meilisearch.
+ """
+ logger.info("Starting Meilisearch synchronization...")
+
+ # Инициализируем индексы в Meilisearch
+ meilisearch_service.initialize_indexes()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ # Синхронизируем все данные
+ sync_categories(db)
+ sync_collections(db)
+ sync_sizes(db)
+ sync_products(db)
+
+ logger.info("Meilisearch synchronization completed successfully")
+ except Exception as e:
+ logger.error(f"Error during Meilisearch synchronization: {str(e)}")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index c98de76..6d2ce0a 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -11,7 +11,7 @@ from app.services.catalog_service import (
upload_product_image, update_product_image, delete_product_image,
create_collection, update_collection, delete_collection, get_collections,
get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size,
- create_product_complete, update_product_complete
+ create_product_complete, update_product_complete, format_product
)
from app.services.order_service import (
@@ -30,5 +30,6 @@ from app.services.content_service import (
from app.services.delivery_service import get_cdek_service
+
# Импорт репозиториев для прямого доступа
-from app.repositories import order_repo
\ No newline at end of file
+from app.repositories import order_repo
\ No newline at end of file
diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc
index 21cec70..841da53 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__/async_catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/async_catalog_service.cpython-310.pyc
new file mode 100644
index 0000000..1bd39e6
Binary files /dev/null and b/backend/app/services/__pycache__/async_catalog_service.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 f134e57..3118b6a 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__/meilisearch_service.cpython-310.pyc b/backend/app/services/__pycache__/meilisearch_service.cpython-310.pyc
new file mode 100644
index 0000000..6d5715c
Binary files /dev/null and b/backend/app/services/__pycache__/meilisearch_service.cpython-310.pyc differ
diff --git a/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc b/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc
new file mode 100644
index 0000000..f0f97d6
Binary files /dev/null and b/backend/app/services/__pycache__/product_cache_service.cpython-310.pyc differ
diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py
index b1b3ffb..c3f6360 100644
--- a/backend/app/services/catalog_service.py
+++ b/backend/app/services/catalog_service.py
@@ -10,6 +10,13 @@ from botocore.exceptions import ClientError
from app.config import settings
from app.repositories import catalog_repo, review_repo
+from app.services import meilisearch_service
+from app.scripts.sync_meilisearch import (
+ format_product_for_meilisearch,
+ format_category_for_meilisearch,
+ format_collection_for_meilisearch,
+ format_size_for_meilisearch
+)
from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories,
ProductCreate, ProductUpdate, Product, ProductWithDetails,
@@ -21,33 +28,76 @@ from app.schemas.catalog_schemas import (
)
+def format_product(product):
+ """
+ Форматирует продукт для отображения в API.
+ Преобразует объект SQLAlchemy в словарь с нужными полями.
+ """
+ from app.schemas.catalog_schemas import Product as ProductSchema
+
+ # Преобразуем объект SQLAlchemy в схему Pydantic
+ product_schema = ProductSchema.model_validate(product)
+
+ # Получаем основное изображение, если есть
+ primary_image = None
+ for image in product.images:
+ if image.is_primary:
+ primary_image = image.image_url
+ break
+
+ # Если нет основного изображения, но есть другие изображения, берем первое
+ if primary_image is None and product.images:
+ primary_image = product.images[0].image_url
+
+ # Создаем словарь с данными продукта
+ result = product_schema.model_dump()
+ result["primary_image"] = primary_image
+
+ return result
+
+
# Сервисы для коллекций
def create_collection(db: Session, collection: CollectionCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
-
+
new_collection = catalog_repo.create_collection(db, collection)
# Преобразуем объект SQLAlchemy в схему Pydantic
collection_schema = CollectionSchema.model_validate(new_collection)
+
+ # Индексируем коллекцию в Meilisearch
+ collection_data = format_collection_for_meilisearch(new_collection)
+ meilisearch_service.index_collection(collection_data)
+
return {"collection": collection_schema}
def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
-
+
updated_collection = catalog_repo.update_collection(db, collection_id, collection)
# Преобразуем объект SQLAlchemy в схему Pydantic
collection_schema = CollectionSchema.model_validate(updated_collection)
+
+ # Обновляем коллекцию в Meilisearch
+ collection_data = format_collection_for_meilisearch(updated_collection)
+ meilisearch_service.index_collection(collection_data)
+
return {"collection": collection_schema}
def delete_collection(db: Session, collection_id: int) -> Dict[str, Any]:
success = catalog_repo.delete_collection(db, collection_id)
+
+ # Удаляем коллекцию из Meilisearch
+ if success:
+ meilisearch_service.delete_collection(collection_id)
+
return {"success": success}
def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
-
+
collections = catalog_repo.get_collections(db, skip, limit)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
collections_schema = [CollectionSchema.model_validate(collection) for collection in collections]
@@ -57,24 +107,39 @@ def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, A
# Сервисы каталога
def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Category as CategorySchema
-
+
new_category = catalog_repo.create_category(db, category)
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(new_category)
+
+ # Индексируем категорию в Meilisearch
+ category_data = format_category_for_meilisearch(new_category)
+ meilisearch_service.index_category(category_data)
+
return {"category": category_schema}
def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Category as CategorySchema
-
+
updated_category = catalog_repo.update_category(db, category_id, category)
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(updated_category)
+
+ # Обновляем категорию в Meilisearch
+ category_data = format_category_for_meilisearch(updated_category)
+ meilisearch_service.index_category(category_data)
+
return {"category": category_schema}
def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
success = catalog_repo.delete_category(db, category_id)
+
+ # Удаляем категорию из Meilisearch
+ if success:
+ meilisearch_service.delete_category(category_id)
+
return {"success": success}
@@ -82,10 +147,10 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]:
from app.schemas.catalog_schemas import Category as CategorySchema
from sqlalchemy import func
from app.models.catalog_models import Product
-
+
# Получаем все категории верхнего уровня
root_categories = catalog_repo.get_categories(db, parent_id=None)
-
+
result = []
for category in root_categories:
# Преобразуем объект SQLAlchemy в схему Pydantic
@@ -95,14 +160,14 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]:
Product.category_id == category.id,
Product.is_active == True
).scalar() or 0
-
+
# Рекурсивно получаем подкатегории
category_dict = category_schema.model_dump()
subcategories, subcategories_products_count = _get_subcategories(db, category.id)
category_dict["subcategories"] = subcategories
category_dict["products_count"] = products_count + subcategories_products_count
result.append(category_dict)
-
+
return result
@@ -110,33 +175,33 @@ def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any]
from app.schemas.catalog_schemas import Category as CategorySchema
from sqlalchemy import func
from app.models.catalog_models import Product
-
+
subcategories = catalog_repo.get_categories(db, parent_id=parent_id)
-
+
result = []
total_products_count = 0
-
+
for category in subcategories:
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(category)
-
+
# Получаем количество продуктов в категории
products_count = db.query(func.count(Product.id)).filter(
Product.category_id == category.id,
Product.is_active == True
).scalar() or 0
-
+
category_dict = category_schema.model_dump()
sub_subcategories, sub_products_count = _get_subcategories(db, category.id)
-
+
total_products_in_category = products_count + sub_products_count
total_products_count += total_products_in_category
-
+
category_dict["subcategories"] = sub_subcategories
category_dict["products_count"] = total_products_in_category
-
+
result.append(category_dict)
-
+
return result, total_products_count
@@ -150,7 +215,7 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
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)
@@ -159,13 +224,19 @@ def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product.collection_id} не найдена"
)
-
+
# Создаем продукт
db_product = catalog_repo.create_product(db, product)
-
+
+ # Индексируем продукт в Meilisearch
+ variants = db_product.variants
+ images = db_product.images
+ product_data = format_product_for_meilisearch(db_product, variants, images)
+ meilisearch_service.index_product(product_data)
+
return {
"success": True,
- "product": Product.from_orm(db_product)
+ "product": Product.model_validate(db_product)
}
except HTTPException as e:
return {
@@ -189,7 +260,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
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)
@@ -198,7 +269,7 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Категория с ID {product.category_id} не найдена"
)
-
+
# Если меняется коллекция, проверяем, что она существует
if product.collection_id is not None:
if product.collection_id:
@@ -208,13 +279,19 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product.collection_id} не найдена"
)
-
+
# Обновляем продукт
updated_product = catalog_repo.update_product(db, product_id, product)
-
+
+ # Обновляем продукт в Meilisearch
+ variants = updated_product.variants
+ images = updated_product.images
+ product_data = format_product_for_meilisearch(updated_product, variants, images)
+ meilisearch_service.index_product(product_data)
+
return {
"success": True,
- "product": Product.from_orm(updated_product)
+ "product": Product.model_validate(updated_product)
}
except HTTPException as e:
return {
@@ -229,13 +306,52 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
- success = catalog_repo.delete_product(db, product_id)
- return {"success": success}
+ """Удалить продукт"""
+ 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} не найден"
+ )
+
+ # Удаляем продукт
+ success = catalog_repo.delete_product(db, product_id)
+
+ # Удаляем продукт из Meilisearch
+ if success:
+ meilisearch_service.delete_product(product_id)
+
+ return {
+ "success": success
+ }
+ except HTTPException as e:
+ return {
+ "success": False,
+ "error": e.detail
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": str(e)
+ }
def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
"""Получить детальную информацию о продукте"""
try:
+ # Пробуем получить продукт из Meilisearch
+ meilisearch_result = meilisearch_service.get_product(product_id)
+
+ if meilisearch_result["success"]:
+ # Если продукт найден в Meilisearch, возвращаем его
+ return {
+ "success": True,
+ "product": meilisearch_result["product"]
+ }
+
+ # Если продукт не найден в Meilisearch, получаем его из базы данных
# Получаем продукт
product = catalog_repo.get_product(db, product_id)
if not product:
@@ -243,21 +359,21 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
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,
@@ -272,12 +388,16 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
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]
+ category=Category.model_validate(category),
+ collection=Collection.model_validate(collection) if collection else None,
+ variants=[ProductVariant.model_validate(variant) for variant in variants],
+ images=[ProductImage.model_validate(image) for image in images]
)
-
+
+ # Индексируем продукт в Meilisearch для будущих запросов
+ product_data = format_product_for_meilisearch(product, variants, images)
+ meilisearch_service.index_product(product_data)
+
return {
"success": True,
"product": product_details
@@ -304,7 +424,7 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str,
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:
@@ -312,10 +432,10 @@ def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str,
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)
@@ -342,7 +462,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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)
@@ -351,7 +471,7 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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)
@@ -360,10 +480,10 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
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)
@@ -386,25 +506,25 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
def upload_product_image(
- db: Session,
- product_id: int,
- file: UploadFile,
- is_primary: bool = False,
+ db: Session,
+ product_id: int,
+ file: UploadFile,
+ is_primary: bool = False,
alt_text: str = ""
) -> dict:
"""
Загружает изображение для продукта в MinIO и создает запись в базе данных.
-
+
Args:
db: Сессия базы данных
product_id: ID продукта
file: Загружаемый файл
is_primary: Является ли изображение основным
alt_text: Альтернативный текст для изображения
-
+
Returns:
dict: Словарь с данными созданного изображения (image_url содержит ключ объекта)
-
+
Raises:
HTTPException: В случае ошибки при загрузке или сохранении изображения
"""
@@ -412,36 +532,36 @@ def upload_product_image(
secure_filename = ""
try:
logging.info(f"Попытка загрузки изображения для продукта с id={product_id} в MinIO")
-
+
if file is None:
error_msg = "Файл не предоставлен"
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
-
+
if not file.filename:
error_msg = "У файла отсутствует имя"
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
-
+
logging.info(f"Получен файл: {file.filename}, content_type: {file.content_type}")
-
+
from app.models.catalog_models import Product as ProductModel, ProductImage as ProductImageModel
-
+
product = db.query(ProductModel).filter(ProductModel.id == product_id).first()
if not product:
error_msg = f"Продукт с ID {product_id} не найден"
logging.error(error_msg)
raise HTTPException(status_code=404, detail=error_msg)
-
+
file_ext = os.path.splitext(file.filename)[1].lower() if file.filename else ""
if file_ext.lstrip('.') not in settings.ALLOWED_UPLOAD_EXTENSIONS:
error_msg = f"Расширение файла {file_ext} не разрешено."
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
-
+
secure_filename = f"{uuid.uuid4()}{file_ext}"
logging.info(f"Генерация ключа объекта MinIO: {secure_filename}")
-
+
try:
s3_client = boto3.client(
's3',
@@ -457,7 +577,7 @@ def upload_product_image(
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail="Ошибка конфигурации хранилища")
-
+
try:
s3_client.upload_fileobj(
file.file,
@@ -476,17 +596,17 @@ def upload_product_image(
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail="Ошибка при сохранении файла в хранилище")
-
+
image_object_key = secure_filename
logging.info(f"Ключ объекта для сохранения в БД: {image_object_key}")
-
+
if is_primary:
logging.info("Обновление флагов для других изображений (is_primary=False)")
db.query(ProductImageModel).filter(
ProductImageModel.product_id == product_id,
ProductImageModel.is_primary == True
).update({"is_primary": False})
-
+
logging.info("Создание записи изображения в базе данных")
new_image = ProductImageModel(
product_id=product_id,
@@ -494,7 +614,7 @@ def upload_product_image(
alt_text=alt_text,
is_primary=is_primary
)
-
+
try:
db.add(new_image)
db.commit()
@@ -505,20 +625,20 @@ def upload_product_image(
error_msg = f"Ошибка при сохранении записи в базу данных: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
-
+
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за ошибки БД.")
try:
if s3_client and secure_filename:
s3_client.delete_object(
- Bucket=settings.MINIO_BUCKET_NAME,
+ Bucket=settings.MINIO_BUCKET_NAME,
Key=secure_filename
)
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
except Exception as delete_err:
logging.error(f"Не удалось удалить объект '{secure_filename}' из MinIO после ошибки БД: {str(delete_err)}")
-
+
raise HTTPException(status_code=500, detail="Ошибка сохранения данных изображения")
-
+
result = {
"id": new_image.id,
"product_id": new_image.product_id,
@@ -528,10 +648,10 @@ def upload_product_image(
"created_at": new_image.created_at.isoformat() if new_image.created_at else None,
"updated_at": new_image.updated_at.isoformat() if new_image.updated_at else None
}
-
+
logging.info(f"Возвращается результат: {result}")
return result
-
+
except HTTPException as http_error:
raise http_error
except Exception as e:
@@ -542,7 +662,7 @@ def upload_product_image(
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за неожиданной ошибки.")
try:
s3_client.delete_object(
- Bucket=settings.MINIO_BUCKET_NAME,
+ Bucket=settings.MINIO_BUCKET_NAME,
Key=secure_filename
)
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
@@ -554,7 +674,7 @@ def upload_product_image(
def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
from app.models.catalog_models import ProductImage as ProductImageModel
-
+
try:
# Получаем существующее изображение
db_image = catalog_repo.get_product_image(db, image_id)
@@ -563,7 +683,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение продукта не найдено"
)
-
+
# Если изображение отмечается как основное, сбрасываем флаг у других изображений
if image.is_primary and not db_image.is_primary:
db.query(ProductImageModel).filter(
@@ -571,16 +691,16 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
ProductImageModel.is_primary == True
).update({"is_primary": False})
db.flush()
-
+
# Обновляем поля
if image.alt_text is not None:
db_image.alt_text = image.alt_text
if image.is_primary is not None:
db_image.is_primary = image.is_primary
-
+
db.commit()
db.refresh(db_image)
-
+
# Создаем словарь для ответа вместо использования from_orm
image_dict = {
"id": db_image.id,
@@ -591,7 +711,7 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
"created_at": db_image.created_at,
"updated_at": db_image.updated_at
}
-
+
return {
"success": True,
"image": image_dict
@@ -612,10 +732,10 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
from app.models.catalog_models import ProductImage as ProductImageModel
-
+
image_key_to_delete = None # Сохраняем ключ для удаления из MinIO
s3_client = None
-
+
try:
# Получаем информацию об изображении перед удалением
db_image = catalog_repo.get_product_image(db, image_id)
@@ -624,13 +744,13 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение не найдено"
)
-
+
image_key_to_delete = db_image.image_url # Получаем ключ объекта MinIO из БД
logging.info(f"Получен ключ объекта для удаления из MinIO: {image_key_to_delete}")
# Удаляем запись из БД
success = catalog_repo.delete_product_image(db, image_id)
-
+
# Если запись из БД удалена успешно, удаляем объект из MinIO
if success and image_key_to_delete:
logging.info(f"Запись из БД удалена, попытка удаления объекта '{image_key_to_delete}' из MinIO.")
@@ -644,9 +764,9 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
use_ssl=settings.MINIO_USE_SSL,
config=boto3.session.Config(signature_version='s3v4')
)
-
+
s3_client.delete_object(
- Bucket=settings.MINIO_BUCKET_NAME,
+ Bucket=settings.MINIO_BUCKET_NAME,
Key=image_key_to_delete
)
logging.info(f"Объект '{image_key_to_delete}' успешно удален из MinIO бакета '{settings.MINIO_BUCKET_NAME}'.")
@@ -706,15 +826,33 @@ def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]:
def create_size(db: Session, size: SizeCreate) -> Size:
- return Size.from_orm(catalog_repo.create_size(db, size))
+ new_size = catalog_repo.create_size(db, size)
+
+ # Индексируем размер в Meilisearch
+ size_data = format_size_for_meilisearch(new_size)
+ meilisearch_service.index_size(size_data)
+
+ return Size.model_validate(new_size)
def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
- return Size.from_orm(catalog_repo.update_size(db, size_id, size))
+ updated_size = catalog_repo.update_size(db, size_id, size)
+
+ # Обновляем размер в Meilisearch
+ size_data = format_size_for_meilisearch(updated_size)
+ meilisearch_service.index_size(size_data)
+
+ return Size.model_validate(updated_size)
def delete_size(db: Session, size_id: int) -> bool:
- return catalog_repo.delete_size(db, size_id)
+ success = catalog_repo.delete_size(db, size_id)
+
+ # Удаляем размер из Meilisearch
+ if success:
+ meilisearch_service.delete_size(size_id)
+
+ return success
def create_product_complete(db: Session, product_data: ProductCreateComplete) -> Dict[str, Any]:
@@ -724,7 +862,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
try:
# Откатываем любую существующую транзакцию и начинаем новую
db.rollback()
-
+
# Проверяем наличие категории
category = catalog_repo.get_category(db, product_data.category_id)
if not category:
@@ -732,7 +870,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Категория с ID {product_data.category_id} не найдена"
)
-
+
# Проверяем наличие коллекции, если она указана
if product_data.collection_id:
collection = catalog_repo.get_collection(db, product_data.collection_id)
@@ -741,7 +879,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product_data.collection_id} не найдена"
)
-
+
# 1. Создаем базовую информацию о продукте используя репозиторий
product_create = ProductCreate(
name=product_data.name,
@@ -754,10 +892,10 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
category_id=product_data.category_id,
collection_id=product_data.collection_id
)
-
+
# Создаем продукт через репозиторий
db_product = catalog_repo.create_product(db, product_create)
-
+
# 2. Создаем варианты продукта
variants = []
if product_data.variants:
@@ -769,7 +907,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {variant_data.size_id} не найден"
)
-
+
# Создаем вариант продукта через репозиторий
variant_create = ProductVariantCreate(
product_id=db_product.id,
@@ -780,13 +918,13 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
)
db_variant = catalog_repo.create_product_variant(db, variant_create)
variants.append(db_variant)
-
+
# 3. Создаем изображения продукта
images = []
if product_data.images:
# Отслеживаем, было ли уже отмечено изображение как основное
had_primary = False
-
+
for image_data in product_data.images:
# Определяем, должно ли изображение быть основным
is_primary = image_data.is_primary
@@ -799,7 +937,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
# Если это первое изображение и нет основного, делаем его основным
is_primary = True
had_primary = True
-
+
# Создаем запись об изображении через репозиторий
image_create = ProductImageCreate(
product_id=db_product.id,
@@ -809,7 +947,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
)
db_image = catalog_repo.create_product_image(db, image_create)
images.append(db_image)
-
+
# Создаем словарь для ответа с прямым извлечением данных из ORM-объектов
product_dict = {
"id": db_product.id,
@@ -825,7 +963,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"created_at": db_product.created_at,
"updated_at": db_product.updated_at
}
-
+
# Создаем списки вариантов и изображений
variants_list = []
for variant in variants:
@@ -839,7 +977,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"created_at": variant.created_at,
"updated_at": variant.updated_at
})
-
+
images_list = []
for image in images:
images_list.append({
@@ -851,7 +989,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"created_at": image.created_at,
"updated_at": image.updated_at
})
-
+
return {
"success": True,
"id": db_product.id,
@@ -859,7 +997,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"variants": variants_list,
"images": images_list
}
-
+
except HTTPException as e:
db.rollback()
return {
@@ -872,7 +1010,7 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
"success": False,
"error": str(e)
}
-
+
def update_product_complete(db: Session, product_id: int, product_data: ProductUpdateComplete) -> Dict[str, Any]:
"""
@@ -881,7 +1019,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
try:
# Откатываем любую существующую транзакцию и начинаем новую
db.rollback()
-
+
# Проверяем, что продукт существует
db_product = catalog_repo.get_product(db, product_id)
if not db_product:
@@ -889,7 +1027,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Продукт с ID {product_id} не найден"
)
-
+
# Если меняется категория, проверяем, что она существует
if product_data.category_id is not None:
category = catalog_repo.get_category(db, product_data.category_id)
@@ -898,7 +1036,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Категория с ID {product_data.category_id} не найдена"
)
-
+
# Если меняется коллекция, проверяем, что она существует
if product_data.collection_id is not None:
if product_data.collection_id > 0:
@@ -908,7 +1046,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Коллекция с ID {product_data.collection_id} не найдена"
)
-
+
# 1. Обновляем базовую информацию о продукте
product_update = ProductUpdate(
name=product_data.name,
@@ -922,11 +1060,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
collection_id=product_data.collection_id
)
db_product = catalog_repo.update_product(db, product_id, product_update)
-
+
# 2. Обрабатываем варианты продукта
variants_updated = []
variants_created = []
-
+
if product_data.variants:
for variant_data in product_data.variants:
# Проверяем наличие размера
@@ -937,7 +1075,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Размер с ID {variant_data.size_id} не найден"
)
-
+
# Если есть ID, обновляем существующий вариант
if variant_data.id:
variant_update = ProductVariantUpdate(
@@ -960,7 +1098,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
)
db_variant = catalog_repo.create_product_variant(db, variant_create)
variants_created.append(db_variant)
-
+
# 3. Удаляем указанные варианты
variants_removed = []
if product_data.variants_to_remove:
@@ -971,11 +1109,11 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
success = catalog_repo.delete_product_variant(db, variant_id)
if success:
variants_removed.append(variant_id)
-
+
# 4. Обрабатываем изображения продукта
images_updated = []
images_created = []
-
+
if product_data.images:
for image_data in product_data.images:
# Если есть ID, обновляем существующее изображение
@@ -997,7 +1135,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
)
db_image = catalog_repo.create_product_image(db, image_create)
images_created.append(db_image)
-
+
# 5. Удаляем указанные изображения
images_removed = []
if product_data.images_to_remove:
@@ -1008,13 +1146,13 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
success = catalog_repo.delete_product_image(db, image_id)
if success:
images_removed.append(image_id)
-
+
# Коммитим транзакцию
db.commit()
-
+
# Собираем полный ответ
product_schema = Product.from_orm(db_product)
-
+
return {
"success": True,
"product": product_schema,
@@ -1029,7 +1167,7 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
"removed": images_removed
}
}
-
+
except HTTPException as e:
db.rollback()
return {
@@ -1041,4 +1179,4 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
return {
"success": False,
"error": str(e)
- }
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/backend/app/services/meilisearch_service.py b/backend/app/services/meilisearch_service.py
new file mode 100644
index 0000000..84729b2
--- /dev/null
+++ b/backend/app/services/meilisearch_service.py
@@ -0,0 +1,649 @@
+from meilisearch import Client
+from typing import Dict, List, Any, Optional, Union
+import logging
+from app.config import settings
+
+# Инициализация клиента Meilisearch
+client = Client(settings.MEILISEARCH_URL, settings.MEILISEARCH_KEY)
+
+# Определение индексов
+PRODUCT_INDEX = "products"
+CATEGORY_INDEX = "categories"
+COLLECTION_INDEX = "collections"
+SIZE_INDEX = "sizes"
+
+# Настройка логгера
+logger = logging.getLogger(__name__)
+
+
+def initialize_indexes():
+ """
+ Инициализирует индексы в Meilisearch, если они не существуют.
+ Настраивает фильтруемые и сортируемые атрибуты.
+ """
+ try:
+ # Создаем индекс продуктов, если он не существует
+ if PRODUCT_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
+ client.create_index(PRODUCT_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем фильтруемые атрибуты для продуктов
+ client.index(PRODUCT_INDEX).update_filterable_attributes([
+ "category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
+ ])
+
+ # Настраиваем сортируемые атрибуты для продуктов
+ client.index(PRODUCT_INDEX).update_sortable_attributes([
+ "price", "discount_price", "created_at", "name"
+ ])
+
+ # Настраиваем поисковые атрибуты для продуктов
+ client.index(PRODUCT_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Создаем индекс категорий, если он не существует
+ if CATEGORY_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
+ client.create_index(CATEGORY_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем фильтруемые атрибуты для категорий
+ client.index(CATEGORY_INDEX).update_filterable_attributes([
+ "parent_id", "is_active", "id", "slug"
+ ])
+
+ # Настраиваем поисковые атрибуты для категорий
+ client.index(CATEGORY_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Создаем индекс коллекций, если он не существует
+ if COLLECTION_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
+ client.create_index(COLLECTION_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем фильтруемые атрибуты для коллекций
+ client.index(COLLECTION_INDEX).update_filterable_attributes([
+ "is_active"
+ ])
+
+ # Настраиваем поисковые атрибуты для коллекций
+ client.index(COLLECTION_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Создаем индекс размеров, если он не существует
+ if SIZE_INDEX not in [index["uid"] for index in client.get_indexes()["results"]]:
+ client.create_index(SIZE_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем поисковые атрибуты для размеров
+ client.index(SIZE_INDEX).update_searchable_attributes([
+ "name", "code", "description"
+ ])
+
+ logger.info("Meilisearch indexes initialized successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error initializing Meilisearch indexes: {str(e)}")
+ return False
+
+
+def index_product(product_data: Dict[str, Any]) -> bool:
+ """
+ Индексирует продукт в Meilisearch.
+
+ Args:
+ product_data: Данные продукта для индексации
+
+ Returns:
+ bool: True, если индексация прошла успешно, иначе False
+ """
+ try:
+ client.index(PRODUCT_INDEX).add_documents([product_data])
+ logger.info(f"Product {product_data.get('id')} indexed successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error indexing product {product_data.get('id')}: {str(e)}")
+ return False
+
+
+def index_category(category_data: Dict[str, Any]) -> bool:
+ """
+ Индексирует категорию в Meilisearch.
+
+ Args:
+ category_data: Данные категории для индексации
+
+ Returns:
+ bool: True, если индексация прошла успешно, иначе False
+ """
+ try:
+ client.index(CATEGORY_INDEX).add_documents([category_data])
+ logger.info(f"Category {category_data.get('id')} indexed successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error indexing category {category_data.get('id')}: {str(e)}")
+ return False
+
+
+def index_collection(collection_data: Dict[str, Any]) -> bool:
+ """
+ Индексирует коллекцию в Meilisearch.
+
+ Args:
+ collection_data: Данные коллекции для индексации
+
+ Returns:
+ bool: True, если индексация прошла успешно, иначе False
+ """
+ try:
+ client.index(COLLECTION_INDEX).add_documents([collection_data])
+ logger.info(f"Collection {collection_data.get('id')} indexed successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error indexing collection {collection_data.get('id')}: {str(e)}")
+ return False
+
+
+def index_size(size_data: Dict[str, Any]) -> bool:
+ """
+ Индексирует размер в Meilisearch.
+
+ Args:
+ size_data: Данные размера для индексации
+
+ Returns:
+ bool: True, если индексация прошла успешно, иначе False
+ """
+ try:
+ client.index(SIZE_INDEX).add_documents([size_data])
+ logger.info(f"Size {size_data.get('id')} indexed successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error indexing size {size_data.get('id')}: {str(e)}")
+ return False
+
+
+def delete_product(product_id: int) -> bool:
+ """
+ Удаляет продукт из индекса Meilisearch.
+
+ Args:
+ product_id: ID продукта для удаления
+
+ Returns:
+ bool: True, если удаление прошло успешно, иначе False
+ """
+ try:
+ client.index(PRODUCT_INDEX).delete_document(product_id)
+ logger.info(f"Product {product_id} deleted from index successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting product {product_id} from index: {str(e)}")
+ return False
+
+
+def delete_category(category_id: int) -> bool:
+ """
+ Удаляет категорию из индекса Meilisearch.
+
+ Args:
+ category_id: ID категории для удаления
+
+ Returns:
+ bool: True, если удаление прошло успешно, иначе False
+ """
+ try:
+ client.index(CATEGORY_INDEX).delete_document(category_id)
+ logger.info(f"Category {category_id} deleted from index successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting category {category_id} from index: {str(e)}")
+ return False
+
+
+def delete_collection(collection_id: int) -> bool:
+ """
+ Удаляет коллекцию из индекса Meilisearch.
+
+ Args:
+ collection_id: ID коллекции для удаления
+
+ Returns:
+ bool: True, если удаление прошло успешно, иначе False
+ """
+ try:
+ client.index(COLLECTION_INDEX).delete_document(collection_id)
+ logger.info(f"Collection {collection_id} deleted from index successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting collection {collection_id} from index: {str(e)}")
+ return False
+
+
+def delete_size(size_id: int) -> bool:
+ """
+ Удаляет размер из индекса Meilisearch.
+
+ Args:
+ size_id: ID размера для удаления
+
+ Returns:
+ bool: True, если удаление прошло успешно, иначе False
+ """
+ try:
+ client.index(SIZE_INDEX).delete_document(size_id)
+ logger.info(f"Size {size_id} deleted from index successfully")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting size {size_id} from index: {str(e)}")
+ return False
+
+
+def search_products(
+ query: str = "",
+ filters: Optional[str] = None,
+ sort: Optional[List[str]] = None,
+ limit: int = 20,
+ offset: int = 0
+) -> Dict[str, Any]:
+ """
+ Поиск продуктов в Meilisearch.
+
+ Args:
+ query: Поисковый запрос
+ filters: Фильтры в формате Meilisearch (например, "category_id = 1 AND price < 1000")
+ sort: Список полей для сортировки (например, ["price:asc"])
+ limit: Максимальное количество результатов
+ offset: Смещение для пагинации
+
+ Returns:
+ Dict[str, Any]: Результаты поиска
+ """
+ try:
+ search_params = {
+ "limit": limit,
+ "offset": offset
+ }
+
+ if filters:
+ search_params["filter"] = filters
+
+ if sort:
+ search_params["sort"] = sort
+
+ results = client.index(PRODUCT_INDEX).search(query, search_params)
+ return {
+ "success": True,
+ "products": results["hits"],
+ "total": results["estimatedTotalHits"],
+ "limit": limit,
+ "offset": offset
+ }
+ except Exception as e:
+ logger.error(f"Error searching products: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "products": [],
+ "total": 0,
+ "limit": limit,
+ "offset": offset
+ }
+
+
+def search_categories(
+ query: str = "",
+ filters: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0
+) -> Dict[str, Any]:
+ """
+ Поиск категорий в Meilisearch.
+
+ Args:
+ query: Поисковый запрос
+ filters: Фильтры в формате Meilisearch (например, "parent_id = 1")
+ limit: Максимальное количество результатов
+ offset: Смещение для пагинации
+
+ Returns:
+ Dict[str, Any]: Результаты поиска
+ """
+ try:
+ search_params = {
+ "limit": limit,
+ "offset": offset
+ }
+
+ if filters:
+ search_params["filter"] = filters
+
+ results = client.index(CATEGORY_INDEX).search(query, search_params)
+ return {
+ "success": True,
+ "categories": results["hits"],
+ "total": results["estimatedTotalHits"]
+ }
+ except Exception as e:
+ logger.error(f"Error searching categories: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "categories": [],
+ "total": 0
+ }
+
+
+def search_collections(
+ query: str = "",
+ filters: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0
+) -> Dict[str, Any]:
+ """
+ Поиск коллекций в Meilisearch.
+
+ Args:
+ query: Поисковый запрос
+ filters: Фильтры в формате Meilisearch (например, "is_active = true")
+ limit: Максимальное количество результатов
+ offset: Смещение для пагинации
+
+ Returns:
+ Dict[str, Any]: Результаты поиска
+ """
+ try:
+ search_params = {
+ "limit": limit,
+ "offset": offset
+ }
+
+ if filters:
+ search_params["filter"] = filters
+
+ results = client.index(COLLECTION_INDEX).search(query, search_params)
+ return {
+ "success": True,
+ "collections": results["hits"],
+ "total": results["estimatedTotalHits"]
+ }
+ except Exception as e:
+ logger.error(f"Error searching collections: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "collections": [],
+ "total": 0
+ }
+
+
+def search_sizes(
+ query: str = "",
+ filters: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0
+) -> Dict[str, Any]:
+ """
+ Поиск размеров в Meilisearch.
+
+ Args:
+ query: Поисковый запрос
+ filters: Фильтры в формате Meilisearch
+ limit: Максимальное количество результатов
+ offset: Смещение для пагинации
+
+ Returns:
+ Dict[str, Any]: Результаты поиска
+ """
+ try:
+ search_params = {
+ "limit": limit,
+ "offset": offset
+ }
+
+ if filters:
+ search_params["filter"] = filters
+
+ results = client.index(SIZE_INDEX).search(query, search_params)
+ return {
+ "success": True,
+ "sizes": results["hits"],
+ "total": results["estimatedTotalHits"]
+ }
+ except Exception as e:
+ logger.error(f"Error searching sizes: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "sizes": [],
+ "total": 0
+ }
+
+
+def get_product(product_id: int) -> Dict[str, Any]:
+ """
+ Получает продукт из Meilisearch по ID.
+
+ Args:
+ product_id: ID продукта
+
+ Returns:
+ Dict[str, Any]: Данные продукта или ошибка
+ """
+ try:
+ product = client.index(PRODUCT_INDEX).get_document(product_id)
+ # Преобразуем объект Document в словарь
+ product_dict = dict(product)
+ return {
+ "success": True,
+ "product": product_dict
+ }
+ except Exception as e:
+ logger.error(f"Error getting product {product_id}: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "product": None
+ }
+
+
+def get_product_by_slug(slug: str) -> Dict[str, Any]:
+ """
+ Получает продукт из Meilisearch по slug.
+
+ Args:
+ slug: Slug продукта
+
+ Returns:
+ Dict[str, Any]: Данные продукта или ошибка
+ """
+ try:
+ # Вместо фильтрации по slug, используем поиск по slug
+ results = client.index(PRODUCT_INDEX).search(slug, {
+ "attributesToSearchOn": ["slug"],
+ "limit": 1
+ })
+
+ if results["hits"]:
+ # Преобразуем объект Document в словарь
+ product_dict = dict(results["hits"][0])
+ return {
+ "success": True,
+ "product": product_dict
+ }
+ else:
+ return {
+ "success": False,
+ "error": f"Product with slug '{slug}' not found",
+ "product": None
+ }
+ except Exception as e:
+ logger.error(f"Error getting product by slug {slug}: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "product": None
+ }
+
+
+def get_category(category_id: int) -> Dict[str, Any]:
+ """
+ Получает категорию из Meilisearch по ID.
+
+ Args:
+ category_id: ID категории
+
+ Returns:
+ Dict[str, Any]: Данные категории или ошибка
+ """
+ try:
+ category = client.index(CATEGORY_INDEX).get_document(category_id)
+ # Преобразуем объект Document в словарь
+ category_dict = dict(category)
+ return {
+ "success": True,
+ "category": category_dict
+ }
+ except Exception as e:
+ logger.error(f"Error getting category {category_id}: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "category": None
+ }
+
+
+def get_collection(collection_id: int) -> Dict[str, Any]:
+ """
+ Получает коллекцию из Meilisearch по ID.
+
+ Args:
+ collection_id: ID коллекции
+
+ Returns:
+ Dict[str, Any]: Данные коллекции или ошибка
+ """
+ try:
+ collection = client.index(COLLECTION_INDEX).get_document(collection_id)
+ # Преобразуем объект Document в словарь
+ collection_dict = dict(collection)
+ return {
+ "success": True,
+ "collection": collection_dict
+ }
+ except Exception as e:
+ logger.error(f"Error getting collection {collection_id}: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "collection": None
+ }
+
+
+def get_size(size_id: int) -> Dict[str, Any]:
+ """
+ Получает размер из Meilisearch по ID.
+
+ Args:
+ size_id: ID размера
+
+ Returns:
+ Dict[str, Any]: Данные размера или ошибка
+ """
+ try:
+ size = client.index(SIZE_INDEX).get_document(size_id)
+ # Преобразуем объект Document в словарь
+ size_dict = dict(size)
+ return {
+ "success": True,
+ "size": size_dict
+ }
+ except Exception as e:
+ logger.error(f"Error getting size {size_id}: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e),
+ "size": None
+ }
+
+
+def sync_all_products(products_data: List[Dict[str, Any]]) -> bool:
+ """
+ Синхронизирует все продукты с Meilisearch.
+
+ Args:
+ products_data: Список данных продуктов для синхронизации
+
+ Returns:
+ bool: True, если синхронизация прошла успешно, иначе False
+ """
+ try:
+ client.index(PRODUCT_INDEX).delete_all_documents()
+ if products_data:
+ client.index(PRODUCT_INDEX).add_documents(products_data)
+ logger.info(f"All products synced successfully ({len(products_data)} products)")
+ return True
+ except Exception as e:
+ logger.error(f"Error syncing all products: {str(e)}")
+ return False
+
+
+def sync_all_categories(categories_data: List[Dict[str, Any]]) -> bool:
+ """
+ Синхронизирует все категории с Meilisearch.
+
+ Args:
+ categories_data: Список данных категорий для синхронизации
+
+ Returns:
+ bool: True, если синхронизация прошла успешно, иначе False
+ """
+ try:
+ client.index(CATEGORY_INDEX).delete_all_documents()
+ if categories_data:
+ client.index(CATEGORY_INDEX).add_documents(categories_data)
+ logger.info(f"All categories synced successfully ({len(categories_data)} categories)")
+ return True
+ except Exception as e:
+ logger.error(f"Error syncing all categories: {str(e)}")
+ return False
+
+
+def sync_all_collections(collections_data: List[Dict[str, Any]]) -> bool:
+ """
+ Синхронизирует все коллекции с Meilisearch.
+
+ Args:
+ collections_data: Список данных коллекций для синхронизации
+
+ Returns:
+ bool: True, если синхронизация прошла успешно, иначе False
+ """
+ try:
+ client.index(COLLECTION_INDEX).delete_all_documents()
+ if collections_data:
+ client.index(COLLECTION_INDEX).add_documents(collections_data)
+ logger.info(f"All collections synced successfully ({len(collections_data)} collections)")
+ return True
+ except Exception as e:
+ logger.error(f"Error syncing all collections: {str(e)}")
+ return False
+
+
+def sync_all_sizes(sizes_data: List[Dict[str, Any]]) -> bool:
+ """
+ Синхронизирует все размеры с Meilisearch.
+
+ Args:
+ sizes_data: Список данных размеров для синхронизации
+
+ Returns:
+ bool: True, если синхронизация прошла успешно, иначе False
+ """
+ try:
+ client.index(SIZE_INDEX).delete_all_documents()
+ if sizes_data:
+ client.index(SIZE_INDEX).add_documents(sizes_data)
+ logger.info(f"All sizes synced successfully ({len(sizes_data)} sizes)")
+ return True
+ except Exception as e:
+ logger.error(f"Error syncing all sizes: {str(e)}")
+ return False
diff --git a/backend/check_data.py b/backend/check_data.py
new file mode 100644
index 0000000..5c1aea8
--- /dev/null
+++ b/backend/check_data.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.core import SessionLocal
+from app.models.catalog_models import Product, Category, Collection, Size
+
+def main():
+ """
+ Скрипт для проверки данных в базе данных.
+ """
+ print("Проверка данных в базе данных...")
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ # Проверяем наличие категорий
+ categories = db.query(Category).all()
+ print(f"Категории: {len(categories)}")
+ for category in categories:
+ print(f" - {category.id}: {category.name} (slug: {category.slug})")
+
+ # Проверяем наличие коллекций
+ collections = db.query(Collection).all()
+ print(f"Коллекции: {len(collections)}")
+ for collection in collections:
+ print(f" - {collection.id}: {collection.name} (slug: {collection.slug})")
+
+ # Проверяем наличие размеров
+ sizes = db.query(Size).all()
+ print(f"Размеры: {len(sizes)}")
+ for size in sizes:
+ print(f" - {size.id}: {size.name} (code: {size.code})")
+
+ # Проверяем наличие продуктов
+ products = db.query(Product).all()
+ print(f"Продукты: {len(products)}")
+ for product in products[:5]: # Выводим только первые 5 продуктов
+ print(f" - {product.id}: {product.name} (slug: {product.slug})")
+
+ except Exception as e:
+ print(f"Ошибка при проверке данных: {str(e)}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/check_meilisearch.py b/backend/check_meilisearch.py
new file mode 100644
index 0000000..044a91c
--- /dev/null
+++ b/backend/check_meilisearch.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX
+
+def main():
+ """
+ Скрипт для проверки данных в Meilisearch.
+ """
+ print("Проверка данных в Meilisearch...")
+
+ try:
+ # Проверяем наличие индексов
+ try:
+ indexes = client.get_indexes()
+ print(f"Индексы: {len(indexes['results'])}")
+ for index in indexes["results"]:
+ print(f" - {index['uid']}: {index['primaryKey']}")
+ except Exception as e:
+ print(f"Ошибка при получении индексов: {str(e)}")
+
+ # Проверяем наличие категорий
+ try:
+ # Проверяем настройки индекса категорий
+ print("\nНастройки индекса категорий:")
+ filterable_attributes = client.index(CATEGORY_INDEX).get_filterable_attributes()
+ print(f" Фильтруемые атрибуты: {filterable_attributes}")
+
+ searchable_attributes = client.index(CATEGORY_INDEX).get_searchable_attributes()
+ print(f" Поисковые атрибуты: {searchable_attributes}")
+
+ # Получаем категории
+ categories = client.index(CATEGORY_INDEX).get_documents({"limit": 100})
+ print(f"\nКатегории: {len(categories['results'])}")
+ for category in categories["results"]:
+ print(f" - {category['id']}: {category['name']} (slug: {category['slug']})")
+
+ # Проверяем поиск категорий
+ print("\nПоиск категорий:")
+ search_result = client.index(CATEGORY_INDEX).search("", {"limit": 100})
+ print(f" Найдено категорий: {len(search_result['hits'])}")
+ print(f" Общее количество категорий: {search_result['estimatedTotalHits']}")
+ except Exception as e:
+ print(f"Ошибка при получении категорий: {str(e)}")
+
+ # Проверяем наличие коллекций
+ try:
+ collections = client.index(COLLECTION_INDEX).get_documents({"limit": 100})
+ print(f"Коллекции: {len(collections['results'])}")
+ for collection in collections["results"]:
+ print(f" - {collection['id']}: {collection['name']} (slug: {collection['slug']})")
+ except Exception as e:
+ print(f"Ошибка при получении коллекций: {str(e)}")
+
+ # Проверяем наличие размеров
+ try:
+ sizes = client.index(SIZE_INDEX).get_documents({"limit": 100})
+ print(f"Размеры: {len(sizes['results'])}")
+ for size in sizes["results"]:
+ print(f" - {size['id']}: {size['name']} (code: {size['code']})")
+ except Exception as e:
+ print(f"Ошибка при получении размеров: {str(e)}")
+
+ # Проверяем наличие продуктов
+ try:
+ products = client.index(PRODUCT_INDEX).get_documents({"limit": 5})
+ print(f"Продукты: {len(products['results'])}")
+ for product in products["results"]:
+ print(f" - {product['id']}: {product['name']} (slug: {product['slug']})")
+ except Exception as e:
+ print(f"Ошибка при получении продуктов: {str(e)}")
+
+ # Проверяем настройки индексов
+ try:
+ print("\nНастройки индекса продуктов:")
+ filterable_attributes = client.index(PRODUCT_INDEX).get_filterable_attributes()
+ print(f" Фильтруемые атрибуты: {filterable_attributes}")
+
+ searchable_attributes = client.index(PRODUCT_INDEX).get_searchable_attributes()
+ print(f" Поисковые атрибуты: {searchable_attributes}")
+
+ sortable_attributes = client.index(PRODUCT_INDEX).get_sortable_attributes()
+ print(f" Сортируемые атрибуты: {sortable_attributes}")
+ except Exception as e:
+ print(f"Ошибка при получении настроек индекса продуктов: {str(e)}")
+
+ except Exception as e:
+ print(f"Ошибка при проверке данных в Meilisearch: {str(e)}")
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 04caf44..d8a6dca 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -79,7 +79,7 @@ SQLAlchemy==2.0.25
starlette==0.35.1
taskiq==0.11.10
taskiq-dependencies==1.5.6
-taskiq-redis==1.0.2
+
tenacity==9.0.0
texttable==1.7.0
tqdm==4.67.1
@@ -104,5 +104,6 @@ bcrypt<4.0.0
python-multipart==0.0.6
email-validator==2.1.0
setuptools==75.8.0
-cachetools
boto3
+meilisearch==0.28.0
+
diff --git a/backend/sync_categories.py b/backend/sync_categories.py
new file mode 100644
index 0000000..a6e9b84
--- /dev/null
+++ b/backend/sync_categories.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.core import SessionLocal
+from app.scripts.sync_meilisearch import sync_categories
+from app.services.meilisearch_service import initialize_indexes
+
+def main():
+ """
+ Скрипт для принудительной синхронизации категорий с Meilisearch.
+ """
+ print("Принудительная синхронизация категорий с Meilisearch...")
+
+ # Инициализируем индексы в Meilisearch
+ initialize_indexes()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ # Синхронизируем категории с Meilisearch
+ success = sync_categories(db)
+
+ if success:
+ print("Категории успешно синхронизированы с Meilisearch")
+ else:
+ print("Ошибка при синхронизации категорий с Meilisearch")
+ except Exception as e:
+ print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/sync_categories_direct.py b/backend/sync_categories_direct.py
new file mode 100644
index 0000000..477ad8e
--- /dev/null
+++ b/backend/sync_categories_direct.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.core import SessionLocal
+from app.models.catalog_models import Category
+from app.services.meilisearch_service import client, CATEGORY_INDEX
+
+def format_category_for_meilisearch(category):
+ """
+ Форматирует категорию для индексации в Meilisearch.
+ """
+ return {
+ "id": category.id,
+ "name": category.name,
+ "slug": category.slug,
+ "description": category.description,
+ "parent_id": category.parent_id,
+ "is_active": category.is_active,
+ "created_at": category.created_at.isoformat() if category.created_at else None,
+ "updated_at": category.updated_at.isoformat() if category.updated_at else None
+ }
+
+def sync_categories(db):
+ """
+ Синхронизирует все категории с Meilisearch.
+ """
+ print("Синхронизация категорий...")
+
+ # Получаем все категории из базы данных
+ categories = db.query(Category).all()
+
+ # Форматируем категории для Meilisearch
+ categories_data = [format_category_for_meilisearch(category) for category in categories]
+
+ # Синхронизируем категории с Meilisearch
+ try:
+ # Удаляем все документы из индекса категорий
+ client.index(CATEGORY_INDEX).delete_all_documents()
+
+ # Добавляем новые документы
+ if categories_data:
+ client.index(CATEGORY_INDEX).add_documents(categories_data)
+
+ print(f"Успешно синхронизировано {len(categories_data)} категорий")
+ return True
+ except Exception as e:
+ print(f"Ошибка при синхронизации категорий: {str(e)}")
+ return False
+
+def initialize_category_index():
+ """
+ Инициализирует индекс категорий в Meilisearch.
+ """
+ try:
+ # Проверяем, существует ли индекс категорий
+ indexes = client.get_indexes()
+ index_exists = False
+
+ for index in indexes["results"]:
+ if index["uid"] == CATEGORY_INDEX:
+ index_exists = True
+ break
+
+ # Если индекс не существует, создаем его
+ if not index_exists:
+ client.create_index(CATEGORY_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем фильтруемые атрибуты для категорий
+ client.index(CATEGORY_INDEX).update_filterable_attributes([
+ "parent_id", "is_active", "id", "slug"
+ ])
+
+ # Настраиваем поисковые атрибуты для категорий
+ client.index(CATEGORY_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ print("Индекс категорий инициализирован успешно")
+ return True
+ except Exception as e:
+ print(f"Ошибка при инициализации индекса категорий: {str(e)}")
+ return False
+
+def main():
+ """
+ Скрипт для принудительной синхронизации категорий с Meilisearch.
+ """
+ print("Принудительная синхронизация категорий с Meilisearch...")
+
+ # Инициализируем индекс категорий в Meilisearch
+ initialize_category_index()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ # Синхронизируем категории с Meilisearch
+ success = sync_categories(db)
+
+ if success:
+ print("Категории успешно синхронизированы с Meilisearch")
+ else:
+ print("Ошибка при синхронизации категорий с Meilisearch")
+ except Exception as e:
+ print(f"Ошибка при синхронизации категорий с Meilisearch: {str(e)}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/sync_meilisearch.py b/backend/sync_meilisearch.py
new file mode 100644
index 0000000..8c0c023
--- /dev/null
+++ b/backend/sync_meilisearch.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.core import SessionLocal
+from app.scripts.sync_meilisearch import sync_products, sync_categories, sync_collections, sync_sizes
+from app.services import meilisearch_service
+
+def main():
+ """
+ Скрипт для ручной синхронизации данных с Meilisearch.
+ """
+ print("Инициализация индексов Meilisearch...")
+ meilisearch_service.initialize_indexes()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ print("Синхронизация категорий...")
+ sync_categories(db)
+
+ print("Синхронизация коллекций...")
+ sync_collections(db)
+
+ print("Синхронизация размеров...")
+ sync_sizes(db)
+
+ print("Синхронизация продуктов...")
+ sync_products(db)
+
+ print("Синхронизация с Meilisearch завершена успешно!")
+ except Exception as e:
+ print(f"Ошибка при синхронизации данных с Meilisearch: {str(e)}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/sync_sizes_direct.py b/backend/sync_sizes_direct.py
new file mode 100644
index 0000000..38c47e2
--- /dev/null
+++ b/backend/sync_sizes_direct.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.core import SessionLocal
+from app.models.catalog_models import Size
+from app.services.meilisearch_service import client, SIZE_INDEX
+
+def format_size_for_meilisearch(size):
+ """
+ Форматирует размер для индексации в Meilisearch.
+ """
+ return {
+ "id": size.id,
+ "name": size.name,
+ "code": size.code,
+ "description": size.description,
+ "created_at": size.created_at.isoformat() if size.created_at else None,
+ "updated_at": size.updated_at.isoformat() if size.updated_at else None
+ }
+
+def sync_sizes(db):
+ """
+ Синхронизирует все размеры с Meilisearch.
+ """
+ print("Синхронизация размеров...")
+
+ # Получаем все размеры из базы данных
+ sizes = db.query(Size).all()
+
+ # Форматируем размеры для Meilisearch
+ sizes_data = [format_size_for_meilisearch(size) for size in sizes]
+
+ # Синхронизируем размеры с Meilisearch
+ try:
+ # Удаляем все документы из индекса размеров
+ client.index(SIZE_INDEX).delete_all_documents()
+
+ # Добавляем новые документы
+ if sizes_data:
+ client.index(SIZE_INDEX).add_documents(sizes_data)
+
+ print(f"Успешно синхронизировано {len(sizes_data)} размеров")
+ return True
+ except Exception as e:
+ print(f"Ошибка при синхронизации размеров: {str(e)}")
+ return False
+
+def initialize_size_index():
+ """
+ Инициализирует индекс размеров в Meilisearch.
+ """
+ try:
+ # Проверяем, существует ли индекс размеров
+ indexes = client.get_indexes()
+ index_exists = False
+
+ for index in indexes["results"]:
+ if index["uid"] == SIZE_INDEX:
+ index_exists = True
+ break
+
+ # Если индекс не существует, создаем его
+ if not index_exists:
+ client.create_index(SIZE_INDEX, {"primaryKey": "id"})
+
+ # Настраиваем фильтруемые атрибуты для размеров
+ client.index(SIZE_INDEX).update_filterable_attributes([
+ "id", "code"
+ ])
+
+ # Настраиваем поисковые атрибуты для размеров
+ client.index(SIZE_INDEX).update_searchable_attributes([
+ "name", "code", "description"
+ ])
+
+ print("Индекс размеров инициализирован успешно")
+ return True
+ except Exception as e:
+ print(f"Ошибка при инициализации индекса размеров: {str(e)}")
+ return False
+
+def main():
+ """
+ Скрипт для принудительной синхронизации размеров с Meilisearch.
+ """
+ print("Принудительная синхронизация размеров с Meilisearch...")
+
+ # Инициализируем индекс размеров в Meilisearch
+ initialize_size_index()
+
+ # Создаем сессию базы данных
+ db = SessionLocal()
+
+ try:
+ # Синхронизируем размеры с Meilisearch
+ success = sync_sizes(db)
+
+ if success:
+ print("Размеры успешно синхронизированы с Meilisearch")
+ else:
+ print("Ошибка при синхронизации размеров с Meilisearch")
+ except Exception as e:
+ print(f"Ошибка при синхронизации размеров с Meilisearch: {str(e)}")
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/update_meilisearch_settings.py b/backend/update_meilisearch_settings.py
new file mode 100644
index 0000000..157b4c8
--- /dev/null
+++ b/backend/update_meilisearch_settings.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+import sys
+import os
+from pathlib import Path
+
+# Добавляем родительскую директорию в sys.path, чтобы импортировать модули приложения
+sys.path.append(str(Path(__file__).parent))
+
+from app.services.meilisearch_service import client, PRODUCT_INDEX, CATEGORY_INDEX, COLLECTION_INDEX, SIZE_INDEX
+
+def main():
+ """
+ Скрипт для обновления настроек индексов Meilisearch.
+ """
+ print("Обновление настроек индексов Meilisearch...")
+
+ try:
+ # Обновляем фильтруемые атрибуты для продуктов
+ print("Обновление фильтруемых атрибутов для продуктов...")
+ client.index(PRODUCT_INDEX).update_filterable_attributes([
+ "category_id", "collection_id", "is_active", "price", "discount_price", "slug", "id"
+ ])
+
+ # Обновляем поисковые атрибуты для продуктов
+ print("Обновление поисковых атрибутов для продуктов...")
+ client.index(PRODUCT_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Обновляем сортируемые атрибуты для продуктов
+ print("Обновление сортируемых атрибутов для продуктов...")
+ client.index(PRODUCT_INDEX).update_sortable_attributes([
+ "price", "discount_price", "created_at", "name"
+ ])
+
+ # Обновляем фильтруемые атрибуты для категорий
+ print("Обновление фильтруемых атрибутов для категорий...")
+ client.index(CATEGORY_INDEX).update_filterable_attributes([
+ "parent_id", "is_active", "id", "slug"
+ ])
+
+ # Обновляем поисковые атрибуты для категорий
+ print("Обновление поисковых атрибутов для категорий...")
+ client.index(CATEGORY_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Обновляем фильтруемые атрибуты для коллекций
+ print("Обновление фильтруемых атрибутов для коллекций...")
+ client.index(COLLECTION_INDEX).update_filterable_attributes([
+ "is_active", "id", "slug"
+ ])
+
+ # Обновляем поисковые атрибуты для коллекций
+ print("Обновление поисковых атрибутов для коллекций...")
+ client.index(COLLECTION_INDEX).update_searchable_attributes([
+ "name", "description", "slug"
+ ])
+
+ # Обновляем поисковые атрибуты для размеров
+ print("Обновление поисковых атрибутов для размеров...")
+ client.index(SIZE_INDEX).update_searchable_attributes([
+ "name", "code", "description"
+ ])
+
+ print("Настройки индексов Meilisearch обновлены успешно!")
+ except Exception as e:
+ print(f"Ошибка при обновлении настроек индексов Meilisearch: {str(e)}")
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg b/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg
new file mode 100644
index 0000000..24fa56e
Binary files /dev/null and b/backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg differ
diff --git a/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg b/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg
new file mode 100644
index 0000000..b136d17
Binary files /dev/null and b/backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg differ
diff --git a/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg b/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg
new file mode 100644
index 0000000..0e34057
Binary files /dev/null and b/backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg differ
diff --git a/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg b/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg
new file mode 100644
index 0000000..29be554
Binary files /dev/null and b/backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg differ
diff --git a/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg b/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg
new file mode 100644
index 0000000..dad336b
Binary files /dev/null and b/backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg differ
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 8ec138d..c4fa331 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -105,10 +105,22 @@ services:
- postgres
restart: always
+ redis:
+ image: redis:alpine
+ container_name: dressed-for-success-redis
+ hostname: redis
+ expose:
+ - "6379"
+ restart: always
+ networks:
+ app_network:
+ aliases:
+ - redis
+
networks:
app_network:
driver: bridge
volumes:
postgres_data:
- driver: local
\ No newline at end of file
+ driver: local
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 93e1b1c..d53d20e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,9 +13,13 @@ services:
- DEBUG=0
- SECRET_KEY=supersecretkey
- UPLOAD_DIRECTORY=/app/uploads
+ - MEILISEARCH_URL=http://meilisearch:7700
+ - MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
depends_on:
postgres:
condition: service_healthy
+ meilisearch:
+ condition: service_healthy
volumes:
- ./backend/uploads:/app/uploads
networks:
@@ -93,6 +97,32 @@ services:
dns_search: .
restart: always
+ meilisearch:
+ image: getmeili/meilisearch:latest
+ container_name: dressed-for-success-meilisearch
+ hostname: meilisearch
+ ports:
+ - "7700:7700"
+ environment:
+ - MEILI_MASTER_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
+ - MEILI_NO_ANALYTICS=true
+ - MEILI_ENV=production
+ volumes:
+ - meilisearch_data:/meili_data
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--spider", "http://localhost:7700/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 15s
+ networks:
+ app_network:
+ aliases:
+ - meilisearch
+ dns_search: .
+ restart: always
+
+
networks:
app_network:
driver: bridge
@@ -101,4 +131,6 @@ volumes:
postgres_data:
driver: local
backend_uploads:
+ driver: local
+ meilisearch_data:
driver: local
\ No newline at end of file
diff --git a/frontend/.DS_Store b/frontend/.DS_Store
index 35db19d..b10d16a 100644
Binary files a/frontend/.DS_Store and b/frontend/.DS_Store differ
diff --git a/frontend/app/(main)/about/page.tsx b/frontend/app/(main)/about/page.tsx
index 1b2aef4..80d99b2 100644
--- a/frontend/app/(main)/about/page.tsx
+++ b/frontend/app/(main)/about/page.tsx
@@ -4,371 +4,259 @@ import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { motion } from "framer-motion"
-import { useInView } from "react-intersection-observer"
-import { ArrowRight } from "lucide-react"
export default function AboutPage() {
- const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
- const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
- const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.2 })
- const [ref4, inView4] = useInView({ triggerOnce: true, threshold: 0.2 })
- const [ref5, inView5] = useInView({ triggerOnce: true, threshold: 0.2 })
-
return (
-
- {/* Hero Section */}
-
-
-
-
-
-
-
-
О нашем бренде
-
Мы создаем одежду, которая становится частью вашей истории и отражает вашу индивидуальность
-
-
- Качество
-
-
- Стиль
-
-
- Устойчивое развитие
-
-
-
-
-
-
-
- {/* Our Story */}
-
-
-
-
- Наша история
-
Путь к созданию уникального бренда
-
-
- Наш бренд был основан в 2015 году с простой, но амбициозной целью: создавать одежду, которая сочетает
- в себе элегантность, комфорт и устойчивое развитие. Мы начали с небольшой коллекции базовых предметов
- гардероба, созданных из экологически чистых материалов.
-
-
- С годами мы росли, но наши ценности оставались неизменными. Мы по-прежнему стремимся создавать одежду,
- которая не только выглядит стильно, но и производится с уважением к людям и планете.
-
-
- Сегодня наш бренд представлен в нескольких странах, но мы по-прежнему сохраняем индивидуальный подход
- к каждому изделию, уделяя особое внимание деталям и качеству.
-
-
-
-
-
-
-
-
-
-
-
-
2015
-
Год основания нашего бренда
-
-
-
-
-
-
- {/* Our Values */}
-
-
-
- Наши ценности
-
Принципы, которыми мы руководствуемся
-
- Наши ценности определяют все, что мы делаем — от выбора материалов до отношений с клиентами и партнерами.
-
-
-
-
+ {/* Вступление */}
+
+
+
+
+
-
-
-
-
-
Качество превыше всего
-
- Мы не идем на компромиссы, когда речь идет о качестве. Каждое изделие проходит строгий контроль, чтобы
- гарантировать долговечность и безупречный внешний вид.
-
-
-
-
-
-
-
-
Устойчивое развитие
-
- Мы заботимся о планете и стремимся минимизировать наше воздействие на окружающую среду. Мы используем экологически чистые материалы и этичные методы производства.
-
-
-
-
-
-
-
-
Клиентоориентированность
-
- Наши клиенты — в центре всего, что мы делаем. Мы стремимся превзойти ожидания и создать положительный опыт на каждом этапе взаимодействия с нашим брендом.
-
-
-
+ Наша Философия: Dressed for Success – Это Вы
+
+
+ Dressed for Success – это не просто одежда, это философия и образ жизни. Бренд создает одежду для женщин, которые сочетают в себе все грани: силу и нежность, элегантность и практичность, уверенность и чувственность. Коллекции – это воплощение идеи, что каждая женщина заслуживает чувствовать себя особенной каждый день.
+
- {/* Team Section */}
-
-
-
- Наша команда
-
Люди, создающие наш бренд
-
- За каждым успешным брендом стоит команда талантливых и преданных своему делу людей. Познакомьтесь с некоторыми из них.
-
- Путь нашего бренда отмечен важными вехами, которые сформировали нас такими, какие мы есть сегодня.
-
-
-
-
-
-
- {[
- { year: "2015", title: "Основание бренда", description: "Запуск первой коллекции базовых предметов гардероба." },
- { year: "2017", title: "Открытие первого магазина", description: "Открытие нашего флагманского магазина в центре города." },
- { year: "2019", title: "Международная экспансия", description: "Выход на международный рынок и запуск онлайн-магазина." },
- { year: "2021", title: "Устойчивое развитие", description: "Переход на 100% экологически чистые материалы во всех коллекциях." },
- { year: "2023", title: "Новая эра", description: "Запуск инновационной линейки одежды с использованием передовых технологий." }
- ].map((milestone, index) => (
-
-
-
-
- {milestone.year}
-
-
-
-
-
-
{milestone.title}
-
{milestone.description}
-
-
- ))}
-
-
-
-
- {/* CTA Section */}
-
-
-
-
Станьте частью нашей истории
-
- Присоединяйтесь к нам в нашем стремлении создавать одежду, которая не только выглядит прекрасно, но и производится с заботой о людях и планете.
-
-
-
-
+
+ {/* Наши Ценности */}
+
+
+
+
+
+ Наши Ценности
+
+
+
+ {/* Натуральные ткани */}
+
+
Натуральные ткани
+
+ Только лучшие натуральные материалы (тенсел, лён, вискоза), которые дарят комфорт и заботятся о самочувствии. В коллекциях используются ткани, которые приятны к телу и позволяют коже дышать.
+
+
+
+ {/* Внимание к деталям */}
+
+
Внимание к деталям
+
+ Всё продумано до мелочей: от кроя до последней строчки и 17 пуговиц на блузах SHIK. Каждый элемент одежды создан с любовью и вниманием, чтобы подчеркнуть индивидуальность.
+
+
+
+ {/* Малые тиражи */}
+
+
Малые тиражи
+
+ Уникальные вещи создаются небольшими партиями, чтобы каждая клиентка чувствовала себя особенной. Это позволяет контролировать качество каждого изделия и гарантировать, что каждая вещь создана с душой.
+
+
+
+ {/* Комфорт и Стиль */}
+
+
Комфорт и Стиль
+
+ Баланс между элегантностью и практичностью. Одежда, в которой удобно и красиво жить. Философия бренда заключается в том, что стильная одежда должна быть комфортной, а комфортная одежда может быть стильной.
+
+
+
+
+
+
+ {/* Наша Миссия */}
+
+
+
+
+
+
+
+
+
+
+
+
Наша Миссия
+
+ Вдохновлять на победы, предлагая стильные, качественные и комфортные вещи, которые подчеркивают индивидуальность и помогают чувствовать себя уверенно.
+
+
+
+ "В Dressed for Success создается не просто одежда, а настроение и уверенность. Каждая женщина заслуживает чувствовать себя особенной каждый день, и одежда бренда помогает ей в этом."
+
+
— Команда Dressed for Success
+
+
+
+ Посмотреть нашу первую коллекцию
+
+
+
+
+
+
+
+
+ {/* Подписка */}
+
+
+
+
+
+
+
+
+ Следите за нами
+
+
+ Подпишитесь на наши соцсети, чтобы быть в курсе новых коллекций, специальных предложений и вдохновляющего контента.
+
+
+
+ Instagram
+
+
+ Telegram
+
+
+
+
+
+
)
}
diff --git a/frontend/app/(main)/cart/page.tsx b/frontend/app/(main)/cart/page.tsx
index 2f6f8d8..8462b1f 100644
--- a/frontend/app/(main)/cart/page.tsx
+++ b/frontend/app/(main)/cart/page.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useState } from "react"
+import { useState, useRef, useEffect } from "react"
import Image from "next/image"
import Link from "next/link"
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react"
@@ -12,9 +12,40 @@ import { useInView } from "react-intersection-observer"
import { useCart } from "@/hooks/useCart"
import { useRouter } from "next/navigation"
import { formatPrice } from "@/lib/utils"
-import { CartItem as CartItemType } from "@/types/cart"
import { toast } from "@/components/ui/use-toast"
import { ProductCard } from "@/components/product/product-card"
+import { normalizeProductImage } from "@/lib/catalog" // Импортируем нормализацию
+import React from "react" // Добавляем импорт React для React.memo
+
+// Компонент для анимации изменения чисел
+const AnimatedNumber = ({ value, className }: { value: number, className?: string }) => {
+ return (
+
+ {value}
+
+ );
+};
+
+// Компонент для анимации изменения цены
+const AnimatedPrice = ({ price, className }: { price: number, className?: string }) => {
+ return (
+
+ {formatPrice(price)}
+
+ );
+};
+
+// Убираем компонент CartItemImage
interface RecommendedProduct {
id: number
@@ -26,11 +57,22 @@ interface RecommendedProduct {
export default function CartPage() {
const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart()
- const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
- const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
- const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 })
+ // Убираем useInView для ref1 и ref2, так как анимация будет управляться isFirstRender
+ // const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
+ // const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
+ const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) // Оставляем для будущих секций, если нужно
const router = useRouter()
const [processing, setProcessing] = useState<{ [key: number]: boolean }>({})
+
+ // Флаг для отслеживания первого рендера
+ const isFirstRender = useRef(true);
+
+ // Сбрасываем флаг первого рендера после монтирования
+ useEffect(() => {
+ return () => {
+ isFirstRender.current = false;
+ };
+ }, []);
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
if (processing[itemId]) return
@@ -59,7 +101,7 @@ export default function CartPage() {
}
// Calculate totals
- const subtotal = cart.total_price
+ const subtotal = cart.total_amount ?? 0
const shipping = subtotal >= 5000 ? 0 : 500 // Free shipping for orders over 5000
const total = subtotal + shipping
@@ -126,18 +168,19 @@ export default function CartPage() {
) : (
@@ -289,7 +342,9 @@ export default function CartPage() {
Итого:
- {formatPrice(total)}
+
+
+
@@ -337,36 +392,11 @@ export default function CartPage() {
{/* Order Summary for mobile - visible only on small screens */}
+ {/* Убираем кнопку-переключатель и делаем блок видимым по умолчанию */}
Спасибо за ваше сообщение. Мы свяжемся с вами в ближайшее время.
-
-
- ) : (
-
- )}
-
-
-
-
-
-
- {/* Map Section */}
-
-
-
-
КАК НАС НАЙТИ
-
-
- Наш магазин расположен в центре города, в нескольких минутах ходьбы от станции метро.
-
-
-
-
- {/* Here you would normally embed a Google Map or similar */}
-
-
-
-
Карта будет здесь
-
ул. Ленина, 123, Москва
-
-
-
-
-
-
- {/* FAQ Section */}
-
-
-
-
ЧАСТО ЗАДАВАЕМЫЕ ВОПРОСЫ
-
-
- Ответы на наиболее распространенные вопросы о нашем магазине и услугах
-
-
-
-
- {[
- {
- question: "Как долго осуществляется доставка?",
- answer: "Стандартная доставка занимает 3-5 рабочих дней. Экспресс-доставка доступна в большинстве крупных городов и занимает 1-2 рабочих дня."
- },
- {
- question: "Какова политика возврата?",
- answer: "Вы можете вернуть товар в течение 14 дней с момента получения, если он не был в использовании и сохранил все бирки и упаковку."
- },
- {
- question: "Есть ли у вас программа лояльности?",
- answer: "Да, у нас есть программа лояльности, которая позволяет накапливать баллы за покупки и обменивать их на скидки."
- },
- {
- question: "Как я могу отследить свой заказ?",
- answer: "После отправки заказа вы получите электронное письмо с номером для отслеживания. Вы также можете проверить статус заказа в личном кабинете."
- }
- ].map((faq, index) => (
-
-
{faq.question}
-
{faq.answer}
-
- ))}
-
-
-
-
-
- Все вопросы и ответы
-
-
-
-
-
-
-
- )
-}
diff --git a/frontend/app/(main)/contacts/page.tsx b/frontend/app/(main)/contacts/page.tsx
new file mode 100644
index 0000000..d5c59ef
--- /dev/null
+++ b/frontend/app/(main)/contacts/page.tsx
@@ -0,0 +1,176 @@
+"use client"
+
+import Link from "next/link"
+import { motion } from "framer-motion"
+import { MessageCircle, ArrowRight } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+export default function ContactsPage() {
+
+ return (
+
+ {/* Декоративные элементы */}
+
+
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+
+
+
+
+ КОНТАКТНАЯ ИНФОРМАЦИЯ
+
+
+ Мы ценим каждого клиента и всегда рады ответить на ваши вопросы.
+ Свяжитесь с нами любым удобным способом, и мы поможем вам с выбором одежды,
+ оформлением заказа или предоставим необходимую информацию.
+
+
+
+
+
+ {/* Контактная информация */}
+
+
+
+ {/* Левая колонка - Контактная информация */}
+
+
+
Телефон
+
+7 905 967 7125
+ {/*
Ежедневно с 10:00 до 20:00
*/}
+
+
+
+
E-MAIL
+
Pmv-84@yandex.ru
+
+
+
+
Адрес
+
654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Дружбы, дом 33
+
+
+ {/* Карта или дополнительная информация */}
+
+
+
+
Мы всегда на связи
+
+ Задавайте вопросы, предлагайте идеи, делитесь впечатлениями. Мы ценим обратную связь и стремимся стать лучше для вас.
+
+
+
+
+
+
+ ПЕРЕЙТИ В КАТАЛОГ
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/(main)/faq/page.tsx b/frontend/app/(main)/faq/page.tsx
index 9e1853d..54cda06 100644
--- a/frontend/app/(main)/faq/page.tsx
+++ b/frontend/app/(main)/faq/page.tsx
@@ -1,333 +1,239 @@
"use client"
-import { useState } from "react"
+import { useState, useRef, useEffect } from "react"
import { Input } from "@/components/ui/input"
+import { Search, Truck, CreditCard, RotateCcw, ShoppingBag, Mail, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
-import { motion } from "framer-motion"
-import { Search, Plus, Minus, ArrowRight, Mail } from "lucide-react"
-import { useInView } from "react-intersection-observer"
import Link from "next/link"
+import { motion } from "framer-motion"
+import { useInView } from "react-intersection-observer"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
+import { cn } from "@/lib/utils"
export default function FAQPage() {
const [searchQuery, setSearchQuery] = useState("")
- const [openCategories, setOpenCategories] = useState(["shopping"])
- const [openQuestions, setOpenQuestions] = useState([])
+ const [activeCategory, setActiveCategory] = useState("shopping")
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
- const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
- // FAQ data structure
const faqCategories = [
{
id: "shopping",
title: "Покупки и заказы",
- icon: "💳",
+ icon: ,
questions: [
{
id: "q1",
question: "Как оформить заказ на сайте?",
- answer:
- "Чтобы оформить заказ, выберите понравившиеся товары, добавьте их в корзину, перейдите в корзину, нажмите кнопку 'Оформить заказ' и следуйте инструкциям. Вам потребуется указать контактную информацию, адрес доставки и выбрать способ оплаты.",
+ answer: "Чтобы оформить заказ, выберите понравившиеся товары, добавьте их в корзину, перейдите в корзину, нажмите кнопку 'Оформить заказ' и следуйте инструкциям. Вам потребуется указать контактную информацию, адрес доставки и выбрать способ оплаты."
},
{
id: "q2",
question: "Какие способы оплаты доступны?",
- answer:
- "Мы принимаем различные способы оплаты: банковские карты (Visa, MasterCard, МИР), онлайн-платежи через системы ЮMoney, QIWI, СБП, а также наличными при получении (для некоторых способов доставки).",
+ answer: "Мы принимаем различные способы оплаты: банковские карты (Visa, MasterCard, МИР), онлайн-платежи через системы ЮMoney, QIWI, СБП, а также наличными при получении (для некоторых способов доставки)."
},
{
id: "q3",
- question: "Могу ли я изменить или отменить заказ после его оформления?",
- answer:
- "Да, вы можете изменить или отменить заказ, если он еще не был отправлен. Для этого свяжитесь с нашей службой поддержки клиентов по телефону или электронной почте как можно скорее.",
- },
- ],
+ question: "Могу ли я изменить или отменить заказ?",
+ answer: "Да, вы можете изменить или отменить заказ, если он еще не был отправлен. Для этого свяжитесь с нашей службой поддержки клиентов как можно скорее."
+ }
+ ]
},
{
id: "delivery",
title: "Доставка",
- icon: "🚚",
+ icon: ,
questions: [
{
id: "q4",
question: "Какие способы доставки вы предлагаете?",
- answer:
- "Мы предлагаем несколько способов доставки: курьерская доставка, доставка в пункты выдачи заказов и доставка Почтой России. Доступные способы доставки могут различаться в зависимости от вашего региона.",
+ answer: "Мы предлагаем курьерскую доставку, доставку в пункты выдачи заказов и доставку Почтой России. Доступные способы доставки зависят от вашего региона."
},
{
id: "q5",
question: "Сколько стоит доставка?",
- answer:
- "Стоимость доставки зависит от выбранного способа доставки и региона. Курьерская доставка по Москве стоит 500 рублей, доставка в пункты выдачи — 300 рублей, доставка Почтой России — 400 рублей. При заказе на сумму от 5000 рублей доставка бесплатная.",
+ answer: "Стоимость доставки: курьерская по Москве - 500₽, пункты выдачи - 300₽, Почта России - 400₽. При заказе от 5000₽ доставка бесплатная."
},
{
id: "q6",
- question: "Как долго осуществляется доставка?",
- answer:
- "Сроки доставки зависят от выбранного способа доставки и вашего региона. Курьерская доставка по Москве осуществляется в течение 1-2 дней, доставка в пункты выдачи — 2-3 дня, доставка Почтой России — 5-14 дней в зависимости от региона.",
- },
- ],
+ question: "Сроки доставки?",
+ answer: "Курьерская доставка по Москве: 1-2 дня, пункты выдачи: 2-3 дня, Почта России: 5-14 дней в зависимости от региона."
+ }
+ ]
},
{
id: "returns",
title: "Возврат и обмен",
- icon: "↩️",
+ icon: ,
questions: [
{
id: "q7",
- question: "Какие условия возврата товара?",
- answer:
- "Вы можете вернуть товар надлежащего качества в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки. Для возврата товара ненадлежащего качества срок составляет 30 дней.",
+ question: "Условия возврата товара",
+ answer: "Возврат товара надлежащего качества возможен в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки."
},
{
id: "q8",
question: "Как оформить возврат?",
- answer:
- "Для оформления возврата свяжитесь с нашей службой поддержки клиентов, заполните заявление на возврат и отправьте товар обратно. После получения и проверки товара мы вернем вам деньги тем же способом, которым была произведена оплата.",
- },
+ answer: "Свяжитесь со службой поддержки, заполните заявление на возврат и отправьте товар. После проверки товара мы вернем деньги тем же способом оплаты."
+ }
+ ]
+ },
+ {
+ id: "payment",
+ title: "Оплата",
+ icon: ,
+ questions: [
{
id: "q9",
- question: "Могу ли я обменять товар на другой размер или модель?",
- answer:
- "Да, вы можете обменять товар на другой размер или модель в течение 14 дней с момента получения. Для этого свяжитесь с нашей службой поддержки клиентов и следуйте инструкциям по обмену.",
+ question: "Способы оплаты",
+ answer: "Принимаем банковские карты (Visa, MasterCard, МИР), СБП и наличные при получении. Все онлайн-платежи защищены."
},
- ],
- },
- {
- id: "products",
- title: "Товары и размеры",
- icon: "👕",
- questions: [
{
id: "q10",
- question: "Как подобрать правильный размер?",
- answer:
- "Для подбора правильного размера воспользуйтесь нашей таблицей размеров, которая доступна на странице каждого товара и в разделе 'Таблица размеров'. Вы также можете воспользоваться нашим калькулятором размеров, который поможет подобрать идеальный размер на основе ваших измерений.",
- },
- {
- id: "q11",
- question: "Из каких материалов изготовлена ваша одежда?",
- answer:
- "Мы используем только высококачественные материалы для нашей одежды, включая натуральные ткани (хлопок, лен, шелк, шерсть) и современные синтетические материалы. Подробная информация о составе каждого изделия указана на странице товара.",
- },
- {
- id: "q12",
- question: "Как ухаживать за приобретенной одеждой?",
- answer:
- "Рекомендации по уходу зависят от типа изделия и используемых материалов. Общие рекомендации: стирать при температуре, указанной на этикетке, использовать мягкие моющие средства, не отбеливать, сушить в расправленном виде, гладить при температуре, соответствующей типу ткани.",
- },
- ],
- },
- {
- id: "account",
- title: "Личный кабинет",
- icon: "👤",
- questions: [
- {
- id: "q13",
- question: "Как зарегистрироваться на сайте?",
- answer:
- "Для регистрации на сайте нажмите на иконку профиля в верхнем правом углу, выберите 'Регистрация', заполните необходимые поля (имя, email, пароль) и нажмите кнопку 'Зарегистрироваться'.",
- },
- {
- id: "q14",
- question: "Что делать, если я забыл пароль?",
- answer:
- "Если вы забыли пароль, нажмите на ссылку 'Забыли пароль?' на странице входа, введите email, указанный при регистрации, и следуйте инструкциям, которые будут отправлены на вашу почту.",
- },
- {
- id: "q15",
- question: "Как отслеживать статус заказа в личном кабинете?",
- answer:
- "Для отслеживания статуса заказа войдите в личный кабинет, перейдите в раздел 'Мои заказы', выберите интересующий вас заказ и просмотрите информацию о его текущем статусе.",
- },
- ],
- },
+ question: "Безопасность платежей",
+ answer: "Все платежи проходят через защищенное соединение. Мы не храним данные карт. Платежи обрабатываются надежными системами по международным стандартам."
+ }
+ ]
+ }
]
- const toggleCategory = (categoryId: string) => {
- setOpenCategories((prev) =>
- prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
- )
+ // Функция для плавной прокрутки к секции
+ const scrollToCategory = (categoryId: string) => {
+ setActiveCategory(categoryId)
+ const element = document.getElementById(categoryId)
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" })
+ }
}
- const toggleQuestion = (questionId: string) => {
- setOpenQuestions((prev) =>
- prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId],
+ // Фильтрация вопросов по поисковому запросу
+ const filteredCategories = faqCategories.map(category => ({
+ ...category,
+ questions: category.questions.filter(q =>
+ q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ q.answer.toLowerCase().includes(searchQuery.toLowerCase())
)
- }
-
- // Filter FAQ based on search query
- const filteredFAQ = faqCategories
- .map((category) => ({
- ...category,
- questions: category.questions.filter(
- (q) =>
- q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
- q.answer.toLowerCase().includes(searchQuery.toLowerCase()),
- ),
- }))
- .filter((category) => category.questions.length > 0)
+ })).filter(category => category.questions.length > 0)
return (
-
- {/* Hero Section */}
-
-
-
-
+
+
+ {/* Боковая навигация */}
+
-
+ {/* Основной контент */}
+
-
Часто задаваемые вопросы
-
- Найдите ответы на самые распространенные вопросы о нашем магазине, товарах и услугах
-
+ Свяжитесь с нашей службой поддержки, и мы поможем решить ваш вопрос
+
+
+
+
+ Написать в поддержку
+
+
+
+
+
+ В каталог
+
+
- )}
-
-
-
-
-
- {/* Contact Section */}
-
-
-
-
Не нашли ответ на свой вопрос?
-
- Свяжитесь с нашей службой поддержки, и мы с радостью поможем вам решить любой вопрос
-
-
-
-
- Связаться с нами
-
-
-
-
-
- Перейти в каталог
-
-
-
+
-
-
+
+
)
}
diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx
index d148113..a58a883 100644
--- a/frontend/app/(main)/layout.tsx
+++ b/frontend/app/(main)/layout.tsx
@@ -1,5 +1,8 @@
+"use client"
+
import { SiteHeader } from "@/components/layout/site-header"
import { SiteFooter } from "@/components/layout/site-footer"
+import { CartProvider } from "@/hooks/use-cart-provider"
interface MainLayoutProps {
children: React.ReactNode
@@ -8,9 +11,11 @@ interface MainLayoutProps {
export default function MainLayout({ children }: MainLayoutProps) {
return (
-
- {children}
-
+
+
+ {children}
+
+
)
}
\ No newline at end of file
diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx
index 880a0dd..4ab7e4e 100644
--- a/frontend/app/(main)/page.tsx
+++ b/frontend/app/(main)/page.tsx
@@ -4,13 +4,9 @@ import { useState, useEffect, useRef } from "react"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
-import { motion, useScroll, useTransform } from "framer-motion"
-import { useInView } from "react-intersection-observer"
+import { motion, useScroll } from "framer-motion"
+
import { ArrowRight, ShoppingBag, Sparkles, Leaf, Hourglass, Heart } from "lucide-react"
-import { Input } from "@/components/ui/input"
-import { ProductCard } from "@/components/product/product-card"
-import { Product as ApiProduct, ProductDetails } from '@/lib/catalog'
-import catalogService from '@/lib/catalog'
// Типы данных
interface Collection {
@@ -22,84 +18,36 @@ interface Collection {
isAvailable: boolean;
}
-// Интерфейс Продукта для использования на стороне клиента
-interface Product {
- id: number;
- slug: string;
- name: string;
- price: number;
- image: string;
- isNew: boolean;
- sizes: string[];
-}
-// Временные данные для отображения в случае ошибки API
-const tempFeaturedProducts: Product[] = [
- {
- id: 1,
- slug: "linen-dress-with-accent-belt",
- name: "Льняное платье с акцентным поясом",
- price: 12800,
- image: "/placeholder.svg?height=600&width=400",
- isNew: true,
- sizes: ["XS", "S", "M", "L", "XL"],
- },
- {
- id: 2,
- slug: "organic-cotton-blouse",
- name: "Блуза из органического хлопка",
- price: 7600,
- image: "/placeholder.svg?height=600&width=400",
- isNew: true,
- sizes: ["XS", "S", "M", "L"],
- },
- {
- id: 3,
- slug: "high-waisted-pants",
- name: "Брюки с высокой посадкой",
- price: 8900,
- image: "/placeholder.svg?height=600&width=400",
- isNew: false,
- sizes: ["S", "M", "L", "XL"],
- },
- {
- id: 4,
- slug: "loose-fit-jacket",
- name: "Жакет свободного кроя",
- price: 14500,
- image: "/placeholder.svg?height=600&width=400",
- isNew: false,
- sizes: ["S", "M", "L"],
- },
-];
+
export default function HomePage() {
- const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 })
- const [heroRef, heroInView] = useInView({ triggerOnce: false, threshold: 0.1 })
+ // Используем useRef для параллакс-эффекта
+ const heroRef = useRef(null)
const parallaxRef = useRef(null)
- const { scrollYProgress } = useScroll({
+ // Настройка параллакс-эффекта при прокрутке
+ useScroll({
target: parallaxRef,
offset: ["start start", "end start"]
})
- const y = useTransform(scrollYProgress, [0, 1], [0, 200])
+ // Используем параллакс-эффект при прокрутке
- // Состояние для хранения загруженных товаров
- const [featuredProducts, setFeaturedProducts] = useState([])
+ // Состояние для хранения статуса загрузки
const [isLoading, setIsLoading] = useState(true)
// Коллекции (drops)
const collections: Collection[] = [
{
id: 1,
- name: "Dressed for Success",
+ name: "Dressed for Success clothes",
description: "Для вдохновения и продуктивности. Идеальные наряды для деловых встреч и творческих будней.",
- image: "/placeholder.svg?height=800&width=600",
+ image: "/images/home/IMG_8382.jpeg",
status: "Доступно сейчас",
isAvailable: true,
},
{
id: 2,
- name: "Chill & Active",
+ name: "Chik & Active",
description: "Комфортные комплекты для активного отдыха, спорта и расслабленных выходных.",
image: "/placeholder.svg?height=800&width=600",
status: "Скоро",
@@ -115,45 +63,21 @@ export default function HomePage() {
},
]
- // Секция избранных товаров
- const featuredProductIds = [18, 19, 2, 7]; // IDs товаров, которые нужно отобразить
-
- // Загрузка избранных товаров при монтировании компонента
+ // Имитация загрузки данных
useEffect(() => {
- const fetchFeaturedProducts = async () => {
- try {
- setIsLoading(true);
-
- // Получаем все товары из API
- const response = await catalogService.getProducts({
- include_variants: true,
- is_active: true
- });
-
- if (response && response.products) {
- // Фильтруем только те товары, которые указаны в featuredProductIds
- const filtered = response.products
- .filter((product) => featuredProductIds.includes(product.id));
-
- setFeaturedProducts(filtered);
- }
- } catch (error) {
- console.error('Ошибка при загрузке избранных товаров:', error);
- // В случае ошибки загружаем временные данные
- setFeaturedProducts([]);
- } finally {
- setIsLoading(false);
- }
- };
+ // Имитируем загрузку данных
+ const timer = setTimeout(() => {
+ setIsLoading(false);
+ }, 1000);
- fetchFeaturedProducts();
+ return () => clearTimeout(timer);
}, []);
return (
{/* Hero Section */}
-
@@ -161,31 +85,33 @@ export default function HomePage() {
{/* Декоративные элементы */}
-
-
- Мы только открываемся
+ Первая коллекция уже здесь ✨
-
+
- Мягкая сила. Новая женственность.
+ Dressed for Success: Одетая в Успех
-
+
- Первая коллекция уже доступна онлайн.
- Малые тиражи, максимум внимания к деталям.
+ Создаем одежду из натуральных тканей, в которой вы чувствуете себя собой и готовы к победам.
+ Женственная, но сильная эстетика, внимание к каждой детали и любовь к качеству ✨
-
+
-
- СМОТРЕТЬ КОЛЛЕКЦИЮ
+ ПОЗНАКОМИТЬСЯ С КОЛЛЕКЦИЕЙ
+
+
+ ПОДПИШИСЬ
+
+
@@ -250,7 +186,7 @@ export default function HomePage() {
-
-
- Наша философия
-
Кто мы такие
+
Dressed for Success: Больше, чем одежда
- Мы создаём одежду для женщины, которая выбирает быть собой. Комфорт. Легкость. Красота вне трендов.
+ Dressed for Success – это философия, образ жизни, стремления и характер! Мы создаем одежду из натуральных тканей, которая подчеркивает женственность и силу одновременно. Каждая деталь продумана с любовью и вниманием. ✨
- Наши коллекции — это баланс между элегантностью и практичностью, между яркостью и утонченностью. Мы создаем одежду для тех, кто ценит качество, устойчивое развитие и уникальность.
+ Мы верим, что правильный образ – первый шаг к успеху. Наша миссия – вдохновлять вас на победы, предлагая стильные, качественные и комфортные вещи из натуральных материалов, которые подчеркнут вашу индивидуальность и помогут выделиться.
-
@@ -302,7 +240,7 @@ export default function HomePage() {
{/* Декоративные элементы фона */}
-
+
Коллекции
-
Наши дропы
+
Коллекция Dressed for Success
- Ограниченные коллекции одежды, созданные с заботой о вас и планете
+ Первая коллекция – это результат месяцев усердной работы, поиска идеальных натуральных материалов и бесконечного внимания к каждой детали. Женственная, но сильная эстетика в каждом изделии ✨
@@ -396,15 +407,15 @@ export default function HomePage() {
-
-
+
-
Мы за силу мягкости. За наряды, в которых удобно и красиво жить свою жизнь.
+
+
+
+
«Одежда — это язык без слов. Я создаю коллекции, которые подчеркивают уникальность каждой женщины и вдохновляют на новые свершения.»
-
- SMALL BATCH PRODUCTION
+
+ Кристина, создательница бренда
@@ -428,7 +442,7 @@ export default function HomePage() {
{/* Декоративные элементы фона */}
-
+
- Новинки
-
Избранные товары
+ Избранное
+
Наши избранные коллекции
- Самые популярные модели этого сезона
+ Откройте для себя наши невесомые платья AIR, сияющие блузы SHIK и игривые шорты IBIZA — созданные с любовью к деталям и натуральным тканям ✨
+
+ Платья AIR: Воплощение Легкости и Женственности 🕊️
+
+
+ Откройте для себя наши невесомые платья из линейки AIR. Созданные из тончайшего натурального тенсела (на каждое уходит до 5 метров ткани!), они ощущаются как вторая кожа. Идеальны для любого случая – носите с кедами в городе или с босоножками на отдыхе. Женственный силуэт подчеркнет вашу индивидуальность и придаст уверенности.
+
+
+
+ СМОТРЕТЬ КОЛЛЕКЦИЮ
+
+
+
+
+
+
+
+ {/* Категория Блузы SHIK */}
+
+
+ {/* Левая колонка - Описание */}
+
+
+
+ Блузы SHIK: Созданы для Вашего Сияния ✨
+
+
+ Коллекция блуз SHIK – это гимн успеху и личностному росту. Невесомые, сделанные из тончайшего натурального тенсела, они абсолютно легкие и невероятно приятные к телу. Каждую блузу украшают 17 роскошных пуговиц – число, символизирующее процветание. Внимание к деталям и качество исполнения делают эти блузы особенными.
+
+
+
+ СМОТРЕТЬ КОЛЛЕКЦИЮ
+
+
+
+
+
+ {/* Правая колонка - Фото */}
+
+
+
+
+
+
+
+
+
+ {/* Категория Шорты IBIZA */}
+
+
+ {/* Левая колонка - Фото */}
+
+
+
+
+
+
+
+ {/* Правая колонка - Описание */}
+
+
+
+ Шорты IBIZA: Дух Авантюризма и Комфорт 🌪️
+
+
+ Для легких, смелых и игривых! Шорты IBIZA созданы для тех, кто ценит свободу и комфорт. Натуральные ткани (лён + вискоза) и универсальная длина делают их идеальными как для динамичной городской жизни, так и для расслабленного отдыха. Сильная и женственная эстетика в каждой детали. Добавьте куража в свой образ!
+
+
+
+ СМОТРЕТЬ КОЛЛЕКЦИЮ
+
+
+
+
+
+
)}
-
+
{/* Кнопка для перехода в каталог */}
-
-
+
- СМОТРЕТЬ БОЛЬШЕ ТОВАРОВ
+ ПОЛНЫЙ КАТАЛОГ КОЛЛЕКЦИИ
@@ -492,35 +637,32 @@ export default function HomePage() {
-
- Как носить
-
Вдохновение
-
- "Каждая деталь имеет значение — ткань, форма, цвет. Я создаю вещи, которые помогают женщинам чувствовать себя уверенно и комфортно, независимо от ситуации."
-
-
- Алина, основательница бренда
+ Ваш Стиль
+
Вдохновение от Dressed for Success
+
+ Мы верим, что стиль – это способ рассказать о себе миру. Не бойтесь проявлять себя и отражать свой характер через повседневные образы! Натуральные ткани и внимание к деталям делают каждую вещь особенной. 🔥
- Сочетайте базовые модели с акцентными вещами для создания универсальных образов на каждый день.
+ Сочетайте наши невесомые платья AIR с грубыми ботинками или элегантными лодочками. Носите сияющие блузы SHIK с классическими брюками для деловых встреч или с шортами IBIZA для создания яркого и дерзкого образа. Женственная, но сильная эстетика в каждом сочетании.
-
-
+
СОВЕТЫ ПО СТИЛЮ
-
-
@@ -542,13 +686,13 @@ export default function HomePage() {
{/* Подписка на рассылку */}
-
+
{/* Декоративные элементы */}
-
+
-
Будь первой
+
Будь первой ✨
- Новые дропы, эксклюзивные предложения и полезные статьи о стиле
+ Новые коллекции, эксклюзивные предложения и полезные статьи о стиле. Подпишитесь на наши социальные сети, чтобы быть в курсе всех новостей и получать вдохновение каждый день!
-
-
+
+ {/*
-
-
-
-
+
*/}
+
+
+
+
Instagram
-
+
+
Telegram
diff --git a/frontend/app/(main)/privacy/page.tsx b/frontend/app/(main)/privacy/page.tsx
new file mode 100644
index 0000000..00b4bb0
--- /dev/null
+++ b/frontend/app/(main)/privacy/page.tsx
@@ -0,0 +1,322 @@
+"use client"
+
+import Link from "next/link"
+import { motion } from "framer-motion"
+import { ArrowLeft } from "lucide-react"
+
+export default function PrivacyPage() {
+ return (
+
+ {/* Hero Section */}
+
+ {/* Декоративные круги */}
+
+
+
+
+
+ Политика конфиденциальности
+
+
+
+ Политика в отношении обработки персональных данных
+
+
+
+
+
+
+
+
+
1. Общие положения
+
+ Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. № 152-ФЗ «О персональных данных» (далее — Закон о персональных данных) и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые ИП Плотниковым Михаилом Владимировичем (далее — Оператор).
+
+
+ 1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
+
+
+ 1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее — Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта https://dressedforsuccess.shop.
+
+
+
+
+
2. Основные понятия, используемые в Политике
+
+ 2.1. Автоматизированная обработка персональных данных — обработка персональных данных с помощью средств вычислительной техники.
+
+
+ 2.2. Блокирование персональных данных — временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных).
+
+
+ 2.3. Веб-сайт — совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу https://dressedforsuccess.shop.
+
+
+ 2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных и обеспечивающих их обработку информационных технологий и технических средств.
+
+
+ 2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных.
+
+
+ 2.6. Обработка персональных данных — любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных.
+
+
+ 2.7. Оператор — государственный орган, муниципальный орган, юридическое или физическое лицо, самостоятельно или совместно с другими лицами организующие и/или осуществляющие обработку персональных данных, а также определяющие цели обработки персональных данных, состав персональных данных, подлежащих обработке, действия (операции), совершаемые с персональными данными.
+
+
+ 2.8. Персональные данные — любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта https://dressedforsuccess.shop.
+
+
+ 2.9. Персональные данные, разрешенные субъектом персональных данных для распространения, — персональные данные, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных путем дачи согласия на обработку персональных данных, разрешенных субъектом персональных данных для распространения в порядке, предусмотренном Законом о персональных данных (далее — персональные данные, разрешенные для распространения).
+
+
+ 2.10. Пользователь — любой посетитель веб-сайта https://dressedforsuccess.shop.
+
+
+ 2.11. Предоставление персональных данных — действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц.
+
+
+ 2.12. Распространение персональных данных — любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц (передача персональных данных) или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом.
+
+
+ 2.13. Трансграничная передача персональных данных — передача персональных данных на территорию иностранного государства органу власти иностранного государства, иностранному физическому или иностранному юридическому лицу.
+
+
+ 2.14. Уничтожение персональных данных — любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и/или уничтожаются материальные носители персональных данных.
+
+
+
+
+
3. Основные права и обязанности Оператора
+
+ 3.1. Оператор имеет право:
+
+
+
получать от субъекта персональных данных достоверные информацию и/или документы, содержащие персональные данные;
+
в случае отзыва субъектом персональных данных согласия на обработку персональных данных, а также, направления обращения с требованием о прекращении обработки персональных данных, Оператор вправе продолжить обработку персональных данных без согласия субъекта персональных данных при наличии оснований, указанных в Законе о персональных данных;
+
самостоятельно определять состав и перечень мер, необходимых и достаточных для обеспечения выполнения обязанностей, предусмотренных Законом о персональных данных и принятыми в соответствии с ним нормативными правовыми актами, если иное не предусмотрено Законом о персональных данных или другими федеральными законами.
+
+
+ 3.2. Оператор обязан:
+
+
+
предоставлять субъекту персональных данных по его просьбе информацию, касающуюся обработки его персональных данных;
+
организовывать обработку персональных данных в порядке, установленном действующим законодательством РФ;
+
отвечать на обращения и запросы субъектов персональных данных и их законных представителей в соответствии с требованиями Закона о персональных данных;
+
сообщать в уполномоченный орган по защите прав субъектов персональных данных по запросу этого органа необходимую информацию в течение 10 дней с даты получения такого запроса;
+
публиковать или иным образом обеспечивать неограниченный доступ к настоящей Политике в отношении обработки персональных данных;
+
принимать правовые, организационные и технические меры для защиты персональных данных от неправомерного или случайного доступа к ним, уничтожения, изменения, блокирования, копирования, предоставления, распространения персональных данных, а также от иных неправомерных действий в отношении персональных данных;
+
прекратить передачу (распространение, предоставление, доступ) персональных данных, прекратить обработку и уничтожить персональные данные в порядке и случаях, предусмотренных Законом о персональных данных;
+
исполнять иные обязанности, предусмотренные Законом о персональных данных.
+
+
+
+
+
4. Основные права и обязанности субъектов персональных данных
+
+ 4.1. Субъекты персональных данных имеют право:
+
+
+
получать информацию, касающуюся обработки его персональных данных, за исключением случаев, предусмотренных федеральными законами;
+
требовать от оператора уточнения его персональных данных, их блокирования или уничтожения в случае, если персональные данные являются неполными, устаревшими, неточными, незаконно полученными или не являются необходимыми для заявленной цели обработки;
+
выдвигать условие предварительного согласия при обработке персональных данных в целях продвижения на рынке товаров, работ и услуг;
+
на отзыв согласия на обработку персональных данных;
+
обжаловать в уполномоченный орган по защите прав субъектов персональных данных или в судебном порядке неправомерные действия или бездействие Оператора при обработке его персональных данных;
+
на осуществление иных прав, предусмотренных законодательством РФ.
+
+
+ 4.2. Субъекты персональных данных обязаны:
+
+
+
предоставлять Оператору достоверные данные о себе;
+
сообщать Оператору об уточнении (обновлении, изменении) своих персональных данных.
+
+
+ 4.3. Лица, передавшие Оператору недостоверные сведения о себе, либо сведения о другом субъекте персональных данных без согласия последнего, несут ответственность в соответствии с законодательством РФ.
+
+
+
+
+
5. Принципы обработки персональных данных
+
+ 5.1. Обработка персональных данных осуществляется на законной и справедливой основе.
+
+
+ 5.2. Обработка персональных данных ограничивается достижением конкретных, заранее определенных и законных целей. Не допускается обработка персональных данных, несовместимая с целями сбора персональных данных.
+
+
+ 5.3. Не допускается объединение баз данных, содержащих персональные данные, обработка которых осуществляется в целях, несовместимых между собой.
+
+
+ 5.4. Обработке подлежат только персональные данные, которые отвечают целям их обработки.
+
+
+ 5.5. Содержание и объем обрабатываемых персональных данных соответствуют заявленным целям обработки. Не допускается избыточность обрабатываемых персональных данных по отношению к заявленным целям их обработки.
+
+
+ 5.6. При обработке персональных данных обеспечивается точность персональных данных, их достаточность, а в необходимых случаях и актуальность по отношению к целям обработки персональных данных. Оператор принимает необходимые меры и/или обеспечивает их принятие по удалению или уточнению неполных или неточных данных.
+
+
+ 5.7. Хранение персональных данных осуществляется в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных. Обрабатываемые персональные данные уничтожаются либо обезличиваются по достижении целей обработки или в случае утраты необходимости в достижении этих целей, если иное не предусмотрено федеральным законом.
+
+
+
+
+
6. Цели обработки персональных данных
+
+ Цель обработки — уточнение деталей заказа, обработка заказов, доставка товаров, информирование о статусе заказа, отправка информационных писем.
+
+
+ Персональные данные — фамилия, имя, отчество; электронный адрес; номера телефонов; адрес доставки.
+
+
+ Правовые основания — Федеральный закон «Об информации, информационных технологиях и о защите информации» от 27.07.2006 N 149-ФЗ
+
+
+ Виды обработки персональных данных — Сбор, запись, систематизация, накопление, хранение, уничтожение и обезличивание персональных данных.
+
+
+
+
+
7. Условия обработки персональных данных
+
+ 7.1. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных.
+
+
+ 7.2. Обработка персональных данных необходима для достижения целей, предусмотренных международным договором Российской Федерации или законом, для осуществления возложенных законодательством Российской Федерации на оператора функций, полномочий и обязанностей.
+
+
+ 7.3. Обработка персональных данных необходима для осуществления правосудия, исполнения судебного акта, акта другого органа или должностного лица, подлежащих исполнению в соответствии с законодательством Российской Федерации об исполнительном производстве.
+
+
+ 7.4. Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем или поручителем.
+
+
+ 7.5. Обработка персональных данных необходима для осуществления прав и законных интересов оператора или третьих лиц либо для достижения общественно значимых целей при условии, что при этом не нарушаются права и свободы субъекта персональных данных.
+
+
+ 7.6. Осуществляется обработка персональных данных, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных либо по его просьбе (далее — общедоступные персональные данные).
+
+
+ 7.7. Осуществляется обработка персональных данных, подлежащих опубликованию или обязательному раскрытию в соответствии с федеральным законом.
+
+
+
+
+
8. Порядок сбора, хранения, передачи и других видов обработки персональных данных
+
+ Безопасность персональных данных, которые обрабатываются Оператором, обеспечивается путем реализации правовых, организационных и технических мер, необходимых для выполнения в полном объеме требований действующего законодательства в области защиты персональных данных.
+
+
+ 8.1. Оператор обеспечивает сохранность персональных данных и принимает все возможные меры, исключающие доступ к персональным данным неуполномоченных лиц.
+
+
+ 8.2. Персональные данные Пользователя никогда, ни при каких условиях не будут переданы третьим лицам, за исключением случаев, связанных с исполнением действующего законодательства либо в случае, если субъектом персональных данных дано согласие Оператору на передачу данных третьему лицу для исполнения обязательств по гражданско-правовому договору.
+
+
+ 8.3. В случае выявления неточностей в персональных данных, Пользователь может актуализировать их самостоятельно, путем направления Оператору уведомление на адрес электронной почты Оператора Pmv-84@yandex.ru с пометкой «Актуализация персональных данных».
+
+
+ 8.4. Срок обработки персональных данных определяется достижением целей, для которых были собраны персональные данные, если иной срок не предусмотрен договором или действующим законодательством.
+
+
+ Пользователь может в любой момент отозвать свое согласие на обработку персональных данных, направив Оператору уведомление посредством электронной почты на электронный адрес Оператора Pmv-84@yandex.ru с пометкой «Отзыв согласия на обработку персональных данных».
+
+
+ 8.5. Вся информация, которая собирается сторонними сервисами, в том числе платежными системами, средствами связи и другими поставщиками услуг, хранится и обрабатывается указанными лицами (Операторами) в соответствии с их Пользовательским соглашением и Политикой конфиденциальности. Субъект персональных данных и/или с указанными документами. Оператор не несет ответственность за действия третьих лиц, в том числе указанных в настоящем пункте поставщиков услуг.
+
+
+ 8.6. Установленные субъектом персональных данных запреты на передачу (кроме предоставления доступа), а также на обработку или условия обработки (кроме получения доступа) персональных данных, разрешенных для распространения, не действуют в случаях обработки персональных данных в государственных, общественных и иных публичных интересах, определенных законодательством РФ.
+
+
+ 8.7. Оператор при обработке персональных данных обеспечивает конфиденциальность персональных данных.
+
+
+ 8.8. Оператор осуществляет хранение персональных данных в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных.
+
+
+ 8.9. Условием прекращения обработки персональных данных может являться достижение целей обработки персональных данных, истечение срока действия согласия субъекта персональных данных, отзыв согласия субъектом персональных данных или требование о прекращении обработки персональных данных, а также выявление неправомерной обработки персональных данных.
+
+
+
+
+
9. Перечень действий, производимых Оператором с полученными персональными данными
+
+ 9.1. Оператор осуществляет сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление и уничтожение персональных данных.
+
+
+ 9.2. Оператор осуществляет автоматизированную обработку персональных данных с получением и/или передачей полученной информации по информационно-телекоммуникационным сетям или без таковой.
+
+
+
+
+
10. Трансграничная передача персональных данных
+
+ 10.1. Оператор до начала осуществления деятельности по трансграничной передаче персональных данных обязан уведомить уполномоченный орган по защите прав субъектов персональных данных о своем намерении осуществлять трансграничную передачу персональных данных (такое уведомление направляется отдельно от уведомления о намерении осуществлять обработку персональных данных).
+
+
+ 10.2. Оператор до подачи вышеуказанного уведомления, обязан получить от органов власти иностранного государства, иностранных физических лиц, иностранных юридических лиц, которым планируется трансграничная передача персональных данных, соответствующие сведения.
+
+
+
+
+
11. Конфиденциальность персональных данных
+
+ Оператор и иные лица, получившие доступ к персональным данным, обязаны не раскрывать третьим лицам и не распространять персональные данные без согласия субъекта персональных данных, если иное не предусмотрено федеральным законом.
+
+
+
+
+
12. Заключительные положения
+
+ 12.1. Пользователь может получить любые разъяснения по интересующим вопросам, касающимся обработки его персональных данных, обратившись к Оператору с помощью электронной почты Pmv-84@yandex.ru.
+
+
+ 12.2. В данном документе будут отражены любые изменения политики обработки персональных данных Оператором. Политика действует бессрочно до замены ее новой версией.
+
+
+ 12.3. Актуальная версия Политики в свободном доступе расположена в сети Интернет по адресу https://dressedforsuccess.shop/privacy.
+
+
+
+
+
+
+
+ {/* Кнопка возврата на главную */}
+
+
+
+
+
+ Вернуться на главную
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/(main)/size-guide/page.tsx b/frontend/app/(main)/size-guide/page.tsx
index ce9cb9c..4447f4d 100644
--- a/frontend/app/(main)/size-guide/page.tsx
+++ b/frontend/app/(main)/size-guide/page.tsx
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { motion } from "framer-motion"
import { Ruler, Info } from "lucide-react"
+import { SizeTable } from "@/components/size-guide/size-table"
export default function SizeGuidePage() {
const [gender, setGender] = useState("women")
@@ -69,418 +70,11 @@ export default function SizeGuidePage() {
}
return (
-
- {/* Hero Section */}
-
-
-
-
-
-
-
-
Таблица размеров
-
Подберите идеальный размер для вашей фигуры
-
-
-
-
-
-
-
-
-
Как правильно снять мерки
-
- Чтобы подобрать идеальный размер, важно правильно снять мерки. Следуйте нашим рекомендациям для
- получения точных измерений.
-
-
-
-
-
-
Советы по измерению
-
-
-
-
-
-
-
Используйте мягкую измерительную ленту
-
- Для точных измерений используйте мягкую портновскую ленту.
-
-
-
-
-
-
-
-
-
Измеряйте в нижнем белье
-
- Для получения точных измерений снимайте мерки в облегающем нижнем белье.
-
-
-
-
-
-
-
-
-
Держите ленту параллельно полу
-
- При измерении обхватов следите, чтобы лента была параллельна полу.
-
-
-
-
-
-
-
-
-
Не затягивайте ленту слишком туго
-
Лента должна прилегать к телу, но не стягивать его.
-
-
-
-
-
-
-
Что измерять
-
-
-
- 1
-
-
-
Обхват груди
-
- Измерьте по наиболее выступающим точкам груди, лента должна проходить через лопатки.
-
-
-
-
-
- 2
-
-
-
Обхват талии
-
- Измерьте по самой узкой части талии, обычно на 2-3 см выше пупка.
-
-
-
-
-
- 3
-
-
-
Обхват бедер
-
Измерьте по самым выступающим точкам ягодиц.
-
-
-
-
- 4
-
-
-
Рост
-
- Измерьте расстояние от макушки до пола, стоя прямо без обуви.
-
-
-
-
-
-
-
-
-
Подберите свой размер
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setHeight(e.target.value)}
- />
-
- {measurementSystem === "cm" ? "см" : "in"}
-
-
-
-
-
-
-
- setWeight(e.target.value)}
- />
-
- {measurementSystem === "cm" ? "кг" : "lb"}
-
-
-
-
-
-
-
- setBust(e.target.value)}
- />
-
- {measurementSystem === "cm" ? "см" : "in"}
-
-
-
-
-
-
-
- setWaist(e.target.value)}
- />
-
- {measurementSystem === "cm" ? "см" : "in"}
-
-
-
-
-
-
-
- setHips(e.target.value)}
- />
-
- {measurementSystem === "cm" ? "см" : "in"}
-
-
-
-
-
-
-
- Рассчитать мой размер
-
-
- Сбросить
-
-
-
- {recommendedSize && (
-
-
-
-
Ваш рекомендуемый размер: {recommendedSize}
-
- Это приблизительный размер, основанный на ваших измерениях. Размеры могут отличаться в
- зависимости от модели и фасона.
-
-
-
- )}
-
-
-
-
-
Таблицы размеров
-
-
-
- Женская одежда
- Мужская одежда
-
-
-
-
-
-
-
-
Российский размер
-
Международный размер
-
Обхват груди (см)
-
Обхват талии (см)
-
Обхват бедер (см)
-
-
-
-
-
40-42
-
XS
-
80-84
-
62-66
-
86-90
-
-
-
42-44
-
S
-
84-88
-
66-70
-
90-94
-
-
-
44-46
-
M
-
88-92
-
70-74
-
94-98
-
-
-
46-48
-
L
-
92-96
-
74-78
-
98-102
-
-
-
48-50
-
XL
-
96-100
-
78-82
-
102-106
-
-
-
-
-
-
-
-
-
-
-
-
Российский размер
-
Международный размер
-
Обхват груди (см)
-
Обхват талии (см)
-
Обхват шеи (см)
-
-
-
-
-
44-46
-
XS
-
86-90
-
74-78
-
36-37
-
-
-
46-48
-
S
-
90-94
-
78-82
-
37-38
-
-
-
48-50
-
M
-
94-98
-
82-86
-
38-39
-
-
-
50-52
-
L
-
98-102
-
86-90
-
39-40
-
-
-
52-54
-
XL
-
102-106
-
90-94
-
40-41
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Нужна помощь с выбором размера?
-
- Если у вас остались вопросы или вам нужна дополнительная помощь с выбором размера, наши консультанты всегда
- готовы помочь.
-
+
+ {/* Кнопка добавления в корзину */}
+
+ {loading ? "Добавление..." : "Добавить в корзину"}
+
)
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/frontend/components/size-guide/size-modal.tsx b/frontend/components/size-guide/size-modal.tsx
new file mode 100644
index 0000000..80edae5
--- /dev/null
+++ b/frontend/components/size-guide/size-modal.tsx
@@ -0,0 +1,31 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Ruler } from "lucide-react"
+import { SizeTable } from "./size-table"
+
+export function SizeModal() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/size-guide/size-table.tsx b/frontend/components/size-guide/size-table.tsx
new file mode 100644
index 0000000..19b2710
--- /dev/null
+++ b/frontend/components/size-guide/size-table.tsx
@@ -0,0 +1,133 @@
+import { Ruler, Info } from "lucide-react"
+import Image from "next/image"
+
+export function SizeTable() {
+ return (
+
+ {/* Таблица размеров */}
+
+
+
+
+
Российский размер
+
Размер производ-ля (INT)
+
Обхват груди, см
+
Обхват талии, см
+
Обхват бедер, см
+
+
+
+
+
40-42
+
XS
+
88-90
+
60-64
+
88-90
+
+
+
42-44
+
S
+
90-92
+
64-68
+
90-94
+
+
+
44-46
+
M
+
92-96
+
68-75
+
94-96
+
+
+
46-48
+
L
+
96-100
+
75-80
+
96-104
+
+
+
48-50
+
XL
+
100-108
+
80-84
+
104-108
+
+
+
+
+
+ {/* Инструкции по измерению */}
+
+
+
+
+
+
+
+
+
+ 1. ОБХВАТ ГРУДИ
+
+
+ Сантиметровая лента должна проходить по наиболее выступающим точкам груди, сбоку - под подмышечными впадинами, обхватываю лопатки сзади.
+
+
+
+
+
+
+ 2. ОБХВАТ ТАЛИИ
+
+
+ Измеряется горизонтально в самой узкой части талии. При измерении лента должна плотно (без натяжения) прилегать к телу.
+
+
+
+
+
+
+ 3. ОБХВАТ БЕДЕР
+
+
+ Сантиметровая лента проходит строго горизонтально по наиболее выступающим точкам ягодиц.
+
+
+
+
+
+
+ 4. ДЛИНА РУКАВОВ
+
+
+ Измеряется сантиметровой лентой от шва соединения с проймой до нижнего края рукава.
+
+
+
+
+
+
+ 5. ДЛИНА БРЮЧИН
+
+
+ Данная мерка снимается по боковому шву от верхнего края пояса до нижнего края брюк.
+
+
+
+
+
+
+
+ Для наиболее точного определения размера рекомендуем снять мерки с себя или похожей одежды, сверить их с таблицей размеров и только после этого сделать заказ.
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx
index 5cca9fe..66ac3af 100644
--- a/frontend/components/ui/dialog.tsx
+++ b/frontend/components/ui/dialog.tsx
@@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
Promise
+ updateCartItem: (id: number, quantity: number) => Promise
+ removeFromCart: (id: number) => Promise
+ clearCart: () => Promise
+ synchronizeCart: () => Promise
+ isAuthenticated: boolean
+}
+
+// Создание контекста
+const CartContext = createContext(undefined)
+
+// Удаляем дублирующийся импорт
+
+// Провайдер контекста
+export function CartProvider({ children }: { children: ReactNode }) {
+ // Инициализируем пустой корзиной, чтобы серверный и первый клиентский рендер совпадали
+ const [cart, setCart] = useState(createEmptyCart())
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [syncInProgress, setSyncInProgress] = useState(false)
+ // Внутреннее состояние для отслеживания аутентификации
+ const [isAuthenticated, setIsAuthenticated] = useState(apiStatus.isAuthenticated);
+ const initialSyncDoneRef = useRef(false); // Флаг для отслеживания первичной синхронизации
+ const { toast } = useToast()
+
+ // Подписка на изменения корзины
+ useEffect(() => {
+ const unsubscribe = cartStore.subscribe(() => {
+ setCart(cartStore.getState())
+ })
+ return () => unsubscribe()
+ }, [])
+
+ // После монтирования на клиенте, устанавливаем актуальное состояние из cartStore
+ useEffect(() => {
+ setCart(cartStore.getState());
+ }, []);
+
+ // Следим за изменением глобального apiStatus.isAuthenticated
+ useEffect(() => {
+ // Обновляем внутреннее состояние только если оно действительно изменилось
+ if (apiStatus.isAuthenticated !== isAuthenticated) {
+ setIsAuthenticated(apiStatus.isAuthenticated);
+ // Сбрасываем флаг синхронизации при изменении статуса (например, при логауте)
+ if (!apiStatus.isAuthenticated) {
+ initialSyncDoneRef.current = false;
+ }
+ }
+ // Эта зависимость все еще может быть не идеальной, но лучше, чем ничего.
+ // В идеале, статус аутентификации должен приходить из AuthContext.
+ }, [apiStatus.isAuthenticated, isAuthenticated]);
+
+ // Синхронизация корзины при изменении статуса аутентификации на true
+ useEffect(() => {
+ // Запускаем синхронизацию только если пользователь стал аутентифицированным
+ // и первичная синхронизация для этой сессии еще не выполнялась.
+ if (isAuthenticated && !initialSyncDoneRef.current) {
+ const now = Date.now();
+ // Дополнительно проверяем троттлинг на всякий случай
+ if (!syncInProgress && now - lastSyncTimestamp > SYNC_THROTTLE_MS) {
+ console.log("Запуск первичной синхронизации корзины для аутентифицированного пользователя.");
+ synchronizeCart();
+ initialSyncDoneRef.current = true; // Отмечаем, что первичная синхронизация запущена
+ }
+ }
+ }, [isAuthenticated, syncInProgress]); // Зависим от внутреннего состояния isAuthenticated
+
+ // Синхронизация локальной корзины с сервером с дроттлингом
+ const synchronizeCart = async () => {
+ try {
+ // Если синхронизация уже идет, не запускаем новую
+ if (syncInProgress) return
+
+ setSyncInProgress(true)
+ setLoading(true)
+
+ // Фиксируем время последней синхронизации
+ lastSyncTimestamp = Date.now()
+
+ await cartStore.syncWithServer()
+ } catch (err) {
+ setError('Не удалось синхронизировать корзину')
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось синхронизировать корзину',
+ })
+ } finally {
+ setLoading(false)
+ setSyncInProgress(false)
+ }
+ }
+
+ // Добавление товара в корзину
+ const addToCart = async (item: CartItemCreate) => {
+ try {
+ setLoading(true)
+ setError(null)
+ const result = await cartStore.addToCart(item)
+
+ if (result) {
+ toast({
+ title: 'Товар добавлен',
+ description: 'Товар успешно добавлен в корзину',
+ })
+ return true
+ } else {
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось добавить товар в корзину',
+ })
+ return false
+ }
+ } catch (err) {
+ setError('Не удалось добавить товар в корзину')
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось добавить товар в корзину',
+ })
+ return false
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Обновление количества товара в корзине
+ const updateCartItem = async (id: number, quantity: number) => {
+ try {
+ setLoading(true)
+ setError(null)
+ const result = await cartStore.updateCartItem(id, quantity)
+
+ if (!result) {
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось обновить товар в корзине',
+ })
+ }
+ return result
+ } catch (err) {
+ setError('Не удалось обновить товар в корзине')
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось обновить товар в корзине',
+ })
+ return false
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Удаление товара из корзины
+ const removeFromCart = async (id: number) => {
+ try {
+ setLoading(true)
+ setError(null)
+ const result = await cartStore.removeFromCart(id)
+
+ if (result) {
+ toast({
+ title: 'Товар удален',
+ description: 'Товар успешно удален из корзины',
+ })
+ return true
+ } else {
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось удалить товар из корзины',
+ })
+ return false
+ }
+ } catch (err) {
+ setError('Не удалось удалить товар из корзины')
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось удалить товар из корзины',
+ })
+ return false
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Очистка корзины
+ const clearCart = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const result = await cartStore.clearCart()
+
+ if (result) {
+ toast({
+ title: 'Корзина очищена',
+ description: 'Корзина успешно очищена',
+ })
+ return true
+ } else {
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось очистить корзину',
+ })
+ return false
+ }
+ } catch (err) {
+ setError('Не удалось очистить корзину')
+ toast({
+ variant: 'destructive',
+ title: 'Ошибка',
+ description: 'Не удалось очистить корзину',
+ })
+ return false
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Вычисляем itemCount на основе состояния cart
+ const itemCount = useMemo(() => {
+ return cart.items.reduce((sum, item) => sum + item.quantity, 0);
+ }, [cart.items]);
+
+ const value = {
+ cart,
+ loading,
+ error,
+ addToCart,
+ updateCartItem,
+ removeFromCart,
+ clearCart,
+ synchronizeCart,
+ itemCount, // Используем вычисленное значение
+ isAuthenticated // Используем внутреннее состояние
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Хук для использования контекста корзины
+export function useCart() {
+ const context = useContext(CartContext)
+
+ if (context === undefined) {
+ throw new Error("useCart должен использоваться внутри CartProvider")
+ }
+
+ return context
+}
diff --git a/frontend/hooks/useAdminApi.ts b/frontend/hooks/useAdminApi.ts
new file mode 100644
index 0000000..2924f4d
--- /dev/null
+++ b/frontend/hooks/useAdminApi.ts
@@ -0,0 +1,210 @@
+import { useState, useCallback } from 'react';
+import { toast } from 'react-hot-toast';
+import api from '@/lib/api';
+
+/**
+ * Типы состояний запроса
+ */
+export type ApiStatus = 'idle' | 'loading' | 'success' | 'error';
+
+/**
+ * Интерфейс для опций хука
+ */
+interface UseAdminApiOptions {
+ onSuccess?: (data: any) => void;
+ onError?: (error: any) => void;
+ showSuccessToast?: boolean;
+ showErrorToast?: boolean;
+ successMessage?: string;
+ errorMessage?: string;
+}
+
+/**
+ * Хук для работы с API в админке
+ * @param options Опции хука
+ * @returns Объект с функциями и состоянием
+ */
+export function useAdminApi(options: UseAdminApiOptions = {}) {
+ const [status, setStatus] = useState('idle');
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ const {
+ onSuccess,
+ onError,
+ showSuccessToast = false,
+ showErrorToast = true,
+ successMessage = 'Операция выполнена успешно',
+ errorMessage = 'Произошла ошибка при выполнении операции'
+ } = options;
+
+ /**
+ * Обработка ошибки API
+ */
+ const handleError = useCallback((err: any, customMessage?: string) => {
+ console.error('API Error:', err);
+
+ // Формируем сообщение об ошибке
+ let message = customMessage || errorMessage;
+
+ if (err?.response?.data?.detail) {
+ message = err.response.data.detail;
+ } else if (err?.message) {
+ message = err.message;
+ }
+
+ // Устанавливаем состояние ошибки
+ setError(message);
+ setStatus('error');
+
+ // Показываем уведомление об ошибке
+ if (showErrorToast) {
+ toast.error(message);
+ }
+
+ // Вызываем пользовательский обработчик ошибки
+ if (onError) {
+ onError(err);
+ }
+ }, [errorMessage, showErrorToast, onError]);
+
+ /**
+ * Выполнение GET запроса
+ */
+ const get = useCallback(async (url: string, params = {}) => {
+ try {
+ setStatus('loading');
+ setError(null);
+
+ const response = await api.get(url, { params });
+
+ setData(response);
+ setStatus('success');
+
+ if (showSuccessToast) {
+ toast.success(successMessage);
+ }
+
+ if (onSuccess) {
+ onSuccess(response);
+ }
+
+ return response;
+ } catch (err) {
+ handleError(err);
+ return null;
+ }
+ }, [handleError, onSuccess, showSuccessToast, successMessage]);
+
+ /**
+ * Выполнение POST запроса
+ */
+ const post = useCallback(async (url: string, data = {}, config = {}) => {
+ try {
+ setStatus('loading');
+ setError(null);
+
+ const response = await api.post(url, data, config);
+
+ setData(response);
+ setStatus('success');
+
+ if (showSuccessToast) {
+ toast.success(successMessage);
+ }
+
+ if (onSuccess) {
+ onSuccess(response);
+ }
+
+ return response;
+ } catch (err) {
+ handleError(err);
+ return null;
+ }
+ }, [handleError, onSuccess, showSuccessToast, successMessage]);
+
+ /**
+ * Выполнение PUT запроса
+ */
+ const put = useCallback(async (url: string, data = {}, config = {}) => {
+ try {
+ setStatus('loading');
+ setError(null);
+
+ const response = await api.put(url, data, config);
+
+ setData(response);
+ setStatus('success');
+
+ if (showSuccessToast) {
+ toast.success(successMessage);
+ }
+
+ if (onSuccess) {
+ onSuccess(response);
+ }
+
+ return response;
+ } catch (err) {
+ handleError(err);
+ return null;
+ }
+ }, [handleError, onSuccess, showSuccessToast, successMessage]);
+
+ /**
+ * Выполнение DELETE запроса
+ */
+ const del = useCallback(async (url: string, config = {}) => {
+ try {
+ setStatus('loading');
+ setError(null);
+
+ const response = await api.delete(url, config);
+
+ setData(response);
+ setStatus('success');
+
+ if (showSuccessToast) {
+ toast.success(successMessage);
+ }
+
+ if (onSuccess) {
+ onSuccess(response);
+ }
+
+ return response;
+ } catch (err) {
+ handleError(err);
+ return null;
+ }
+ }, [handleError, onSuccess, showSuccessToast, successMessage]);
+
+ /**
+ * Сброс состояния
+ */
+ const reset = useCallback(() => {
+ setStatus('idle');
+ setData(null);
+ setError(null);
+ }, []);
+
+ return {
+ status,
+ isLoading: status === 'loading',
+ isSuccess: status === 'success',
+ isError: status === 'error',
+ data,
+ error,
+ get,
+ post,
+ put,
+ delete: del,
+ reset,
+ setData,
+ setError,
+ setStatus
+ };
+}
+
+export default useAdminApi;
diff --git a/frontend/hooks/useAdminCache.ts b/frontend/hooks/useAdminCache.ts
new file mode 100644
index 0000000..dcaefa1
--- /dev/null
+++ b/frontend/hooks/useAdminCache.ts
@@ -0,0 +1,191 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+/**
+ * Интерфейс для кэшированных данных
+ */
+interface CacheItem {
+ data: T;
+ timestamp: number;
+}
+
+/**
+ * Интерфейс для опций хука
+ */
+interface UseAdminCacheOptions {
+ key: string;
+ ttl?: number; // Время жизни кэша в миллисекундах
+ storage?: 'memory' | 'session' | 'local';
+}
+
+/**
+ * Глобальный кэш для хранения данных в памяти
+ */
+const memoryCache = new Map>();
+
+/**
+ * Хук для кэширования данных в админке
+ * @param options Опции хука
+ * @returns Объект с функциями и состоянием
+ */
+export function useAdminCache(options: UseAdminCacheOptions) {
+ const {
+ key,
+ ttl = 5 * 60 * 1000, // 5 минут по умолчанию
+ storage = 'memory'
+ } = options;
+
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const timerRef = useRef(null);
+
+ /**
+ * Получение данных из кэша
+ */
+ const getFromCache = useCallback((): CacheItem | null => {
+ try {
+ if (storage === 'memory') {
+ return memoryCache.get(key) as CacheItem || null;
+ } else if (storage === 'session') {
+ const item = sessionStorage.getItem(`admin_cache_${key}`);
+ return item ? JSON.parse(item) : null;
+ } else if (storage === 'local') {
+ const item = localStorage.getItem(`admin_cache_${key}`);
+ return item ? JSON.parse(item) : null;
+ }
+ return null;
+ } catch (error) {
+ console.error('Ошибка при получении данных из кэша:', error);
+ return null;
+ }
+ }, [key, storage]);
+
+ /**
+ * Сохранение данных в кэш
+ */
+ const saveToCache = useCallback((value: T) => {
+ try {
+ const cacheItem: CacheItem = {
+ data: value,
+ timestamp: Date.now()
+ };
+
+ if (storage === 'memory') {
+ memoryCache.set(key, cacheItem);
+ } else if (storage === 'session') {
+ sessionStorage.setItem(`admin_cache_${key}`, JSON.stringify(cacheItem));
+ } else if (storage === 'local') {
+ localStorage.setItem(`admin_cache_${key}`, JSON.stringify(cacheItem));
+ }
+
+ // Устанавливаем таймер для очистки кэша
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ timerRef.current = setTimeout(() => {
+ invalidateCache();
+ }, ttl);
+ } catch (error) {
+ console.error('Ошибка при сохранении данных в кэш:', error);
+ }
+ }, [key, ttl, storage]);
+
+ /**
+ * Инвалидация кэша
+ */
+ const invalidateCache = useCallback(() => {
+ try {
+ if (storage === 'memory') {
+ memoryCache.delete(key);
+ } else if (storage === 'session') {
+ sessionStorage.removeItem(`admin_cache_${key}`);
+ } else if (storage === 'local') {
+ localStorage.removeItem(`admin_cache_${key}`);
+ }
+
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ } catch (error) {
+ console.error('Ошибка при инвалидации кэша:', error);
+ }
+ }, [key, storage]);
+
+ /**
+ * Проверка актуальности кэша
+ */
+ const isCacheValid = useCallback((): boolean => {
+ const cacheItem = getFromCache();
+ if (!cacheItem) return false;
+
+ const now = Date.now();
+ return now - cacheItem.timestamp < ttl;
+ }, [getFromCache, ttl]);
+
+ /**
+ * Загрузка данных из кэша при монтировании компонента
+ */
+ useEffect(() => {
+ const loadFromCache = () => {
+ const cacheItem = getFromCache();
+ if (cacheItem && isCacheValid()) {
+ setData(cacheItem.data);
+ }
+ };
+
+ loadFromCache();
+
+ // Очистка таймера при размонтировании компонента
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [getFromCache, isCacheValid]);
+
+ /**
+ * Установка данных с сохранением в кэш
+ */
+ const setDataWithCache = useCallback((value: T) => {
+ setData(value);
+ saveToCache(value);
+ }, [saveToCache]);
+
+ /**
+ * Загрузка данных с использованием функции загрузки
+ */
+ const loadData = useCallback(async (loadFn: () => Promise) => {
+ try {
+ // Проверяем наличие данных в кэше
+ if (isCacheValid()) {
+ const cacheItem = getFromCache();
+ if (cacheItem) {
+ setData(cacheItem.data);
+ return cacheItem.data;
+ }
+ }
+
+ // Если данных в кэше нет или они устарели, загружаем новые
+ setIsLoading(true);
+ const newData = await loadFn();
+ setDataWithCache(newData);
+ setIsLoading(false);
+ return newData;
+ } catch (error) {
+ console.error('Ошибка при загрузке данных:', error);
+ setIsLoading(false);
+ return null;
+ }
+ }, [getFromCache, isCacheValid, setDataWithCache]);
+
+ return {
+ data,
+ isLoading,
+ setData: setDataWithCache,
+ invalidateCache,
+ loadData
+ };
+}
+
+export default useAdminCache;
diff --git a/frontend/hooks/useApiRequest.ts b/frontend/hooks/useApiRequest.ts
new file mode 100644
index 0000000..2993c58
--- /dev/null
+++ b/frontend/hooks/useApiRequest.ts
@@ -0,0 +1,271 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import axios from 'axios'; // Убираем CancelTokenSource
+import api, { apiStatus } from '@/lib/api';
+
+interface UseApiRequestOptions {
+ // URL для запроса
+ url: string;
+ // Метод запроса
+ method?: 'get' | 'post' | 'put' | 'delete';
+ // Параметры запроса
+ params?: any;
+ // Данные для отправки (для POST, PUT)
+ data?: any;
+ // Хедеры запроса
+ headers?: Record;
+ // Автоматически выполнять запрос при монтировании
+ autoFetch?: boolean;
+ // Интервал для повторного запроса в миллисекундах
+ refreshInterval?: number;
+ // Количество автоматических повторных попыток при ошибке
+ retries?: number;
+ // Интервал между повторными попытками в миллисекундах
+ retryInterval?: number;
+ // Преобразователь для данных ответа
+ dataTransformer?: (data: any) => T;
+ // Функция для определения успешности ответа
+ isSuccessful?: (response: any) => boolean;
+ // Максимальное время запроса в миллисекундах
+ timeout?: number;
+}
+
+interface UseApiRequestResult {
+ // Данные ответа
+ data: T | null;
+ // Состояние загрузки
+ loading: boolean;
+ // Ошибка, если есть
+ error: Error | null;
+ // Функция для выполнения запроса
+ fetchData: (config?: Partial>) => Promise;
+ // Функция для отмены текущего запроса
+ cancelRequest: () => void;
+ // Функция для сброса состояния
+ reset: () => void;
+ // Статус запроса
+ status: 'idle' | 'loading' | 'success' | 'error';
+}
+
+// AbortController используется вместо CancelTokenSource
+
+/**
+ * Хук для выполнения API-запросов с оптимизацией
+ * @param options Параметры запроса
+ * @returns Результат запроса и функции управления
+ */
+export function useApiRequest(
+ options: UseApiRequestOptions
+): UseApiRequestResult {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+
+ // Для хранения AbortController
+ const abortControllerRef = useRef(null);
+
+ // Для хранения таймера обновления
+ const refreshTimerRef = useRef(null);
+
+ // Для отслеживания количества повторных попыток
+ const retriesCountRef = useRef(0);
+
+ // Кэш последних успешных ответов по URL
+ const responseCache = useRef