Добавлены настройки для Meilisearch в конфигурацию приложения, включая переменные окружения и новый сервис. Обновлены файлы docker-compose для добавления Redis и Meilisearch. Исправлены зависимости в requirements.txt. Обновлены компоненты фронтенда для работы с новыми API ответами и улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений.
This commit is contained in:
parent
6aef5fb7ce
commit
0a56297ad7
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
13
backend/.env
Normal file
13
backend/.env
Normal file
@ -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
|
||||
@ -7,4 +7,8 @@ SECRET_KEY=supersecretkey
|
||||
# UPLOAD_DIRECTORY=/app/uploads
|
||||
|
||||
# Настройки CORS
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL=http://meilisearch:7700
|
||||
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
|
||||
BIN
backend/app/.DS_Store
vendored
BIN
backend/app/.DS_Store
vendored
Binary file not shown.
@ -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
|
||||
MINIO_USE_SSL = false
|
||||
|
||||
MEILISEARCH_KEY = dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
MEILISEARCH_URL = http://localhost:7700
|
||||
BIN
backend/app/__pycache__/cache_with_logging.cpython-310.pyc
Normal file
BIN
backend/app/__pycache__/cache_with_logging.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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()
|
||||
settings = Settings()
|
||||
@ -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"}
|
||||
return {"message": "Добро пожаловать в API интернет-магазина Dressed for Success"}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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)}"
|
||||
)
|
||||
)
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@ -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)
|
||||
result = services.update_product_complete(db, product_id, product)
|
||||
return result
|
||||
|
||||
1
backend/app/scripts/__init__.py
Normal file
1
backend/app/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
BIN
backend/app/scripts/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/scripts/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc
Normal file
BIN
backend/app/scripts/__pycache__/sync_meilisearch.cpython-310.pyc
Normal file
Binary file not shown.
249
backend/app/scripts/sync_meilisearch.py
Normal file
249
backend/app/scripts/sync_meilisearch.py
Normal file
@ -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()
|
||||
@ -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
|
||||
from app.repositories import order_repo
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
}
|
||||
}
|
||||
649
backend/app/services/meilisearch_service.py
Normal file
649
backend/app/services/meilisearch_service.py
Normal file
@ -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
|
||||
52
backend/check_data.py
Normal file
52
backend/check_data.py
Normal file
@ -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()
|
||||
96
backend/check_meilisearch.py
Normal file
96
backend/check_meilisearch.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
|
||||
39
backend/sync_categories.py
Normal file
39
backend/sync_categories.py
Normal file
@ -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()
|
||||
115
backend/sync_categories_direct.py
Normal file
115
backend/sync_categories_direct.py
Normal file
@ -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()
|
||||
43
backend/sync_meilisearch.py
Normal file
43
backend/sync_meilisearch.py
Normal file
@ -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()
|
||||
113
backend/sync_sizes_direct.py
Normal file
113
backend/sync_sizes_direct.py
Normal file
@ -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()
|
||||
71
backend/update_meilisearch_settings.py
Normal file
71
backend/update_meilisearch_settings.py
Normal file
@ -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()
|
||||
BIN
backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg
Normal file
BIN
backend/uploads/062aaa80-fae1-4f50-8b14-489edfcfac3f.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
BIN
backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg
Normal file
BIN
backend/uploads/27d93165-7b8f-48d5-869c-42754ce7cc63.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
BIN
backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg
Normal file
BIN
backend/uploads/8ef8a3e2-a7f3-4427-a5e6-b3d7710a8234.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 796 KiB |
BIN
backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg
Normal file
BIN
backend/uploads/caf6b988-17ef-4d52-8a4f-bdc207029309.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg
Normal file
BIN
backend/uploads/f25d0924-931d-4ccf-94d4-43f0abd6abc2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
@ -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
|
||||
driver: local
|
||||
@ -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
|
||||
BIN
frontend/.DS_Store
vendored
BIN
frontend/.DS_Store
vendored
Binary file not shown.
@ -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 (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[60vh] min-h-[400px] md:min-h-[500px] lg:min-h-[600px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=1080&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-light mb-4 tracking-tight">О нашем бренде</h1>
|
||||
<p className="text-lg md:text-xl opacity-90 mb-6 font-light">Мы создаем одежду, которая становится частью вашей истории и отражает вашу индивидуальность</p>
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Качество
|
||||
</span>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Стиль
|
||||
</span>
|
||||
<span className="inline-block px-3 py-1 bg-white/20 backdrop-blur-sm text-white text-sm rounded-none">
|
||||
Устойчивое развитие
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-white text-primary hover:bg-white/90 rounded-none mt-2"
|
||||
asChild
|
||||
>
|
||||
<Link href="#our-story">
|
||||
Узнать больше
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Story */}
|
||||
<section id="our-story" className="py-16 md:py-20 lg:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView1 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="grid md:grid-cols-2 gap-8 md:gap-12 items-center"
|
||||
>
|
||||
<div className="order-2 md:order-1">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наша история</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Путь к созданию уникального бренда</h2>
|
||||
<div className="space-y-4 text-gray-700">
|
||||
<p className="text-base md:text-lg">
|
||||
Наш бренд был основан в 2015 году с простой, но амбициозной целью: создавать одежду, которая сочетает
|
||||
в себе элегантность, комфорт и устойчивое развитие. Мы начали с небольшой коллекции базовых предметов
|
||||
гардероба, созданных из экологически чистых материалов.
|
||||
</p>
|
||||
<p className="text-base md:text-lg">
|
||||
С годами мы росли, но наши ценности оставались неизменными. Мы по-прежнему стремимся создавать одежду,
|
||||
которая не только выглядит стильно, но и производится с уважением к людям и планете.
|
||||
</p>
|
||||
<p className="text-base md:text-lg">
|
||||
Сегодня наш бренд представлен в нескольких странах, но мы по-прежнему сохраняем индивидуальный подход
|
||||
к каждому изделию, уделяя особое внимание деталям и качеству.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
asChild
|
||||
>
|
||||
<Link href="/collections">
|
||||
Смотреть коллекции
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative order-1 md:order-2">
|
||||
<div className="relative h-[300px] md:h-[400px] lg:h-[500px] overflow-hidden">
|
||||
<Image
|
||||
src="/placeholder.svg?height=1000&width=800"
|
||||
alt="История бренда"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 bg-tertiary p-4 md:p-6 max-w-[200px] md:max-w-xs">
|
||||
<p className="text-primary font-medium text-base md:text-lg">2015</p>
|
||||
<p className="text-gray-700 text-sm md:text-base">Год основания нашего бренда</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Our Values */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наши ценности</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Принципы, которыми мы руководствуемся</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
Наши ценности определяют все, что мы делаем — от выбора материалов до отношений с клиентами и партнерами.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
<main className="overflow-hidden">
|
||||
{/* Вступление */}
|
||||
<section className="relative py-28 bg-white overflow-hidden">
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
<div className="container mx-auto px-4 relative z-10 text-center">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.8, staggerChildren: 0.2 }}
|
||||
className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-6xl font-serif text-primary mb-6"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Качество превыше всего</h3>
|
||||
<p className="text-gray-700">
|
||||
Мы не идем на компромиссы, когда речь идет о качестве. Каждое изделие проходит строгий контроль, чтобы
|
||||
гарантировать долговечность и безупречный внешний вид.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Устойчивое развитие</h3>
|
||||
<p className="text-gray-700">
|
||||
Мы заботимся о планете и стремимся минимизировать наше воздействие на окружающую среду. Мы используем экологически чистые материалы и этичные методы производства.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="bg-white p-6 md:p-8 shadow-sm border border-tertiary/20 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary h-6 w-6 md:h-8 md:w-8"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"></line>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-medium text-primary mb-3">Клиентоориентированность</h3>
|
||||
<p className="text-gray-700">
|
||||
Наши клиенты — в центре всего, что мы делаем. Мы стремимся превзойти ожидания и создать положительный опыт на каждом этапе взаимодействия с нашим брендом.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
Наша Философия: Dressed for Success – Это Вы
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-3xl mx-auto text-lg md:text-xl text-gray-700 mb-8"
|
||||
>
|
||||
Dressed for Success – это не просто одежда, это философия и образ жизни. Бренд создает одежду для женщин, которые сочетают в себе все грани: силу и нежность, элегантность и практичность, уверенность и чувственность. Коллекции – это воплощение идеи, что каждая женщина заслуживает чувствовать себя особенной каждый день.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team Section */}
|
||||
<section className="py-16 md:py-20 lg:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наша команда</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Люди, создающие наш бренд</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
За каждым успешным брендом стоит команда талантливых и преданных своему делу людей. Познакомьтесь с некоторыми из них.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref3}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView3 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6"
|
||||
>
|
||||
{[
|
||||
{ name: "Анна Смирнова", role: "Основатель и креативный директор" },
|
||||
{ name: "Михаил Петров", role: "Директор по производству" },
|
||||
{ name: "Елена Иванова", role: "Главный дизайнер" },
|
||||
{ name: "Дмитрий Козлов", role: "Директор по маркетингу" }
|
||||
].map((member, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView3 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="group"
|
||||
>
|
||||
<div className="relative overflow-hidden mb-4">
|
||||
<Image
|
||||
src={`/placeholder.svg?height=600&width=400&text=Team${index + 1}`}
|
||||
alt={member.name}
|
||||
width={400}
|
||||
height={600}
|
||||
className="w-full aspect-[3/4] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
|
||||
<div className="p-4 text-white">
|
||||
<p className="font-medium">{member.name}</p>
|
||||
<p className="text-sm text-white/80">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-primary font-medium">{member.name}</h3>
|
||||
<p className="text-gray-600 text-sm">{member.role}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Milestones */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center max-w-3xl mx-auto mb-12 md:mb-16">
|
||||
<span className="inline-block text-secondary font-medium mb-3 uppercase tracking-widest text-sm">Наши достижения</span>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary mb-6 tracking-tight">Ключевые моменты нашей истории</h2>
|
||||
<p className="text-base md:text-lg text-gray-700">
|
||||
Путь нашего бренда отмечен важными вехами, которые сформировали нас такими, какие мы есть сегодня.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
ref={ref4}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView4 ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 h-full w-px bg-primary/20 hidden md:block"></div>
|
||||
|
||||
{[
|
||||
{ year: "2015", title: "Основание бренда", description: "Запуск первой коллекции базовых предметов гардероба." },
|
||||
{ year: "2017", title: "Открытие первого магазина", description: "Открытие нашего флагманского магазина в центре города." },
|
||||
{ year: "2019", title: "Международная экспансия", description: "Выход на международный рынок и запуск онлайн-магазина." },
|
||||
{ year: "2021", title: "Устойчивое развитие", description: "Переход на 100% экологически чистые материалы во всех коллекциях." },
|
||||
{ year: "2023", title: "Новая эра", description: "Запуск инновационной линейки одежды с использованием передовых технологий." }
|
||||
].map((milestone, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView4 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.2 }}
|
||||
className={`flex flex-col md:flex-row items-start md:items-center gap-4 mb-12 md:mb-16 relative ${
|
||||
index % 2 === 0 ? "md:flex-row-reverse text-left md:text-right" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="md:w-1/2 flex flex-col items-start md:items-center">
|
||||
<div className={`flex items-center gap-4 ${index % 2 === 0 ? "md:flex-row-reverse" : ""}`}>
|
||||
<div className="w-12 h-12 rounded-full bg-primary text-white flex items-center justify-center text-lg font-medium z-10">
|
||||
{milestone.year}
|
||||
</div>
|
||||
<div className={`h-px w-12 bg-primary/20 hidden md:block ${index % 2 === 0 ? "md:order-first" : ""}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`md:w-1/2 ${index % 2 === 0 ? "md:pr-16" : "md:pl-16"}`}>
|
||||
<h3 className="text-xl font-medium text-primary mb-2">{milestone.title}</h3>
|
||||
<p className="text-gray-700">{milestone.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 md:py-20 lg:py-24 bg-primary text-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
ref={ref5}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView5 ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-3xl mx-auto text-center"
|
||||
>
|
||||
<h2 className="text-2xl md:text-3xl lg:text-4xl font-light mb-6">Станьте частью нашей истории</h2>
|
||||
<p className="text-lg md:text-xl opacity-90 mb-8 font-light">
|
||||
Присоединяйтесь к нам в нашем стремлении создавать одежду, которая не только выглядит прекрасно, но и производится с заботой о людях и планете.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
className="bg-white text-primary hover:bg-white/90 rounded-none min-w-[160px]"
|
||||
{/* Путь к Созданию */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Путь к Созданию</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Месяцы в дороге, мягчайшие ткани, сутки на производствах... За каждой вещью стоят сотни задач, в которые вкладывается особый интерес, трепет и предвкушение!
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Каждый материал тщательно отбирается, каждый этап производства контролируется, и каждой детали уделяется особое внимание, чтобы создать по-настоящему особенные вещи.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Смотреть каталог
|
||||
Смотреть коллекцию
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white/10 rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">
|
||||
Связаться с нами
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl">
|
||||
<Image
|
||||
src="/images/home/IMG_8382.jpeg"
|
||||
alt="Путь к созданию"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-32 h-32 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Наши Ценности */}
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-40 h-40 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-60 h-60 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-3xl md:text-4xl font-serif text-primary mb-12 text-center"
|
||||
>
|
||||
Наши Ценности
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-16">
|
||||
{/* Натуральные ткани */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Натуральные ткани</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Только лучшие натуральные материалы (тенсел, лён, вискоза), которые дарят комфорт и заботятся о самочувствии. В коллекциях используются ткани, которые приятны к телу и позволяют коже дышать.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Внимание к деталям */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Внимание к деталям</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Всё продумано до мелочей: от кроя до последней строчки и 17 пуговиц на блузах SHIK. Каждый элемент одежды создан с любовью и вниманием, чтобы подчеркнуть индивидуальность.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Малые тиражи */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Малые тиражи</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Уникальные вещи создаются небольшими партиями, чтобы каждая клиентка чувствовала себя особенной. Это позволяет контролировать качество каждого изделия и гарантировать, что каждая вещь создана с душой.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Комфорт и Стиль */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<h3 className="text-2xl font-serif text-primary mb-4">Комфорт и Стиль</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Баланс между элегантностью и практичностью. Одежда, в которой удобно и красиво жить. Философия бренда заключается в том, что стильная одежда должна быть комфортной, а комфортная одежда может быть стильной.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Наша Миссия */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative order-2 md:order-1"
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl">
|
||||
<Image
|
||||
src="/images/home/IMG_8224.jpeg"
|
||||
alt="Наша миссия"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -left-6 w-32 h-32 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="order-1 md:order-2"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Наша Миссия</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Вдохновлять на победы, предлагая стильные, качественные и комфортные вещи, которые подчеркивают индивидуальность и помогают чувствовать себя уверенно.
|
||||
</p>
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-primary/10 mt-8 mb-8">
|
||||
<p className="italic text-gray-700 leading-relaxed">
|
||||
"В Dressed for Success создается не просто одежда, а настроение и уверенность. Каждая женщина заслуживает чувствовать себя особенной каждый день, и одежда бренда помогает ей в этом."
|
||||
</p>
|
||||
<p className="text-right mt-4 text-primary font-medium">— Команда Dressed for Success</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10">Посмотреть нашу первую коллекцию</span>
|
||||
<span className="absolute inset-0 bg-white scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100 z-0"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Подписка */}
|
||||
<section className="py-24 bg-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -right-20 -top-20 w-80 h-80 rounded-full border border-white/10 opacity-30"></div>
|
||||
<div className="absolute -left-40 bottom-0 w-96 h-96 rounded-full border border-white/10 opacity-20"></div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-3xl md:text-4xl font-serif text-white mb-6"
|
||||
>
|
||||
Следите за нами
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-white/80 mb-8 leading-relaxed"
|
||||
>
|
||||
Подпишитесь на наши соцсети, чтобы быть в курсе новых коллекций, специальных предложений и вдохновляющего контента.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="flex justify-center gap-10 mt-12"
|
||||
>
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold">
|
||||
Instagram
|
||||
</Link>
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold">
|
||||
Telegram
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<motion.span
|
||||
key={value}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={className}
|
||||
>
|
||||
{value}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для анимации изменения цены
|
||||
const AnimatedPrice = ({ price, className }: { price: number, className?: string }) => {
|
||||
return (
|
||||
<motion.span
|
||||
key={price}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={className}
|
||||
>
|
||||
{formatPrice(price)}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
// Убираем компонент 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() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
|
||||
{/* Cart Items */}
|
||||
{/* Убираем ref1 и меняем логику animate */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={inView1 ? "visible" : "hidden"}
|
||||
initial={isFirstRender.current ? "hidden" : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? "visible" : { opacity: 1 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { delay: 0.1 } : { duration: 0 }} // Небольшая задержка для контейнера при первом рендере
|
||||
className="md:col-span-2 space-y-6 md:space-y-8"
|
||||
>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items.length})</h2>
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items_count})</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -152,22 +195,28 @@ export default function CartPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{cart.items.map((item) => (
|
||||
{cart.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
variants={itemVariants}
|
||||
initial={isFirstRender.current ? "hidden" : false}
|
||||
animate={isFirstRender.current ? "visible" : false}
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: index * 0.05 } : { duration: 0 }}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:items-center border-b pb-6 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{/* Изображение товара */}
|
||||
{/* Изображение товара через background-image */}
|
||||
<div className="relative w-24 h-32 sm:w-32 sm:h-40 flex-shrink-0 rounded-md overflow-hidden border border-gray-200">
|
||||
{item.image || item.product_image ? (
|
||||
<Link href={`/product/${item.productId || item.product_id}`}>
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover bg-secondary/10"
|
||||
/>
|
||||
{item.product_image ? (
|
||||
<Link href={`/product/${item.product_id}`}>
|
||||
<div
|
||||
className="w-full h-full bg-secondary/10 lazy-bg"
|
||||
data-bg={`url(${normalizeProductImage(item.product_image)})`}
|
||||
style={{
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
aria-label={item.product_name || "Товар"}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
@ -179,21 +228,19 @@ export default function CartPage() {
|
||||
{/* Информация о товаре */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<Link
|
||||
href={`/product/${item.productId || item.product_id}`}
|
||||
href={`/product/${item.product_id}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors duration-200"
|
||||
>
|
||||
<h3 className="font-medium text-base sm:text-lg line-clamp-2">
|
||||
{item.name || item.product_name || "Товар"}
|
||||
{item.product_name || "Товар"}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Вариант товара - размер и цвет */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{(item.size || item.variant_name) && (
|
||||
<span>Размер: {item.size || item.variant_name}</span>
|
||||
{item.variant_name && (
|
||||
<span>Размер: {item.variant_name}</span>
|
||||
)}
|
||||
{(item.size || item.variant_name) && item.color && <span className="mx-2">•</span>}
|
||||
{item.color && <span>Цвет: {item.color}</span>}
|
||||
</div>
|
||||
|
||||
{/* Количество и цена */}
|
||||
@ -213,7 +260,9 @@ export default function CartPage() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-center w-8 select-none">{item.quantity}</span>
|
||||
<span className="text-center w-8 select-none">
|
||||
<AnimatedNumber value={item.quantity} />
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -233,7 +282,9 @@ export default function CartPage() {
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Цена:</div>
|
||||
<div className="font-medium text-primary">{formatPrice(item.price * item.quantity)}</div>
|
||||
<div className="font-medium text-primary">
|
||||
<AnimatedPrice price={item.total_price} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@ -259,11 +310,11 @@ export default function CartPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary */}
|
||||
{/* Убираем ref2 и меняем логику анимации */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
initial={isFirstRender.current ? { opacity: 0, y: 20 } : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? { opacity: 1, y: 0 } : { opacity: 1, y: 0 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: 0.3 } : { duration: 0 }} // Анимация только при первом рендере
|
||||
className="md:sticky md:top-24 h-fit hidden md:block"
|
||||
>
|
||||
<div className="border border-gray-100 p-6 rounded-lg bg-white shadow-sm">
|
||||
@ -274,8 +325,10 @@ export default function CartPage() {
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">Товары ({cart.items_count}):</span>
|
||||
<span className="font-medium">
|
||||
<AnimatedPrice price={cart.total_amount} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@ -289,7 +342,9 @@ export default function CartPage() {
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<AnimatedPrice price={total} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -337,36 +392,11 @@ export default function CartPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* Order Summary for mobile - visible only on small screens */}
|
||||
{/* Убираем кнопку-переключатель и делаем блок видимым по умолчанию */}
|
||||
<div className="block md:hidden">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mb-8 flex items-center justify-center"
|
||||
disabled={cart.items.length === 0}
|
||||
onClick={() => {
|
||||
const orderSummary = document.getElementById('mobile-order-summary');
|
||||
if (orderSummary) {
|
||||
orderSummary.classList.toggle('hidden');
|
||||
|
||||
// Меняем иконку и текст при раскрытии/скрытии
|
||||
const icon = document.getElementById('toggle-icon');
|
||||
if (icon) {
|
||||
icon.classList.toggle('rotate-270');
|
||||
icon.classList.toggle('rotate-90');
|
||||
}
|
||||
|
||||
const buttonText = document.getElementById('toggle-text');
|
||||
if (buttonText) {
|
||||
buttonText.innerText = orderSummary.classList.contains('hidden') ?
|
||||
'Посмотреть итог заказа' : 'Скрыть итог заказа';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span id="toggle-text">Посмотреть итог заказа</span>
|
||||
<span className="ml-2 font-medium">{formatPrice(total)}</span>
|
||||
<ChevronLeft id="toggle-icon" className="ml-auto h-5 w-5 transform rotate-90" />
|
||||
</Button>
|
||||
{/* Удалена кнопка-переключатель */}
|
||||
|
||||
<div id="mobile-order-summary" className="hidden bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100">
|
||||
<div id="mobile-order-summary" className="bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100"> {/* Убран класс 'hidden' */}
|
||||
<h2 className="text-xl font-medium text-primary mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Сводка заказа
|
||||
@ -374,8 +404,10 @@ export default function CartPage() {
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">Товары ({cart.items_count}):</span>
|
||||
<span className="font-medium">
|
||||
<AnimatedPrice price={cart.total_amount} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@ -389,7 +421,9 @@ export default function CartPage() {
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(total)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<AnimatedPrice price={total} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -414,4 +448,3 @@ export default function CartPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useState } from "react"
|
||||
import { Suspense, useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, ChevronRight, Truck, RotateCcw, Heart } from "lucide-react"
|
||||
@ -34,7 +34,7 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
const { addToCart, loading: cartLoading } = useCart()
|
||||
|
||||
// Загрузка товара при монтировании компонента
|
||||
useState(() => {
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -54,7 +54,7 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
fetchProduct()
|
||||
})
|
||||
}, [params.slug])
|
||||
|
||||
// Если все еще загружается, показываем скелетон
|
||||
if (loading) {
|
||||
@ -373,4 +373,4 @@ function ProductSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useMemo } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@ -25,6 +25,8 @@ import { Input } from "@/components/ui/input"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import catalogService, { Product, Category, Collection, Size } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useCatalogData, ProductQueryParams } from "@/hooks/useCatalogData"
|
||||
import { useDebounce } from "@/hooks/useDebounce"
|
||||
|
||||
// Расширение интерфейса Product для поддержки дополнительных свойств
|
||||
interface ExtendedProduct extends Product {
|
||||
@ -50,25 +52,31 @@ interface ProductsResponse {
|
||||
|
||||
export default function CatalogPage({ searchParams }: { searchParams?: { [key: string]: string } }) {
|
||||
const searchParamsObject = useSearchParams();
|
||||
|
||||
|
||||
// Используем наш новый хук для работы с данными каталога
|
||||
const catalogData = useCatalogData();
|
||||
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
// Применяем дебаунсинг к поисковому запросу
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const [sortOption, setSortOption] = useState("popular")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
// Состояния для реальных данных
|
||||
|
||||
// Состояния для отображения продуктов
|
||||
const [products, setProducts] = useState<ExtendedProduct[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [collections, setCollections] = useState<Collection[]>([])
|
||||
const [sizes, setSizes] = useState<Size[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
const [selectedCollection, setSelectedCollection] = useState<number | null>(null)
|
||||
const [selectedSizes, setSelectedSizes] = useState<number[]>([])
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
|
||||
const [productsError, setProductsError] = useState<string | null>(null)
|
||||
|
||||
// Получаем данные из хука
|
||||
const { categories, collections, sizes, loading, error } = catalogData;
|
||||
|
||||
// Инициализация фильтров из параметров URL
|
||||
useEffect(() => {
|
||||
@ -77,25 +85,25 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
if (categoryId) {
|
||||
setSelectedCategory(Number(categoryId));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие коллекции в URL
|
||||
const collectionId = searchParamsObject.get('collection_id');
|
||||
if (collectionId) {
|
||||
setSelectedCollection(Number(collectionId));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие размеров в URL
|
||||
const sizeIds = searchParamsObject.get('size_ids');
|
||||
if (sizeIds) {
|
||||
setSelectedSizes(sizeIds.split(',').map(id => Number(id)));
|
||||
}
|
||||
|
||||
|
||||
// Проверяем наличие поискового запроса
|
||||
const search = searchParamsObject.get('search');
|
||||
if (search) {
|
||||
setSearchQuery(search);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем сортировку
|
||||
const sort = searchParamsObject.get('sort');
|
||||
if (sort) {
|
||||
@ -108,183 +116,112 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
|
||||
checkIsMobile()
|
||||
window.addEventListener('resize', checkIsMobile)
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIsMobile)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Загрузка категорий
|
||||
// Загрузка начальных данных каталога при монтировании компонента
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
console.log('Загрузка категорий...');
|
||||
const categoriesData = await catalogService.getCategoriesTree();
|
||||
|
||||
if (categoriesData && categoriesData.length > 0) {
|
||||
setCategories(categoriesData);
|
||||
} else {
|
||||
setError("Не удалось загрузить категории с сервера");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке категорий:", err);
|
||||
setError("Не удалось загрузить категории с сервера");
|
||||
}
|
||||
// Загружаем категории, коллекции и размеры при первой загрузке страницы
|
||||
const loadInitialData = async () => {
|
||||
await Promise.all([
|
||||
catalogData.fetchCategories(),
|
||||
catalogData.fetchCollections(),
|
||||
catalogData.fetchSizes()
|
||||
]);
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
// Загрузка коллекций
|
||||
useEffect(() => {
|
||||
const loadCollections = async () => {
|
||||
try {
|
||||
console.log('Загрузка коллекций...');
|
||||
const collectionsResponse = await catalogService.getCollections();
|
||||
|
||||
if (collectionsResponse && collectionsResponse.collections) {
|
||||
setCollections(collectionsResponse.collections);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке коллекций:", err);
|
||||
}
|
||||
|
||||
// Подготавливаем параметры запроса
|
||||
const queryParams = useMemo((): ProductQueryParams => {
|
||||
const params: ProductQueryParams = {
|
||||
limit: 12,
|
||||
skip: (currentPage - 1) * 12,
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
loadCollections();
|
||||
}, []);
|
||||
|
||||
// Загрузка размеров
|
||||
useEffect(() => {
|
||||
const loadSizes = async () => {
|
||||
try {
|
||||
console.log('Загрузка размеров...');
|
||||
const sizesData = await catalogService.getSizes();
|
||||
|
||||
if (sizesData && sizesData.length > 0) {
|
||||
setSizes(sizesData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке размеров:", err);
|
||||
}
|
||||
};
|
||||
if (selectedCategory) {
|
||||
params.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
loadSizes();
|
||||
}, []);
|
||||
if (selectedCollection) {
|
||||
params.collection_id = selectedCollection;
|
||||
}
|
||||
|
||||
// Загрузка продуктов с учетом фильтров
|
||||
if (debouncedSearchQuery) {
|
||||
params.search = debouncedSearchQuery;
|
||||
}
|
||||
|
||||
// Добавляем сортировку
|
||||
if (sortOption) {
|
||||
params.sort = sortOption;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по размерам, если они выбраны
|
||||
if (selectedSizes.length > 0) {
|
||||
params.size_ids = selectedSizes;
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [currentPage, selectedCategory, selectedCollection, debouncedSearchQuery, sortOption, selectedSizes]); // Добавляем selectedSizes в зависимости
|
||||
|
||||
// Загрузка продуктов при изменении параметров запроса
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log('Загрузка продуктов...');
|
||||
|
||||
// Формируем параметры запроса
|
||||
const params: any = {
|
||||
limit: 12,
|
||||
skip: (currentPage - 1) * 12,
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
// Добавляем фильтр по категории
|
||||
if (selectedCategory) {
|
||||
params.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по коллекции
|
||||
if (selectedCollection) {
|
||||
params.collection_id = selectedCollection;
|
||||
}
|
||||
|
||||
// Добавляем поисковый запрос
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
|
||||
// Здесь можно добавить фильтрацию по размерам на стороне клиента,
|
||||
// так как API не поддерживает прямую фильтрацию по размерам
|
||||
|
||||
console.log('Параметры запроса:', params);
|
||||
|
||||
// Получаем продукты через сервис
|
||||
const response = await catalogService.getProducts(params) as ProductsResponse;
|
||||
console.log('Полученный ответ:', response);
|
||||
|
||||
setIsLoadingProducts(true);
|
||||
setProductsError(null);
|
||||
|
||||
// Получаем продукты через кэширующий сервис
|
||||
const response = await catalogData.fetchProducts(queryParams);
|
||||
|
||||
if (!response || !response.products) {
|
||||
setError("Товары не найдены");
|
||||
setLoading(false);
|
||||
setProductsError("Товары не найдены");
|
||||
setProducts([]);
|
||||
setTotalProducts(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let productsData = response.products;
|
||||
|
||||
// Фильтрация по размерам теперь выполняется на бэкенде
|
||||
setProducts(response.products as ExtendedProduct[]);
|
||||
setTotalProducts(response.total);
|
||||
|
||||
// Фильтрация по размерам на стороне клиента, если выбраны размеры
|
||||
if (selectedSizes.length > 0) {
|
||||
productsData = productsData.filter(product => {
|
||||
// Проверяем, есть ли у продукта варианты с выбранными размерами
|
||||
if (!product.variants) return false;
|
||||
|
||||
return product.variants.some(variant =>
|
||||
selectedSizes.includes(variant.size_id) && variant.is_active && variant.stock > 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Сортировка полученных продуктов
|
||||
let sortedProducts = [...productsData];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'price_asc':
|
||||
sortedProducts.sort((a, b) => a.price - b.price);
|
||||
break;
|
||||
case 'price_desc':
|
||||
sortedProducts.sort((a, b) => b.price - a.price);
|
||||
break;
|
||||
case 'newest':
|
||||
sortedProducts.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at || '').getTime();
|
||||
const dateB = new Date(b.created_at || '').getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
default: // popular - оставляем как есть
|
||||
break;
|
||||
}
|
||||
|
||||
setProducts(sortedProducts);
|
||||
setLoading(false);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке продуктов:", err);
|
||||
setError("Не удалось загрузить продукты");
|
||||
setLoading(false);
|
||||
setProductsError("Не удалось загрузить продукты");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, [currentPage, selectedCategory, selectedCollection, selectedSizes, searchQuery, sortOption]);
|
||||
// Зависим только от параметров запроса и самой функции fetchProducts
|
||||
}, [queryParams, catalogData.fetchProducts]);
|
||||
|
||||
// Обработчик выбора категории
|
||||
const handleCategorySelect = (categoryId: number) => {
|
||||
setSelectedCategory(categoryId === selectedCategory ? null : categoryId);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории
|
||||
};
|
||||
|
||||
|
||||
// Обработчик выбора коллекции
|
||||
const handleCollectionSelect = (collectionId: number) => {
|
||||
setSelectedCollection(collectionId === selectedCollection ? null : collectionId);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении коллекции
|
||||
};
|
||||
|
||||
|
||||
// Обработчик выбора размера
|
||||
const handleSizeSelect = (sizeId: number) => {
|
||||
setSelectedSizes(prev =>
|
||||
prev.includes(sizeId)
|
||||
? prev.filter(id => id !== sizeId)
|
||||
setSelectedSizes(prev =>
|
||||
prev.includes(sizeId)
|
||||
? prev.filter(id => id !== sizeId)
|
||||
: [...prev, sizeId]
|
||||
);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении размера
|
||||
@ -324,35 +261,55 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
Категории
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={selectedCategory === category.id}
|
||||
onCheckedChange={() => handleCategorySelect(category.id)}
|
||||
className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{loading.categories ? (
|
||||
// Скелетон для загрузки категорий
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))
|
||||
) : categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={selectedCategory === category.id}
|
||||
onCheckedChange={() => handleCategorySelect(category.id)}
|
||||
className="border-primary/30 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Категории не найдены</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
|
||||
{/* Фильтр по коллекциям */}
|
||||
{collections.length > 0 && (
|
||||
<Accordion type="single" collapsible defaultValue="collections">
|
||||
<AccordionItem value="collections" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Коллекции
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{collections.map((collection) => (
|
||||
<Accordion type="single" collapsible defaultValue="collections">
|
||||
<AccordionItem value="collections" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Коллекции
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{loading.collections ? (
|
||||
// Скелетон для загрузки коллекций
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))
|
||||
) : collections.length > 0 ? (
|
||||
collections.map((collection) => (
|
||||
<div key={collection.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`collection-${collection.id}`}
|
||||
@ -367,22 +324,32 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
{collection.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Коллекции не найдены</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Фильтр по размерам */}
|
||||
{sizes.length > 0 && (
|
||||
<Accordion type="single" collapsible defaultValue="sizes">
|
||||
<AccordionItem value="sizes" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Размеры
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sizes.map((size) => (
|
||||
<Accordion type="single" collapsible defaultValue="sizes">
|
||||
<AccordionItem value="sizes" className="border-b-0">
|
||||
<AccordionTrigger className="py-2 text-base font-medium">
|
||||
Размеры
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{loading.sizes ? (
|
||||
// Скелетон для загрузки размеров
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<Skeleton className="h-4 w-4 mr-2" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
))
|
||||
) : sizes.length > 0 ? (
|
||||
sizes.map((size) => (
|
||||
<div key={size.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`size-${size.id}`}
|
||||
@ -394,22 +361,24 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
htmlFor={`size-${size.id}`}
|
||||
className="ml-2 text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{size.name} ({size.value})
|
||||
{size.name} {size.code ? `(${size.code})` : ''}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">Размеры не найдены</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Компонент активных фильтров
|
||||
const ActiveFilters = () => {
|
||||
const filters = [];
|
||||
|
||||
|
||||
if (selectedCategory) {
|
||||
const category = categories.find(c => c.id === selectedCategory);
|
||||
if (category) {
|
||||
@ -420,7 +389,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (selectedCollection) {
|
||||
const collection = collections.find(c => c.id === selectedCollection);
|
||||
if (collection) {
|
||||
@ -431,20 +400,20 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (selectedSizes.length > 0) {
|
||||
const sizeNames = selectedSizes
|
||||
.map(id => sizes.find(s => s.id === id)?.value)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
|
||||
filters.push({
|
||||
id: 'sizes',
|
||||
name: `Размеры: ${sizeNames}`,
|
||||
onRemove: () => setSelectedSizes([])
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (searchQuery) {
|
||||
filters.push({
|
||||
id: 'search',
|
||||
@ -452,9 +421,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
onRemove: () => setSearchQuery('')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (filters.length === 0) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 my-4">
|
||||
{filters.map(filter => (
|
||||
@ -468,7 +437,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
@ -510,7 +479,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<Search className="h-10 w-10 text-primary/60 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">Товары не найдены</h3>
|
||||
<p className="text-gray-600 mb-4">Попробуйте изменить параметры поиска или фильтры</p>
|
||||
<Button
|
||||
<Button
|
||||
onClick={clearAllFilters}
|
||||
variant="outline"
|
||||
className="border-primary/20 hover:border-primary/80 text-primary hover:bg-primary/5"
|
||||
@ -542,10 +511,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold">Каталог</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{loading ? 'Загрузка...' : `${totalProducts} товаров`}
|
||||
{isLoadingProducts ? 'Загрузка...' : `${totalProducts} товаров`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Сортировка */}
|
||||
<Select
|
||||
@ -562,12 +531,12 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<SelectItem value="newest">По новизне</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
{/* Фильтр для мобильной версии */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:hidden border-primary/20 text-primary/90 rounded-xl"
|
||||
>
|
||||
@ -594,10 +563,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Отображение активных фильтров */}
|
||||
<ActiveFilters />
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Фильтры (сайдбар) - только на десктопе */}
|
||||
<div className="hidden md:block md:col-span-3">
|
||||
@ -605,15 +574,15 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<FilterSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Основной контент с товарами */}
|
||||
<div className="md:col-span-9">
|
||||
{loading ? (
|
||||
<ProductGrid products={products} loading={loading} />
|
||||
) : error ? (
|
||||
{isLoadingProducts ? (
|
||||
<ProductGrid products={products} loading={isLoadingProducts} />
|
||||
) : productsError ? (
|
||||
<div className="text-center py-12 bg-white rounded-3xl p-8">
|
||||
<h2 className="text-xl font-medium mb-2">Ошибка при загрузке товаров</h2>
|
||||
<p className="text-gray-500 mb-4">{error}</p>
|
||||
<p className="text-gray-500 mb-4">{productsError}</p>
|
||||
<Button onClick={() => window.location.reload()} className="rounded-xl">
|
||||
Попробовать снова
|
||||
</Button>
|
||||
@ -630,8 +599,8 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ProductGrid products={products} loading={loading} />
|
||||
|
||||
<ProductGrid products={products} loading={isLoadingProducts} />
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalProducts > 12 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
@ -644,7 +613,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
<ChevronDown className="w-4 h-4 rotate-90 mr-2" />
|
||||
Пред.
|
||||
</button>
|
||||
|
||||
|
||||
<div className="hidden sm:flex">
|
||||
{Array.from({ length: Math.min(5, Math.ceil(totalProducts / 12)) }).map((_, i) => {
|
||||
// Логика для отображения страниц вокруг текущей
|
||||
@ -655,15 +624,15 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
pageToShow = Math.ceil(totalProducts / 12) - (5 - i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (pageToShow > 0 && pageToShow <= Math.ceil(totalProducts / 12)) {
|
||||
return (
|
||||
<button
|
||||
key={pageToShow}
|
||||
onClick={() => setCurrentPage(pageToShow)}
|
||||
className={`w-10 h-10 flex items-center justify-center text-sm ${
|
||||
currentPage === pageToShow
|
||||
? 'bg-primary text-white font-medium'
|
||||
currentPage === pageToShow
|
||||
? 'bg-primary text-white font-medium'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@ -674,11 +643,11 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center px-3 sm:hidden">
|
||||
<span className="text-sm font-medium">{currentPage} из {Math.ceil(totalProducts / 12)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
disabled={currentPage === Math.ceil(totalProducts / 12)}
|
||||
onClick={() => setCurrentPage(p => Math.min(Math.ceil(totalProducts / 12), p + 1))}
|
||||
|
||||
@ -14,16 +14,23 @@ import { formatPrice } from "@/lib/utils"
|
||||
export default function ContactPage() {
|
||||
const { cart, loading } = useCart()
|
||||
const [ref, inView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setInitialLoading(false)
|
||||
}
|
||||
}, [loading])
|
||||
const [whatsappText, setWhatsappText] = useState("")
|
||||
|
||||
// Формируем текст для WhatsApp при изменении корзины
|
||||
useEffect(() => {
|
||||
if (!loading && cart.items && cart.items.length > 0) {
|
||||
const itemsList = cart.items.map(item =>
|
||||
`- ${item.product_name || item.name} (${item.size || 'No size'}) x ${item.quantity} - ${formatPrice(item.price * item.quantity)}`
|
||||
`- ${item.product_name} (${item.variant_name || 'No size'}) x ${item.quantity} - ${formatPrice(item.total_price)}`
|
||||
).join('\n');
|
||||
|
||||
const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_price)}`;
|
||||
const message = `Здравствуйте! Хочу оформить заказ на следующие товары:\n\n${itemsList}\n\nИтого: ${formatPrice(cart.total_amount)}`;
|
||||
|
||||
setWhatsappText(encodeURIComponent(message));
|
||||
}
|
||||
@ -89,10 +96,10 @@ export default function ContactPage() {
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center border-b pb-4">
|
||||
<div className="relative w-16 h-20 flex-shrink-0 rounded-md overflow-hidden bg-gray-100 mr-4">
|
||||
{item.image || item.product_image ? (
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
src={require('@/lib/catalog').normalizeProductImage(item.product_image || "")}
|
||||
alt={item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
@ -103,11 +110,14 @@ export default function ContactPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{item.product_name || item.name}</h3>
|
||||
<h3 className="text-sm font-medium">{item.product_name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Размер: {item.size || 'Не указан'} | Количество: {item.quantity}
|
||||
Размер: {item.variant_name || 'Не указан'} | Количество: {item.quantity}
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1">{formatPrice(item.price * item.quantity)}</p>
|
||||
{/* <p className="text-xs text-gray-500">
|
||||
Артикул: {item.slug || 'Не указан'}
|
||||
</p> */}
|
||||
<p className="text-sm font-medium mt-1">{formatPrice(item.total_price)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -117,12 +127,41 @@ export default function ContactPage() {
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600">Товары ({cart.total_items}):</span>
|
||||
<span className="font-medium">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-gray-600">
|
||||
Товары (
|
||||
<motion.span
|
||||
key={cart.items_count}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{cart.items_count}
|
||||
</motion.span>
|
||||
):
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
<motion.span
|
||||
key={cart.total_amount}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{formatPrice(cart.total_amount)}
|
||||
</motion.span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
<span>Итого:</span>
|
||||
<span className="text-xl text-primary">{formatPrice(cart.total_price)}</span>
|
||||
<span className="text-xl text-primary">
|
||||
<motion.span
|
||||
key={cart.total_amount}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{formatPrice(cart.total_amount)}
|
||||
</motion.span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -175,4 +214,4 @@ export default function ContactPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
90
frontend/app/(main)/company-info/page.tsx
Normal file
90
frontend/app/(main)/company-info/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function CompanyInfoPage() {
|
||||
return (
|
||||
<main className="overflow-hidden bg-white min-h-screen py-20 relative">
|
||||
{/* Декоративные круги */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-5xl font-serif text-primary mb-12 text-center"
|
||||
>
|
||||
Реквизиты компании
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-3xl mx-auto bg-white/80 backdrop-blur-sm rounded-3xl shadow-md p-6 md:p-10 space-y-6 border border-primary/10"
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">1. Наименование предприятия</h2>
|
||||
<p className="text-gray-700">Индивидуальный предприниматель Плотников Михаил Владимирович</p>
|
||||
<p className="text-gray-700">ИП Плотников Михаил Владимирович</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">2. ИНН</h2>
|
||||
<p className="text-gray-700">421713424512</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">3. Адрес</h2>
|
||||
<p className="text-gray-700">654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Грдины, дом 23, кв. 186</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">4. Телефон</h2>
|
||||
<p className="text-gray-700">8 905 966 2401</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">5. Эл. почта</h2>
|
||||
<p className="text-gray-700">Pmv-84@yandex.ru</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">6. Регистрация ИП</h2>
|
||||
<p className="text-gray-700">Межрайонная инспекция ФНС №15 по Кемеровской области-Кузбассу</p>
|
||||
<p className="text-gray-700">ОГРНИП 325420500025878 от 17.03.2025 г.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">7. Банк</h2>
|
||||
<p className="text-gray-700">Филиал «Новосибирский» АО «АЛЬФА-БАНК» г. Новосибирск</p>
|
||||
<p className="text-gray-700">БИК 045004774</p>
|
||||
<p className="text-gray-700">Корр. счет 30101810600000000774</p>
|
||||
<p className="text-gray-700">Расчетный счет 40802810023070009086</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg md:text-xl mb-2 text-primary">8. Руководитель</h2>
|
||||
<p className="text-gray-700">Плотников Михаил Владимирович</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.6 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<Link href="/" className="inline-flex items-center text-primary hover:text-primary/80 transition-colors font-medium">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
На главную
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,415 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { motion } from "framer-motion"
|
||||
import { Check, Mail, MapPin, Phone, ArrowRight, Send, Clock, ChevronRight } from "lucide-react"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formSubmitted, setFormSubmitted] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "general",
|
||||
message: "",
|
||||
})
|
||||
const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [mapRef, mapInView] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
const [faqRef, faqInView] = useInView({ triggerOnce: true, threshold: 0.2 })
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleRadioChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, subject: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Here you would normally send the form data to your backend
|
||||
console.log("Form submitted:", formData)
|
||||
setFormSubmitted(true)
|
||||
// Reset form after submission
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "general",
|
||||
message: "",
|
||||
})
|
||||
// Reset submission status after 5 seconds
|
||||
setTimeout(() => setFormSubmitted(false), 5000)
|
||||
}
|
||||
|
||||
// Анимационные варианты
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6 } }
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] md:min-h-[400px] lg:min-h-[500px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/90 to-primary/50"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-serif mb-6 tracking-tight">СВЯЗАТЬСЯ С НАМИ</h1>
|
||||
<div className="w-20 h-[1px] bg-white/60 mb-6"></div>
|
||||
<p className="text-lg md:text-xl opacity-90 font-light">Мы всегда рады ответить на ваши вопросы и услышать ваше мнение</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid md:grid-cols-3 gap-8 md:gap-12 lg:gap-16">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={inView1 ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="md:col-span-1"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-serif text-primary mb-6 tracking-tight">КОНТАКТНАЯ ИНФОРМАЦИЯ</h2>
|
||||
<div className="w-16 h-[2px] bg-secondary mb-8"></div>
|
||||
|
||||
<div className="space-y-8 md:space-y-10">
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Адрес</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">ул. Ленина, 123, Москва, 123456</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">Россия</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<Mail className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Email</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">info@dressedforsuccessstore.com</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">support@dressedforsuccessstore.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="bg-tertiary/20 p-3 mr-4 rounded-sm">
|
||||
<Phone className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-1">Телефон</h3>
|
||||
<p className="text-gray-600 text-sm md:text-base">+7 (495) 123-45-67</p>
|
||||
<p className="text-gray-600 text-sm md:text-base">Пн-Пт: 9:00 - 18:00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 md:mt-12 p-6 md:p-8 bg-tertiary/10 border border-tertiary/20 rounded-sm">
|
||||
<div className="flex items-center mb-4">
|
||||
<Clock className="h-5 w-5 text-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-primary">Часы работы</h3>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm md:text-base">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Понедельник - Пятница:</span>
|
||||
<span className="text-gray-800 font-medium">10:00 - 20:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Суббота:</span>
|
||||
<span className="text-gray-800 font-medium">10:00 - 18:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Воскресенье:</span>
|
||||
<span className="text-gray-800 font-medium">11:00 - 17:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5 rounded-sm group"
|
||||
asChild
|
||||
>
|
||||
<a href="https://maps.google.com" target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||
Посмотреть на карте
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<div className="bg-white p-6 md:p-8 lg:p-10 border border-gray-100 shadow-sm rounded-sm">
|
||||
<h2 className="text-xl md:text-2xl font-serif text-primary mb-6 tracking-tight">НАПИШИТЕ НАМ</h2>
|
||||
<div className="w-16 h-[2px] bg-secondary mb-8"></div>
|
||||
|
||||
{formSubmitted ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-green-50 border border-green-200 text-green-800 p-6 rounded-sm flex items-center"
|
||||
>
|
||||
<Check className="h-6 w-6 mr-3 text-green-600" />
|
||||
<div>
|
||||
<h3 className="font-medium">Сообщение отправлено!</h3>
|
||||
<p className="text-sm text-green-700">Спасибо за ваше сообщение. Мы свяжемся с вами в ближайшее время.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-gray-700">Имя</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-gray-700">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="ваш@email.com"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-gray-700">Телефон (опционально)</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
className="border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-gray-700">Тема обращения</Label>
|
||||
<RadioGroup
|
||||
value={formData.subject}
|
||||
onValueChange={handleRadioChange}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="general" id="general" className="text-primary" />
|
||||
<Label htmlFor="general" className="text-gray-700 cursor-pointer">Общий вопрос</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="order" id="order" className="text-primary" />
|
||||
<Label htmlFor="order" className="text-gray-700 cursor-pointer">Вопрос по заказу</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="product" id="product" className="text-primary" />
|
||||
<Label htmlFor="product" className="text-gray-700 cursor-pointer">Вопрос о товаре</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="partnership" id="partnership" className="text-primary" />
|
||||
<Label htmlFor="partnership" className="text-gray-700 cursor-pointer">Сотрудничество</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message" className="text-gray-700">Сообщение</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Ваше сообщение..."
|
||||
required
|
||||
className="min-h-[150px] border-gray-300 focus:border-primary focus:ring-primary rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-sm w-full md:w-auto group"
|
||||
>
|
||||
Отправить сообщение
|
||||
<Send className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Map Section */}
|
||||
<section
|
||||
ref={mapRef}
|
||||
className="py-16 md:py-24 bg-tertiary/10"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={mapInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl lg:text-3xl font-serif text-primary mb-4 tracking-tight">КАК НАС НАЙТИ</h2>
|
||||
<div className="w-20 h-[2px] bg-secondary mx-auto mb-6"></div>
|
||||
<p className="text-gray-600 text-sm md:text-base">
|
||||
Наш магазин расположен в центре города, в нескольких минутах ходьбы от станции метро.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={mapInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative h-[400px] md:h-[500px] bg-gray-200 rounded-sm overflow-hidden"
|
||||
>
|
||||
{/* Here you would normally embed a Google Map or similar */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-700 font-medium">Карта будет здесь</p>
|
||||
<p className="text-gray-500 text-sm">ул. Ленина, 123, Москва</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section
|
||||
ref={faqRef}
|
||||
className="py-16 md:py-24"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={faqInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl lg:text-3xl font-serif text-primary mb-4 tracking-tight">ЧАСТО ЗАДАВАЕМЫЕ ВОПРОСЫ</h2>
|
||||
<div className="w-20 h-[2px] bg-secondary mx-auto mb-6"></div>
|
||||
<p className="text-gray-600 text-sm md:text-base">
|
||||
Ответы на наиболее распространенные вопросы о нашем магазине и услугах
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={faqInView ? "visible" : "hidden"}
|
||||
variants={staggerContainer}
|
||||
className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto"
|
||||
>
|
||||
{[
|
||||
{
|
||||
question: "Как долго осуществляется доставка?",
|
||||
answer: "Стандартная доставка занимает 3-5 рабочих дней. Экспресс-доставка доступна в большинстве крупных городов и занимает 1-2 рабочих дня."
|
||||
},
|
||||
{
|
||||
question: "Какова политика возврата?",
|
||||
answer: "Вы можете вернуть товар в течение 14 дней с момента получения, если он не был в использовании и сохранил все бирки и упаковку."
|
||||
},
|
||||
{
|
||||
question: "Есть ли у вас программа лояльности?",
|
||||
answer: "Да, у нас есть программа лояльности, которая позволяет накапливать баллы за покупки и обменивать их на скидки."
|
||||
},
|
||||
{
|
||||
question: "Как я могу отследить свой заказ?",
|
||||
answer: "После отправки заказа вы получите электронное письмо с номером для отслеживания. Вы также можете проверить статус заказа в личном кабинете."
|
||||
}
|
||||
].map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={fadeIn}
|
||||
className="border border-gray-100 p-6 md:p-8 hover:border-primary/20 transition-colors rounded-sm hover:shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-medium text-primary mb-3">{faq.question}</h3>
|
||||
<p className="text-gray-600">{faq.answer}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={faqInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5 rounded-sm group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/faq" className="flex items-center">
|
||||
Все вопросы и ответы
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
frontend/app/(main)/contacts/page.tsx
Normal file
176
frontend/app/(main)/contacts/page.tsx
Normal file
@ -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 (
|
||||
<main className="overflow-hidden bg-white min-h-screen relative">
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-primary">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/40 to-transparent z-10" />
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
initial={{ scale: 1.1, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1, transition: { duration: 1.5 } }}
|
||||
>
|
||||
<img
|
||||
src="/images/hero/image-3.jpeg"
|
||||
alt="Контактная информация"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10 text-white">
|
||||
<div className="max-w-3xl">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-serif mb-6"
|
||||
>
|
||||
КОНТАКТНАЯ ИНФОРМАЦИЯ
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.5 }}
|
||||
className="text-lg md:text-xl mb-8 font-light"
|
||||
>
|
||||
Мы ценим каждого клиента и всегда рады ответить на ваши вопросы.
|
||||
Свяжитесь с нами любым удобным способом, и мы поможем вам с выбором одежды,
|
||||
оформлением заказа или предоставим необходимую информацию.
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-start">
|
||||
{/* Левая колонка - Контактная информация */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Телефон</h2>
|
||||
<p className="text-xl text-gray-700 mb-2">+7 905 967 7125</p>
|
||||
{/* <p className="text-gray-600">Ежедневно с 10:00 до 20:00</p> */}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">E-MAIL</h2>
|
||||
<p className="text-xl text-gray-700">Pmv-84@yandex.ru</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Адрес</h2>
|
||||
<p className="text-xl text-gray-700 mb-2">654066, Кемеровская область-Кузбасс, г. Новокузнецк, ул. Дружбы, дом 33</p>
|
||||
{/* <p className="text-gray-600">Время работы: 10:00 - 20:00</p> */}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Месседжеры и соцсети</h2>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6"><rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/></svg>
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
<a href="https://t.me/" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
<span>Telegram</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Реквизиты организации</h2>
|
||||
<ul className="space-y-2 text-gray-700">
|
||||
<li><span className="font-medium">ИП Плотников Михаил Владимирович</span></li>
|
||||
<li><span className="font-medium">ОГРНИП:</span> 325420500025878 от 17.03.2025 г.</li>
|
||||
<li><span className="font-medium">ИНН:</span> 421713424512</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Правая колонка - Связь через WhatsApp */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-10 text-center">Напишите нам в WhatsApp</h2>
|
||||
|
||||
<div className="mb-8">
|
||||
<img src="/images/whatsapp-code.svg" alt="WhatsApp QR код" className="w-48 h-48 mx-auto" />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-8 text-center">
|
||||
Отсканируйте QR-код или нажмите на кнопку ниже, чтобы связаться с нами через WhatsApp.
|
||||
Мы ответим на все ваши вопросы в кратчайшие сроки.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-[#25D366] text-white hover:bg-[#128C7E] py-6 px-8 flex items-center gap-2 text-lg"
|
||||
>
|
||||
<a href="https://wa.me/79059677125" target="_blank" rel="noopener noreferrer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2"><path d="M3 21l1.65-3.8a9 9 0 1 1 3.4 2.9L3 21"/><path d="M9 10a.5.5 0 0 0 1 0V9a.5.5 0 0 0-1 0v1Zm0 0a5 5 0 0 0 5 5"/><path d="M9.5 11a.5.5 0 0 0 1 0v-1a.5.5 0 0 0-1 0v1Zm0 0a3 3 0 0 0 3 3"/></svg>
|
||||
Написать в WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Карта или дополнительная информация */}
|
||||
<section className="py-24 bg-tertiary/10 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center max-w-3xl mx-auto mb-12"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Мы всегда на связи</h2>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Задавайте вопросы, предлагайте идеи, делитесь впечатлениями. Мы ценим обратную связь и стремимся стать лучше для вас.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex justify-center gap-6 mt-8"
|
||||
>
|
||||
<Button asChild className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden px-8 py-6 text-lg">
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10 flex items-center">ПЕРЕЙТИ В КАТАЛОГ <ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" /></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -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<string[]>(["shopping"])
|
||||
const [openQuestions, setOpenQuestions] = useState<string[]>([])
|
||||
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: <ShoppingBag className="h-5 w-5" />,
|
||||
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: <Truck className="h-5 w-5" />,
|
||||
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: <RotateCcw className="h-5 w-5" />,
|
||||
questions: [
|
||||
{
|
||||
id: "q7",
|
||||
question: "Какие условия возврата товара?",
|
||||
answer:
|
||||
"Вы можете вернуть товар надлежащего качества в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки. Для возврата товара ненадлежащего качества срок составляет 30 дней.",
|
||||
question: "Условия возврата товара",
|
||||
answer: "Возврат товара надлежащего качества возможен в течение 14 дней с момента получения. Товар должен быть в неношеном состоянии, с сохранением всех бирок и упаковки."
|
||||
},
|
||||
{
|
||||
id: "q8",
|
||||
question: "Как оформить возврат?",
|
||||
answer:
|
||||
"Для оформления возврата свяжитесь с нашей службой поддержки клиентов, заполните заявление на возврат и отправьте товар обратно. После получения и проверки товара мы вернем вам деньги тем же способом, которым была произведена оплата.",
|
||||
},
|
||||
answer: "Свяжитесь со службой поддержки, заполните заявление на возврат и отправьте товар. После проверки товара мы вернем деньги тем же способом оплаты."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
title: "Оплата",
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
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 (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] md:min-h-[400px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Боковая навигация */}
|
||||
<aside className="md:w-64 flex-shrink-0">
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по вопросам..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{faqCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => scrollToCategory(category.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors",
|
||||
activeCategory === category.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{category.icon}
|
||||
{category.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
{/* Основной контент */}
|
||||
<main className="flex-1 max-w-3xl">
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
animate={inView1 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-light mb-4 tracking-tight">Часто задаваемые вопросы</h1>
|
||||
<p className="text-lg md:text-xl opacity-90 font-light">
|
||||
Найдите ответы на самые распространенные вопросы о нашем магазине, товарах и услугах
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Search */}
|
||||
<motion.div
|
||||
ref={ref1}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView1 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-10 md:mb-12"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по вопросам..."
|
||||
className="pl-10 py-3 md:py-6 text-base md:text-lg border-gray-200 focus:border-primary focus:ring-primary rounded-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && filteredCategories.length === 0 ? (
|
||||
<div className="text-center py-12 bg-muted rounded-lg">
|
||||
<p className="text-muted-foreground mb-4">По вашему запросу ничего не найдено</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
Сбросить поиск
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* FAQ Categories */}
|
||||
<motion.div
|
||||
ref={ref2}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={inView2 ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="space-y-6 md:space-y-8"
|
||||
>
|
||||
{filteredFAQ.length > 0 ? (
|
||||
filteredFAQ.map((category, categoryIndex) => (
|
||||
<motion.div
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{filteredCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={inView2 ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: categoryIndex * 0.1 }}
|
||||
className="border border-gray-100 overflow-hidden"
|
||||
id={category.id}
|
||||
className="space-y-6 scroll-mt-24"
|
||||
>
|
||||
<button
|
||||
className={`w-full flex items-center justify-between p-4 md:p-6 text-left ${
|
||||
openCategories.includes(category.id) ? "bg-primary/5" : "bg-white"
|
||||
}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-2xl mr-3">{category.icon}</span>
|
||||
<h2 className="text-lg md:text-xl font-medium text-primary">{category.title}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
{category.icon}
|
||||
</div>
|
||||
{openCategories.includes(category.id) ? (
|
||||
<Minus className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<Plus className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
<h2 className="text-3xl font-semibold">{category.title}</h2>
|
||||
</div>
|
||||
|
||||
{openCategories.includes(category.id) && (
|
||||
<div className="border-t border-gray-100">
|
||||
{category.questions.map((question, questionIndex) => (
|
||||
<div key={question.id} className="border-b border-gray-100 last:border-b-0">
|
||||
<button
|
||||
className={`w-full flex items-center justify-between p-4 md:p-6 text-left ${
|
||||
openQuestions.includes(question.id) ? "bg-tertiary/5" : "bg-white"
|
||||
}`}
|
||||
onClick={() => toggleQuestion(question.id)}
|
||||
>
|
||||
<h3 className="text-base md:text-lg font-medium text-gray-800 pr-8">{question.question}</h3>
|
||||
{openQuestions.includes(question.id) ? (
|
||||
<Minus className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
{openQuestions.includes(question.id) && (
|
||||
<div className="p-4 md:p-6 pt-0 md:pt-0 text-gray-600 text-sm md:text-base">
|
||||
<p>{question.answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-gray-50 border border-gray-100">
|
||||
<p className="text-gray-500 mb-4">По вашему запросу ничего не найдено</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
Сбросить поиск
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{category.questions.map((q) => (
|
||||
<AccordionItem key={q.id} value={q.id}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{q.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Секция поддержки */}
|
||||
<div className="mt-16 p-6 bg-muted rounded-lg">
|
||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold mb-2">Не нашли ответ?</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Свяжитесь с нашей службой поддержки, и мы поможем решить ваш вопрос
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/contacts">
|
||||
Написать в поддержку
|
||||
<Mail className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/catalog">
|
||||
В каталог
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="py-12 md:py-20 bg-tertiary/10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-light text-primary mb-4 tracking-tight">Не нашли ответ на свой вопрос?</h2>
|
||||
<p className="text-gray-600 mb-8 text-base md:text-lg">
|
||||
Свяжитесь с нашей службой поддержки, и мы с радостью поможем вам решить любой вопрос
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">
|
||||
Связаться с нами
|
||||
<Mail className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none min-w-[160px]"
|
||||
asChild
|
||||
>
|
||||
<Link href="/catalog">
|
||||
Перейти в каталог
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
<CartProvider>
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</CartProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<ApiProduct[]>([])
|
||||
// Состояние для хранения статуса загрузки
|
||||
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 (
|
||||
<main className="overflow-hidden">
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
ref={heroRef}
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative min-h-screen flex items-center overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 z-0">
|
||||
@ -161,31 +85,33 @@ export default function HomePage() {
|
||||
<motion.div
|
||||
className="w-full h-full"
|
||||
initial={{ scale: 1.1, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: { duration: 1.5 }
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/images/hero/image-3.png"
|
||||
src="/images/hero/image-3.jpeg"
|
||||
alt="Dressed for Success Collection"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none z-[5] overflow-hidden">
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute top-[10%] right-[5%] w-64 h-64 rounded-full border border-tertiary/20 opacity-60"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.6 }}
|
||||
transition={{ duration: 2, delay: 0.5 }}
|
||||
/>
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="absolute bottom-[15%] left-[10%] w-40 h-40 rounded-full border border-tertiary/20 opacity-40"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.4 }}
|
||||
@ -202,45 +128,55 @@ export default function HomePage() {
|
||||
className="mb-4"
|
||||
>
|
||||
<span className="inline-block text-sm md:text-base tracking-widest text-tertiary font-light pb-1">
|
||||
Мы только открываемся
|
||||
Первая коллекция уже здесь ✨
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="text-4xl md:text-6xl lg:text-7xl font-serif text-white mb-6"
|
||||
>
|
||||
Мягкая сила. Новая женственность.
|
||||
Dressed for Success: Одетая в Успех
|
||||
</motion.h1>
|
||||
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="text-lg md:text-xl text-white/90 mb-8 font-light"
|
||||
>
|
||||
Первая коллекция уже доступна онлайн.
|
||||
Малые тиражи, максимум внимания к деталям.
|
||||
Создаем одежду из натуральных тканей, в которой вы чувствуете себя собой и готовы к победам.
|
||||
Женственная, но сильная эстетика, внимание к каждой детали и любовь к качеству ✨
|
||||
</motion.p>
|
||||
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
size="lg"
|
||||
className="bg-tertiary text-primary hover:bg-tertiary/90 group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ</span>
|
||||
<span className="relative z-10">ПОЗНАКОМИТЬСЯ С КОЛЛЕКЦИЕЙ</span>
|
||||
<span className="absolute inset-0 bg-white scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100 z-0"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white/10"
|
||||
>
|
||||
<Link href="#subscribe">
|
||||
ПОДПИШИСЬ
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
@ -250,7 +186,7 @@ export default function HomePage() {
|
||||
<section className="py-24 bg-tertiary/30 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@ -263,29 +199,31 @@ export default function HomePage() {
|
||||
alt="О бренде"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 border border-primary/10 rounded-3xl"></div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-40 h-40 bg-primary/5 -z-10 rounded-2xl"></div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Наша философия</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Кто мы такие</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Dressed for Success: Больше, чем одежда</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Мы создаём одежду для женщины, которая выбирает быть собой. Комфорт. Легкость. Красота вне трендов.
|
||||
Dressed for Success – это философия, образ жизни, стремления и характер! Мы создаем одежду из натуральных тканей, которая подчеркивает женственность и силу одновременно. Каждая деталь продумана с любовью и вниманием. ✨
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8 leading-relaxed">
|
||||
Наши коллекции — это баланс между элегантностью и практичностью, между яркостью и утонченностью. Мы создаем одежду для тех, кто ценит качество, устойчивое развитие и уникальность.
|
||||
Мы верим, что правильный образ – первый шаг к успеху. Наша миссия – вдохновлять вас на победы, предлагая стильные, качественные и комфортные вещи из натуральных материалов, которые подчеркнут вашу индивидуальность и помогут выделиться.
|
||||
</p>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/about">
|
||||
@ -302,7 +240,7 @@ export default function HomePage() {
|
||||
{/* Декоративные элементы фона */}
|
||||
<div className="absolute top-20 left-0 w-40 h-40 bg-tertiary/10 rounded-full blur-3xl opacity-60"></div>
|
||||
<div className="absolute bottom-20 right-0 w-60 h-60 bg-primary/5 rounded-full blur-3xl opacity-70"></div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -312,70 +250,75 @@ export default function HomePage() {
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Коллекции</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Наши дропы</h2>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Коллекция Dressed for Success</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto text-lg">
|
||||
Ограниченные коллекции одежды, созданные с заботой о вас и планете
|
||||
Первая коллекция – это результат месяцев усердной работы, поиска идеальных натуральных материалов и бесконечного внимания к каждой детали. Женственная, но сильная эстетика в каждом изделии ✨
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{collections.map((collection, index) => (
|
||||
<motion.div
|
||||
key={collection.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.2 }}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-lg hover:shadow-xl transition-all duration-500"
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.8}}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12"
|
||||
>
|
||||
{/* Левый столбец - Первая коллекция (большая) */}
|
||||
{collections.length > 0 && (
|
||||
<div
|
||||
key={collections[0].id}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-lg hover:shadow-xl transition-all duration-700 ease-in-out h-full"
|
||||
>
|
||||
{/* Статус коллекции */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<span className={`inline-block py-1 px-4 rounded-full text-xs uppercase tracking-wider font-medium ${
|
||||
collection.isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
collections[0].isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
: 'bg-primary/90 text-white'
|
||||
}`}>
|
||||
{collection.status}
|
||||
{collections[0].status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Изображение коллекции */}
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<div className="relative aspect-[16/9] overflow-hidden">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
src={collections[0].image}
|
||||
alt={collections[0].name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
|
||||
|
||||
|
||||
{/* Название коллекции на фоне */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 transform transition-transform duration-500 group-hover:translate-y-0">
|
||||
<h3 className="text-2xl font-serif text-white mb-2 drop-shadow-lg">{collection.name}</h3>
|
||||
<h3 className="text-3xl font-serif text-white mb-2 drop-shadow-lg">{collections[0].name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Информация и кнопка */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-700 mb-6 text-base">{collection.description}</p>
|
||||
|
||||
{collection.isAvailable ? (
|
||||
<Button
|
||||
<div className="p-8">
|
||||
<p className="text-gray-700 mb-6 text-lg">{collections[0].description}</p>
|
||||
|
||||
{collections[0].isAvailable ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
variant="outline"
|
||||
className="justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
>
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<Link href={`/collections/${collections[0].id}`}>
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ</span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
<Button
|
||||
disabled
|
||||
variant="outline"
|
||||
className="w-full justify-center border-secondary/30 text-secondary/50"
|
||||
variant="outline"
|
||||
className="justify-center border-secondary/30 text-secondary/50"
|
||||
>
|
||||
СКОРО В ПРОДАЖЕ
|
||||
</Button>
|
||||
@ -383,12 +326,80 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Декоративный элемент */}
|
||||
<div className="absolute -bottom-2 -right-2 w-16 h-16 rounded-full bg-tertiary/10 -z-10 transition-all duration-500 group-hover:scale-150 opacity-0 group-hover:opacity-100"></div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Правый столбец - Остальные коллекции (маленькие с фото) */}
|
||||
{collections.length > 1 && (
|
||||
<div className="flex flex-col gap-6 h-full">
|
||||
{collections.slice(1).map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
className="group relative overflow-hidden rounded-xl bg-white shadow-md hover:shadow-lg transition-all duration-500 ease-in-out border border-gray-100 hover:border-tertiary/30 flex-1"
|
||||
>
|
||||
{/* Статус коллекции */}
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<span className={`inline-block py-1 px-3 rounded-full text-xs uppercase tracking-wider font-medium ${
|
||||
collection.isAvailable
|
||||
? 'bg-tertiary/90 text-primary'
|
||||
: 'bg-primary/90 text-white'
|
||||
}`}>
|
||||
{collection.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row h-full">
|
||||
{/* Изображение коллекции */}
|
||||
<div className="relative md:w-1/3 aspect-[4/3] md:aspect-auto overflow-hidden">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Информация и кнопка */}
|
||||
<div className="p-5 md:w-2/3 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-serif text-primary mb-3">{collection.name}</h3>
|
||||
<p className="text-gray-700 mb-5 text-sm">{collection.description}</p>
|
||||
</div>
|
||||
|
||||
{collection.isAvailable ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-center group relative overflow-hidden border-primary text-primary hover:text-white hover:border-primary"
|
||||
>
|
||||
<Link href={`/collections/${collection.id}`}>
|
||||
<span className="relative z-10">СМОТРЕТЬ</span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-center border-secondary/30 text-secondary/50"
|
||||
>
|
||||
СКОРО В ПРОДАЖЕ
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -396,15 +407,15 @@ export default function HomePage() {
|
||||
<section className="py-20 bg-primary text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-5">
|
||||
<Image
|
||||
src="/pattern.svg"
|
||||
alt=""
|
||||
fill
|
||||
<Image
|
||||
src="/pattern.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="container max-w-4xl mx-auto px-4 text-center relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -412,12 +423,15 @@ export default function HomePage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif mb-8">Мы за силу мягкости. За наряды, в которых удобно и красиво жить свою жизнь.</h2>
|
||||
<div className="flex justify-center mb-6">
|
||||
<Hourglass className="h-12 w-12 text-tertiary/80" />
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-serif mb-8">«Одежда — это язык без слов. Я создаю коллекции, которые подчеркивают уникальность каждой женщины и вдохновляют на новые свершения.»</h2>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-[1px] bg-tertiary/60"></div>
|
||||
</div>
|
||||
<p className="mt-6 text-white/80 text-sm">
|
||||
SMALL BATCH PRODUCTION
|
||||
<p className="mt-6 text-white/80 text-lg italic">
|
||||
Кристина, создательница бренда
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -428,7 +442,7 @@ export default function HomePage() {
|
||||
{/* Декоративные элементы фона */}
|
||||
<div className="absolute top-40 right-0 w-80 h-80 bg-tertiary/10 rounded-full blur-3xl opacity-60"></div>
|
||||
<div className="absolute -bottom-20 -left-20 w-80 h-80 bg-primary/5 rounded-full blur-3xl opacity-70"></div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -437,51 +451,182 @@ export default function HomePage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Новинки</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Избранные товары</h2>
|
||||
<span className="text-sm tracking-widest text-secondary uppercase mb-3 inline-block">Избранное</span>
|
||||
<h2 className="text-3xl md:text-5xl font-serif text-primary mb-6">Наши избранные коллекции</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto text-lg">
|
||||
Самые популярные модели этого сезона
|
||||
Откройте для себя наши невесомые платья AIR, сияющие блузы SHIK и игривые шорты IBIZA — созданные с любовью к деталям и натуральным тканям ✨
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{isLoading ? (
|
||||
// Отображаем скелетон загрузки
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
{[1, 2, 3, 4].map((_, index) => (
|
||||
<div className="grid grid-cols-1 gap-16">
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gray-200 h-80"></div>
|
||||
<div className="p-4">
|
||||
<div className="h-4 bg-gray-200 rounded-full mb-4 w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded-full w-1/2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded-full mb-6 w-1/3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded-full mb-8 w-2/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2].map((_, idx) => (
|
||||
<div key={idx} className="relative overflow-hidden rounded-3xl bg-gray-200 h-80"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Отображаем загруженные товары, используя компонент ProductCard
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
{featuredProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.1 }}
|
||||
>
|
||||
<ProductCard product={product} />
|
||||
</motion.div>
|
||||
))}
|
||||
// Отображаем товары по категориям
|
||||
<div className="space-y-20">
|
||||
{/* Категория Платья AIR */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section mb-20 border-b border-gray-100 pb-16"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Фото */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/air-dress.jpeg"
|
||||
alt="Платья AIR"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -right-4 w-24 h-24 bg-tertiary/10 rounded-full -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Описание */}
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Leaf className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Платья AIR: Воплощение Легкости и Женственности 🕊️
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Откройте для себя наши невесомые платья из линейки AIR. Созданные из тончайшего натурального тенсела (на каждое уходит до 5 метров ткани!), они ощущаются как вторая кожа. Идеальны для любого случая – носите с кедами в городе или с босоножками на отдыхе. Женственный силуэт подчеркнет вашу индивидуальность и придаст уверенности.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=dresses">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Категория Блузы SHIK */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section mb-20 border-b border-gray-100 pb-16"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Описание */}
|
||||
<div className="order-2 md:order-1">
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Sparkles className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Блузы SHIK: Созданы для Вашего Сияния ✨
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Коллекция блуз SHIK – это гимн успеху и личностному росту. Невесомые, сделанные из тончайшего натурального тенсела, они абсолютно легкие и невероятно приятные к телу. Каждую блузу украшают 17 роскошных пуговиц – число, символизирующее процветание. Внимание к деталям и качество исполнения делают эти блузы особенными.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=blouses">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Фото */}
|
||||
<div className="relative order-1 md:order-2">
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/shik-blouse.jpeg"
|
||||
alt="Блузы SHIK"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -top-4 -left-4 w-24 h-24 bg-primary/5 rounded-full -z-10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Категория Шорты IBIZA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="category-section"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 items-center">
|
||||
{/* Левая колонка - Фото */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src="/images/home/ibiza-shorts.jpeg"
|
||||
alt="Шорты IBIZA"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -left-4 w-24 h-24 bg-tertiary/10 rounded-full -z-10"></div>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Описание */}
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-serif text-primary mb-4 flex items-center">
|
||||
<Heart className="mr-3 h-6 w-6 text-tertiary" />
|
||||
Шорты IBIZA: Дух Авантюризма и Комфорт 🌪️
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-6 leading-relaxed">
|
||||
Для легких, смелых и игривых! Шорты IBIZA созданы для тех, кто ценит свободу и комфорт. Натуральные ткани (лён + вискоза) и универсальная длина делают их идеальными как для динамичной городской жизни, так и для расслабленного отдыха. Сильная и женственная эстетика в каждой детали. Добавьте куража в свой образ!
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:text-white hover:border-primary group relative overflow-hidden"
|
||||
>
|
||||
<Link href="/catalog?category=shorts">
|
||||
<span className="relative z-10">СМОТРЕТЬ КОЛЛЕКЦИЮ <ArrowRight className="ml-2 h-4 w-4 inline transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="absolute inset-0 bg-primary scale-x-0 origin-left transition-transform duration-300 group-hover:scale-x-100"></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Кнопка для перехода в каталог */}
|
||||
<div className="text-center mt-16">
|
||||
<Button
|
||||
<div className="text-center mt-20">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden"
|
||||
className="bg-primary text-white hover:bg-primary/90 group relative overflow-hidden px-8 py-6 text-lg"
|
||||
>
|
||||
<Link href="/catalog">
|
||||
<span className="relative z-10 flex items-center">СМОТРЕТЬ БОЛЬШЕ ТОВАРОВ <ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /></span>
|
||||
<span className="relative z-10 flex items-center">ПОЛНЫЙ КАТАЛОГ КОЛЛЕКЦИИ <ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" /></span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@ -492,35 +637,32 @@ export default function HomePage() {
|
||||
<section className="py-20 bg-tertiary/30 relative overflow-hidden">
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Как носить</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Вдохновение</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed italic">
|
||||
"Каждая деталь имеет значение — ткань, форма, цвет. Я создаю вещи, которые помогают женщинам чувствовать себя уверенно и комфортно, независимо от ситуации."
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4 font-medium">
|
||||
Алина, основательница бренда
|
||||
<span className="text-sm tracking-widest text-primary uppercase mb-4 inline-block">Ваш Стиль</span>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-primary mb-6">Вдохновение от Dressed for Success</h2>
|
||||
<p className="text-gray-700 mb-5 leading-relaxed">
|
||||
Мы верим, что стиль – это способ рассказать о себе миру. Не бойтесь проявлять себя и отражать свой характер через повседневные образы! Натуральные ткани и внимание к деталям делают каждую вещь особенной. 🔥
|
||||
</p>
|
||||
<p className="text-gray-700 mb-8">
|
||||
Сочетайте базовые модели с акцентными вещами для создания универсальных образов на каждый день.
|
||||
Сочетайте наши невесомые платья AIR с грубыми ботинками или элегантными лодочками. Носите сияющие блузы SHIK с классическими брюками для деловых встреч или с шортами IBIZA для создания яркого и дерзкого образа. Женственная, но сильная эстетика в каждом сочетании.
|
||||
</p>
|
||||
<Button
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary/5"
|
||||
>
|
||||
<Link href="/blog">
|
||||
<Link href="/catalog">
|
||||
СОВЕТЫ ПО СТИЛЮ
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@ -533,6 +675,8 @@ export default function HomePage() {
|
||||
alt="Lookbook"
|
||||
fill
|
||||
className="object-cover"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
</div>
|
||||
@ -542,13 +686,13 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* Подписка на рассылку */}
|
||||
<section className="py-24 bg-primary relative overflow-hidden">
|
||||
<section id="subscribe" className="py-24 bg-primary relative overflow-hidden">
|
||||
{/* Декоративные элементы */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -right-20 -top-20 w-80 h-80 rounded-full border border-white/10 opacity-30"></div>
|
||||
<div className="absolute -left-40 bottom-0 w-96 h-96 rounded-full border border-white/10 opacity-20"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<motion.div
|
||||
@ -557,32 +701,34 @@ export default function HomePage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-white mb-6">Будь первой</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-white mb-6">Будь первой ✨</h2>
|
||||
<p className="text-white/80 mb-8 leading-relaxed">
|
||||
Новые дропы, эксклюзивные предложения и полезные статьи о стиле
|
||||
Новые коллекции, эксклюзивные предложения и полезные статьи о стиле. Подпишитесь на наши социальные сети, чтобы быть в курсе всех новостей и получать вдохновение каждый день!
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 md:p-12">
|
||||
|
||||
{/* <div className="bg-white rounded-2xl shadow-lg p-8 md:p-12">
|
||||
<form className="flex flex-col md:flex-row gap-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Ваш email"
|
||||
className="flex-1 border-gray-200 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary text-white hover:bg-primary/90"
|
||||
>
|
||||
Подписаться
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-6 mt-12">
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
</div> */}
|
||||
|
||||
<div className="flex justify-center gap-10 mt-12">
|
||||
<Link href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold flex items-center gap-2">
|
||||
<ShoppingBag className="w-6 h-6" />
|
||||
Instagram
|
||||
</Link>
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
<Link href="https://t.me/" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors text-2xl md:text-3xl font-semibold flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
Telegram
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
322
frontend/app/(main)/privacy/page.tsx
Normal file
322
frontend/app/(main)/privacy/page.tsx
Normal file
@ -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 (
|
||||
<main className="overflow-hidden bg-white min-h-screen relative">
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-24 bg-gradient-to-b from-primary/10 to-white overflow-hidden">
|
||||
{/* Декоративные круги */}
|
||||
<div className="absolute -top-20 -left-20 w-80 h-80 bg-primary/10 rounded-full blur-3xl opacity-50"></div>
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-tertiary/20 rounded-full blur-3xl opacity-40"></div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="text-4xl md:text-5xl lg:text-6xl font-serif text-primary mb-6 text-center"
|
||||
>
|
||||
Политика конфиденциальности
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="text-lg md:text-xl text-gray-700 max-w-3xl mx-auto text-center mb-12"
|
||||
>
|
||||
Политика в отношении обработки персональных данных
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.3 }}
|
||||
className="max-w-4xl mx-auto space-y-10"
|
||||
>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">1. Общие положения</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. № 152-ФЗ «О персональных данных» (далее — Закон о персональных данных) и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые ИП Плотниковым Михаилом Владимировичем (далее — Оператор).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее — Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">2. Основные понятия, используемые в Политике</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
2.1. Автоматизированная обработка персональных данных — обработка персональных данных с помощью средств вычислительной техники.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
2.2. Блокирование персональных данных — временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4 leading-relaxed">
|
||||
2.3. Веб-сайт — совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных и обеспечивающих их обработку информационных технологий и технических средств.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.6. Обработка персональных данных — любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.7. Оператор — государственный орган, муниципальный орган, юридическое или физическое лицо, самостоятельно или совместно с другими лицами организующие и/или осуществляющие обработку персональных данных, а также определяющие цели обработки персональных данных, состав персональных данных, подлежащих обработке, действия (операции), совершаемые с персональными данными.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.8. Персональные данные — любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.9. Персональные данные, разрешенные субъектом персональных данных для распространения, — персональные данные, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных путем дачи согласия на обработку персональных данных, разрешенных субъектом персональных данных для распространения в порядке, предусмотренном Законом о персональных данных (далее — персональные данные, разрешенные для распространения).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.10. Пользователь — любой посетитель веб-сайта https://dressedforsuccess.shop.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.11. Предоставление персональных данных — действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.12. Распространение персональных данных — любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц (передача персональных данных) или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.13. Трансграничная передача персональных данных — передача персональных данных на территорию иностранного государства органу власти иностранного государства, иностранному физическому или иностранному юридическому лицу.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
2.14. Уничтожение персональных данных — любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и/или уничтожаются материальные носители персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">3. Основные права и обязанности Оператора</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
3.1. Оператор имеет право:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>получать от субъекта персональных данных достоверные информацию и/или документы, содержащие персональные данные;</li>
|
||||
<li>в случае отзыва субъектом персональных данных согласия на обработку персональных данных, а также, направления обращения с требованием о прекращении обработки персональных данных, Оператор вправе продолжить обработку персональных данных без согласия субъекта персональных данных при наличии оснований, указанных в Законе о персональных данных;</li>
|
||||
<li>самостоятельно определять состав и перечень мер, необходимых и достаточных для обеспечения выполнения обязанностей, предусмотренных Законом о персональных данных и принятыми в соответствии с ним нормативными правовыми актами, если иное не предусмотрено Законом о персональных данных или другими федеральными законами.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
3.2. Оператор обязан:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>предоставлять субъекту персональных данных по его просьбе информацию, касающуюся обработки его персональных данных;</li>
|
||||
<li>организовывать обработку персональных данных в порядке, установленном действующим законодательством РФ;</li>
|
||||
<li>отвечать на обращения и запросы субъектов персональных данных и их законных представителей в соответствии с требованиями Закона о персональных данных;</li>
|
||||
<li>сообщать в уполномоченный орган по защите прав субъектов персональных данных по запросу этого органа необходимую информацию в течение 10 дней с даты получения такого запроса;</li>
|
||||
<li>публиковать или иным образом обеспечивать неограниченный доступ к настоящей Политике в отношении обработки персональных данных;</li>
|
||||
<li>принимать правовые, организационные и технические меры для защиты персональных данных от неправомерного или случайного доступа к ним, уничтожения, изменения, блокирования, копирования, предоставления, распространения персональных данных, а также от иных неправомерных действий в отношении персональных данных;</li>
|
||||
<li>прекратить передачу (распространение, предоставление, доступ) персональных данных, прекратить обработку и уничтожить персональные данные в порядке и случаях, предусмотренных Законом о персональных данных;</li>
|
||||
<li>исполнять иные обязанности, предусмотренные Законом о персональных данных.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">4. Основные права и обязанности субъектов персональных данных</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
4.1. Субъекты персональных данных имеют право:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>получать информацию, касающуюся обработки его персональных данных, за исключением случаев, предусмотренных федеральными законами;</li>
|
||||
<li>требовать от оператора уточнения его персональных данных, их блокирования или уничтожения в случае, если персональные данные являются неполными, устаревшими, неточными, незаконно полученными или не являются необходимыми для заявленной цели обработки;</li>
|
||||
<li>выдвигать условие предварительного согласия при обработке персональных данных в целях продвижения на рынке товаров, работ и услуг;</li>
|
||||
<li>на отзыв согласия на обработку персональных данных;</li>
|
||||
<li>обжаловать в уполномоченный орган по защите прав субъектов персональных данных или в судебном порядке неправомерные действия или бездействие Оператора при обработке его персональных данных;</li>
|
||||
<li>на осуществление иных прав, предусмотренных законодательством РФ.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
4.2. Субъекты персональных данных обязаны:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mt-4 space-y-3 text-gray-700 leading-relaxed">
|
||||
<li>предоставлять Оператору достоверные данные о себе;</li>
|
||||
<li>сообщать Оператору об уточнении (обновлении, изменении) своих персональных данных.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-6 leading-relaxed">
|
||||
4.3. Лица, передавшие Оператору недостоверные сведения о себе, либо сведения о другом субъекте персональных данных без согласия последнего, несут ответственность в соответствии с законодательством РФ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">5. Принципы обработки персональных данных</h2>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
5.1. Обработка персональных данных осуществляется на законной и справедливой основе.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.2. Обработка персональных данных ограничивается достижением конкретных, заранее определенных и законных целей. Не допускается обработка персональных данных, несовместимая с целями сбора персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.3. Не допускается объединение баз данных, содержащих персональные данные, обработка которых осуществляется в целях, несовместимых между собой.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.4. Обработке подлежат только персональные данные, которые отвечают целям их обработки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.5. Содержание и объем обрабатываемых персональных данных соответствуют заявленным целям обработки. Не допускается избыточность обрабатываемых персональных данных по отношению к заявленным целям их обработки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.6. При обработке персональных данных обеспечивается точность персональных данных, их достаточность, а в необходимых случаях и актуальность по отношению к целям обработки персональных данных. Оператор принимает необходимые меры и/или обеспечивает их принятие по удалению или уточнению неполных или неточных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
5.7. Хранение персональных данных осуществляется в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных. Обрабатываемые персональные данные уничтожаются либо обезличиваются по достижении целей обработки или в случае утраты необходимости в достижении этих целей, если иное не предусмотрено федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">6. Цели обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Цель обработки — уточнение деталей заказа, обработка заказов, доставка товаров, информирование о статусе заказа, отправка информационных писем.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Персональные данные — фамилия, имя, отчество; электронный адрес; номера телефонов; адрес доставки.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Правовые основания — Федеральный закон «Об информации, информационных технологиях и о защите информации» от 27.07.2006 N 149-ФЗ
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Виды обработки персональных данных — Сбор, запись, систематизация, накопление, хранение, уничтожение и обезличивание персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">7. Условия обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
7.1. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.2. Обработка персональных данных необходима для достижения целей, предусмотренных международным договором Российской Федерации или законом, для осуществления возложенных законодательством Российской Федерации на оператора функций, полномочий и обязанностей.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.3. Обработка персональных данных необходима для осуществления правосудия, исполнения судебного акта, акта другого органа или должностного лица, подлежащих исполнению в соответствии с законодательством Российской Федерации об исполнительном производстве.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.4. Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем или поручителем.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.5. Обработка персональных данных необходима для осуществления прав и законных интересов оператора или третьих лиц либо для достижения общественно значимых целей при условии, что при этом не нарушаются права и свободы субъекта персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.6. Осуществляется обработка персональных данных, доступ неограниченного круга лиц к которым предоставлен субъектом персональных данных либо по его просьбе (далее — общедоступные персональные данные).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
7.7. Осуществляется обработка персональных данных, подлежащих опубликованию или обязательному раскрытию в соответствии с федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">8. Порядок сбора, хранения, передачи и других видов обработки персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Безопасность персональных данных, которые обрабатываются Оператором, обеспечивается путем реализации правовых, организационных и технических мер, необходимых для выполнения в полном объеме требований действующего законодательства в области защиты персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.1. Оператор обеспечивает сохранность персональных данных и принимает все возможные меры, исключающие доступ к персональным данным неуполномоченных лиц.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.2. Персональные данные Пользователя никогда, ни при каких условиях не будут переданы третьим лицам, за исключением случаев, связанных с исполнением действующего законодательства либо в случае, если субъектом персональных данных дано согласие Оператору на передачу данных третьему лицу для исполнения обязательств по гражданско-правовому договору.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.3. В случае выявления неточностей в персональных данных, Пользователь может актуализировать их самостоятельно, путем направления Оператору уведомление на адрес электронной почты Оператора Pmv-84@yandex.ru с пометкой «Актуализация персональных данных».
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.4. Срок обработки персональных данных определяется достижением целей, для которых были собраны персональные данные, если иной срок не предусмотрен договором или действующим законодательством.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
Пользователь может в любой момент отозвать свое согласие на обработку персональных данных, направив Оператору уведомление посредством электронной почты на электронный адрес Оператора Pmv-84@yandex.ru с пометкой «Отзыв согласия на обработку персональных данных».
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.5. Вся информация, которая собирается сторонними сервисами, в том числе платежными системами, средствами связи и другими поставщиками услуг, хранится и обрабатывается указанными лицами (Операторами) в соответствии с их Пользовательским соглашением и Политикой конфиденциальности. Субъект персональных данных и/или с указанными документами. Оператор не несет ответственность за действия третьих лиц, в том числе указанных в настоящем пункте поставщиков услуг.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.6. Установленные субъектом персональных данных запреты на передачу (кроме предоставления доступа), а также на обработку или условия обработки (кроме получения доступа) персональных данных, разрешенных для распространения, не действуют в случаях обработки персональных данных в государственных, общественных и иных публичных интересах, определенных законодательством РФ.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.7. Оператор при обработке персональных данных обеспечивает конфиденциальность персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.8. Оператор осуществляет хранение персональных данных в форме, позволяющей определить субъекта персональных данных, не дольше, чем этого требуют цели обработки персональных данных, если срок хранения персональных данных не установлен федеральным законом, договором, стороной которого, выгодоприобретателем или поручителем по которому является субъект персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
8.9. Условием прекращения обработки персональных данных может являться достижение целей обработки персональных данных, истечение срока действия согласия субъекта персональных данных, отзыв согласия субъектом персональных данных или требование о прекращении обработки персональных данных, а также выявление неправомерной обработки персональных данных.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">9. Перечень действий, производимых Оператором с полученными персональными данными</h2>
|
||||
<p className="text-gray-700">
|
||||
9.1. Оператор осуществляет сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление и уничтожение персональных данных.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
9.2. Оператор осуществляет автоматизированную обработку персональных данных с получением и/или передачей полученной информации по информационно-телекоммуникационным сетям или без таковой.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">10. Трансграничная передача персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
10.1. Оператор до начала осуществления деятельности по трансграничной передаче персональных данных обязан уведомить уполномоченный орган по защите прав субъектов персональных данных о своем намерении осуществлять трансграничную передачу персональных данных (такое уведомление направляется отдельно от уведомления о намерении осуществлять обработку персональных данных).
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
10.2. Оператор до подачи вышеуказанного уведомления, обязан получить от органов власти иностранного государства, иностранных физических лиц, иностранных юридических лиц, которым планируется трансграничная передача персональных данных, соответствующие сведения.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">11. Конфиденциальность персональных данных</h2>
|
||||
<p className="text-gray-700">
|
||||
Оператор и иные лица, получившие доступ к персональным данным, обязаны не раскрывать третьим лицам и не распространять персональные данные без согласия субъекта персональных данных, если иное не предусмотрено федеральным законом.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h2 className="font-semibold text-xl md:text-2xl mb-4 text-primary">12. Заключительные положения</h2>
|
||||
<p className="text-gray-700">
|
||||
12.1. Пользователь может получить любые разъяснения по интересующим вопросам, касающимся обработки его персональных данных, обратившись к Оператору с помощью электронной почты Pmv-84@yandex.ru.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
12.2. В данном документе будут отражены любые изменения политики обработки персональных данных Оператором. Политика действует бессрочно до замены ее новой версией.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-4">
|
||||
12.3. Актуальная версия Политики в свободном доступе расположена в сети Интернет по адресу https://dressedforsuccess.shop/privacy.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Кнопка возврата на главную */}
|
||||
<section className="py-12 bg-tertiary/5">
|
||||
<div className="container mx-auto px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.6 }}
|
||||
className="text-center"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary text-white rounded-full hover:bg-primary/90 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] bg-gray-100">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/80 to-primary/40"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full container mx-auto px-4 flex flex-col justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl text-white"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">Таблица размеров</h1>
|
||||
<p className="text-xl opacity-90">Подберите идеальный размер для вашей фигуры</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Как правильно снять мерки</h2>
|
||||
<p className="text-gray-700 max-w-2xl mx-auto">
|
||||
Чтобы подобрать идеальный размер, важно правильно снять мерки. Следуйте нашим рекомендациям для
|
||||
получения точных измерений.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12 mb-16">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary mb-4">Советы по измерению</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Используйте мягкую измерительную ленту</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Для точных измерений используйте мягкую портновскую ленту.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Измеряйте в нижнем белье</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Для получения точных измерений снимайте мерки в облегающем нижнем белье.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Держите ленту параллельно полу</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
При измерении обхватов следите, чтобы лента была параллельна полу.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<Ruler className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Не затягивайте ленту слишком туго</p>
|
||||
<p className="text-gray-600 text-sm">Лента должна прилегать к телу, но не стягивать его.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary mb-4">Что измерять</h3>
|
||||
<ul className="space-y-4">
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват груди</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте по наиболее выступающим точкам груди, лента должна проходить через лопатки.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват талии</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте по самой узкой части талии, обычно на 2-3 см выше пупка.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Обхват бедер</p>
|
||||
<p className="text-gray-600 text-sm">Измерьте по самым выступающим точкам ягодиц.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<div className="bg-tertiary p-2 rounded-full mr-3 mt-0.5">
|
||||
<span className="text-primary font-bold text-sm">4</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Рост</p>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Измерьте расстояние от макушки до пола, стоя прямо без обуви.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100 mb-16">
|
||||
<h3 className="text-2xl font-bold text-primary mb-6">Подберите свой размер</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<Label className="mb-2 block">Выберите пол</Label>
|
||||
<RadioGroup value={gender} onValueChange={setGender} className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="women" id="women" />
|
||||
<Label htmlFor="women">Женщины</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="men" id="men" />
|
||||
<Label htmlFor="men">Мужчины</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label className="mb-2 block">Единицы измерения</Label>
|
||||
<RadioGroup value={measurementSystem} onValueChange={setMeasurementSystem} className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="cm" id="cm" />
|
||||
<Label htmlFor="cm">Сантиметры</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inch" id="inch" />
|
||||
<Label htmlFor="inch">Дюймы</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="height">Рост</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "170" : "67"}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="weight">Вес</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "60" : "132"}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "кг" : "lb"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bust">Обхват груди</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="bust"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "90" : "35"}
|
||||
value={bust}
|
||||
onChange={(e) => setBust(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="waist">Обхват талии</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="waist"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "70" : "28"}
|
||||
value={waist}
|
||||
onChange={(e) => setWaist(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hips">Обхват бедер</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="hips"
|
||||
type="number"
|
||||
placeholder={measurementSystem === "cm" ? "95" : "37"}
|
||||
value={hips}
|
||||
onChange={(e) => setHips(e.target.value)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center text-gray-500">
|
||||
{measurementSystem === "cm" ? "см" : "in"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button className="bg-primary hover:bg-secondary rounded-full" onClick={calculateSize}>
|
||||
Рассчитать мой размер
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary text-primary hover:bg-primary hover:text-white rounded-full"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recommendedSize && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-tertiary/50 p-6 rounded-xl flex items-start"
|
||||
>
|
||||
<Info className="h-6 w-6 mr-3 mt-0.5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-1">Ваш рекомендуемый размер: {recommendedSize}</h3>
|
||||
<p className="text-gray-700">
|
||||
Это приблизительный размер, основанный на ваших измерениях. Размеры могут отличаться в
|
||||
зависимости от модели и фасона.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-primary mb-6">Таблицы размеров</h3>
|
||||
|
||||
<Tabs defaultValue="women" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 mb-6">
|
||||
<TabsTrigger value="women">Женская одежда</TabsTrigger>
|
||||
<TabsTrigger value="men">Мужская одежда</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="women" className="pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-tertiary/50">
|
||||
<th className="border border-gray-200 p-3 text-left">Российский размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Международный размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват груди (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват талии (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват бедер (см)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">40-42</td>
|
||||
<td className="border border-gray-200 p-3">XS</td>
|
||||
<td className="border border-gray-200 p-3">80-84</td>
|
||||
<td className="border border-gray-200 p-3">62-66</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">42-44</td>
|
||||
<td className="border border-gray-200 p-3">S</td>
|
||||
<td className="border border-gray-200 p-3">84-88</td>
|
||||
<td className="border border-gray-200 p-3">66-70</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">44-46</td>
|
||||
<td className="border border-gray-200 p-3">M</td>
|
||||
<td className="border border-gray-200 p-3">88-92</td>
|
||||
<td className="border border-gray-200 p-3">70-74</td>
|
||||
<td className="border border-gray-200 p-3">94-98</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">46-48</td>
|
||||
<td className="border border-gray-200 p-3">L</td>
|
||||
<td className="border border-gray-200 p-3">92-96</td>
|
||||
<td className="border border-gray-200 p-3">74-78</td>
|
||||
<td className="border border-gray-200 p-3">98-102</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">48-50</td>
|
||||
<td className="border border-gray-200 p-3">XL</td>
|
||||
<td className="border border-gray-200 p-3">96-100</td>
|
||||
<td className="border border-gray-200 p-3">78-82</td>
|
||||
<td className="border border-gray-200 p-3">102-106</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="men" className="pt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-tertiary/50">
|
||||
<th className="border border-gray-200 p-3 text-left">Российский размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Международный размер</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват груди (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват талии (см)</th>
|
||||
<th className="border border-gray-200 p-3 text-left">Обхват шеи (см)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">44-46</td>
|
||||
<td className="border border-gray-200 p-3">XS</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
<td className="border border-gray-200 p-3">74-78</td>
|
||||
<td className="border border-gray-200 p-3">36-37</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">46-48</td>
|
||||
<td className="border border-gray-200 p-3">S</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
<td className="border border-gray-200 p-3">78-82</td>
|
||||
<td className="border border-gray-200 p-3">37-38</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">48-50</td>
|
||||
<td className="border border-gray-200 p-3">M</td>
|
||||
<td className="border border-gray-200 p-3">94-98</td>
|
||||
<td className="border border-gray-200 p-3">82-86</td>
|
||||
<td className="border border-gray-200 p-3">38-39</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="border border-gray-200 p-3">50-52</td>
|
||||
<td className="border border-gray-200 p-3">L</td>
|
||||
<td className="border border-gray-200 p-3">98-102</td>
|
||||
<td className="border border-gray-200 p-3">86-90</td>
|
||||
<td className="border border-gray-200 p-3">39-40</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 p-3">52-54</td>
|
||||
<td className="border border-gray-200 p-3">XL</td>
|
||||
<td className="border border-gray-200 p-3">102-106</td>
|
||||
<td className="border border-gray-200 p-3">90-94</td>
|
||||
<td className="border border-gray-200 p-3">40-41</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 bg-tertiary/30">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-bold text-primary mb-4">Нужна помощь с выбором размера?</h2>
|
||||
<p className="text-gray-700 mb-8 max-w-2xl mx-auto">
|
||||
Если у вас остались вопросы или вам нужна дополнительная помощь с выбором размера, наши консультанты всегда
|
||||
готовы помочь.
|
||||
</p>
|
||||
<Button asChild className="bg-primary hover:bg-secondary rounded-full px-8">
|
||||
<a href="/contact">Связаться с нами</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-semibold mb-8 text-center">Таблица размеров</h1>
|
||||
<SizeTable />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/app/.DS_Store
vendored
BIN
frontend/app/.DS_Store
vendored
Binary file not shown.
11
frontend/app/LazyBgLoader.tsx
Normal file
11
frontend/app/LazyBgLoader.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function LazyBgLoader() {
|
||||
useEffect(() => {
|
||||
import("./lazy-background");
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -2,10 +2,23 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag } from 'lucide-react';
|
||||
import { fetchDashboardStats, fetchRecentOrders, fetchPopularProducts } from '@/lib/admin-api';
|
||||
import { BarChart3, Package, Users, ShoppingBag } from 'lucide-react';
|
||||
import { Order } from '@/lib/orders';
|
||||
import { Product } from '@/lib/catalog';
|
||||
import api from '@/lib/api';
|
||||
import useAdminApi from '@/hooks/useAdminApi';
|
||||
import useAdminCache from '@/hooks/useAdminCache';
|
||||
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
|
||||
import AdminLoadingState from '@/components/admin/AdminLoadingState';
|
||||
|
||||
// Интерфейс статистики для дашборда
|
||||
interface DashboardStats {
|
||||
ordersCount: number;
|
||||
totalSales: number;
|
||||
customersCount: number;
|
||||
productsCount: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Компонент статистической карточки
|
||||
interface StatCardProps {
|
||||
@ -38,27 +51,15 @@ interface RecentOrdersProps {
|
||||
|
||||
const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AdminLoadingState message="Загрузка заказов..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке заказов: {error}</div>
|
||||
</div>
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки заказов"
|
||||
message={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -88,21 +89,21 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
|
||||
order.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
|
||||
order.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'}`}>
|
||||
{order.status === 'delivered' ? 'Доставлен' :
|
||||
order.status === 'processing' ? 'В обработке' :
|
||||
order.status === 'shipped' ? 'Отправлен' :
|
||||
order.status === 'paid' ? 'Оплачен' :
|
||||
{order.status === 'delivered' ? 'Доставлен' :
|
||||
order.status === 'processing' ? 'В обработке' :
|
||||
order.status === 'shipped' ? 'Отправлен' :
|
||||
order.status === 'paid' ? 'Оплачен' :
|
||||
order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{order.total !== undefined && order.total !== null
|
||||
? `${order.total.toLocaleString('ru-RU')} ₽`
|
||||
{order.total !== undefined && order.total !== null
|
||||
? `${order.total.toLocaleString('ru-RU')} ₽`
|
||||
: 'Н/Д'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
@ -140,27 +141,15 @@ interface PopularProductsProps {
|
||||
|
||||
const PopularProducts = ({ products, loading, error }: PopularProductsProps) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AdminLoadingState message="Загрузка товаров..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке товаров: {error}</div>
|
||||
</div>
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки товаров"
|
||||
message={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -187,9 +176,9 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.category?.name || 'Без категории'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{typeof product.sales === 'number' ? product.sales : 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${typeof product.stock === 'number' && product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
typeof product.stock === 'number' && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${typeof product.stock === 'number' && product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
typeof product.stock === 'number' && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{typeof product.stock === 'number' ? product.stock : 'Н/Д'}
|
||||
</span>
|
||||
@ -221,304 +210,204 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
// Используем кэш для статистики
|
||||
const statsCache = useAdminCache<DashboardStats>({
|
||||
key: 'dashboard-stats',
|
||||
ttl: 5 * 60 * 1000, // 5 минут
|
||||
});
|
||||
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||
const [popularProducts, setPopularProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState({
|
||||
stats: true,
|
||||
orders: true,
|
||||
products: true
|
||||
|
||||
// Используем кэш для заказов
|
||||
const ordersCache = useAdminCache<Order[]>({
|
||||
key: 'recent-orders',
|
||||
ttl: 2 * 60 * 1000, // 2 минуты
|
||||
});
|
||||
const [error, setError] = useState<{
|
||||
stats: string | null;
|
||||
orders: string | null;
|
||||
products: string | null;
|
||||
}>({
|
||||
stats: null,
|
||||
orders: null,
|
||||
products: null
|
||||
|
||||
// Используем кэш для товаров
|
||||
const productsCache = useAdminCache<Product[]>({
|
||||
key: 'popular-products',
|
||||
ttl: 5 * 60 * 1000, // 5 минут
|
||||
});
|
||||
|
||||
// API для статистики
|
||||
const statsApi = useAdminApi({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
// Нормализуем данные статистики
|
||||
const normalizedStats = {
|
||||
ordersCount: typeof data.ordersCount === 'number' ? data.ordersCount : 0,
|
||||
totalSales: typeof data.totalSales === 'number' ? data.totalSales : 0,
|
||||
customersCount: typeof data.customersCount === 'number' ? data.customersCount : 0,
|
||||
productsCount: typeof data.productsCount === 'number' ? data.productsCount : 0
|
||||
};
|
||||
statsCache.setData(normalizedStats);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// API для заказов
|
||||
const ordersApi = useAdminApi({
|
||||
onSuccess: (data) => {
|
||||
if (data && Array.isArray(data)) {
|
||||
// Нормализуем данные заказов
|
||||
const normalizedOrders = data.map(order => ({
|
||||
id: order.id || 0,
|
||||
user_id: order.user_id || 0,
|
||||
user_name: order.user_name || '',
|
||||
created_at: order.created_at || new Date().toISOString(),
|
||||
status: order.status || 'processing',
|
||||
total: typeof order.total === 'number' ? order.total : 0
|
||||
}));
|
||||
ordersCache.setData(normalizedOrders);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// API для товаров
|
||||
const productsApi = useAdminApi({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
let productsArray = Array.isArray(data) ? data :
|
||||
(data.products && Array.isArray(data.products)) ? data.products : [];
|
||||
|
||||
// Нормализуем данные товаров
|
||||
const normalizedProducts = productsArray.map(product => {
|
||||
// Определяем stock как сумму остатков всех вариантов, если они есть
|
||||
let totalStock = 0;
|
||||
if (product.variants && Array.isArray(product.variants)) {
|
||||
totalStock = product.variants.reduce((sum: number, variant: any) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
} else if (typeof product.stock === 'number') {
|
||||
totalStock = product.stock;
|
||||
}
|
||||
|
||||
// Создаем корректный объект Product
|
||||
return {
|
||||
id: product.id || 0,
|
||||
name: product.name || 'Без названия',
|
||||
category: product.category ||
|
||||
(product.category_id ? { id: product.category_id, name: 'Категория ' + product.category_id } :
|
||||
{ id: 0, name: 'Без категории' }),
|
||||
category_id: product.category_id,
|
||||
sales: typeof product.sales === 'number' ? product.sales : 0,
|
||||
stock: totalStock,
|
||||
price: typeof product.price === 'number' ? product.price : 0,
|
||||
images: product.images && Array.isArray(product.images) ?
|
||||
product.images.map((img: any) => img.image_url) : [],
|
||||
description: product.description || ''
|
||||
};
|
||||
});
|
||||
|
||||
productsCache.setData(normalizedProducts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка данных при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, stats: true }));
|
||||
let statsData;
|
||||
|
||||
try {
|
||||
// Пытаемся получить данные от API
|
||||
statsData = await fetchDashboardStats();
|
||||
console.log('Полученные данные статистики от API:', JSON.stringify(statsData, null, 2));
|
||||
|
||||
// Проверяем наличие данных
|
||||
if (!statsData.data) {
|
||||
throw new Error('Данные статистики отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем структуру данных
|
||||
let statsObject = statsData.data;
|
||||
if (statsData.data && 'data' in statsData.data) {
|
||||
statsObject = (statsData.data as any).data;
|
||||
}
|
||||
|
||||
// Устанавливаем объект статистики
|
||||
statsData = {
|
||||
data: statsObject,
|
||||
status: statsData.status
|
||||
};
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка при получении данных от API, используем моковые данные:', apiError);
|
||||
|
||||
// Если API недоступен, используем моковые данные
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
statsData = {
|
||||
data: {
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
},
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
if (statsData.data) {
|
||||
// Проверяем, что все нужные поля существуют в данных
|
||||
const safeStats = {
|
||||
ordersCount: typeof statsData.data.ordersCount === 'number' ? statsData.data.ordersCount : 0,
|
||||
totalSales: typeof statsData.data.totalSales === 'number' ? statsData.data.totalSales : 0,
|
||||
customersCount: typeof statsData.data.customersCount === 'number' ? statsData.data.customersCount : 0,
|
||||
productsCount: typeof statsData.data.productsCount === 'number' ? statsData.data.productsCount : 0
|
||||
};
|
||||
setStats(safeStats);
|
||||
console.log('Установленные данные статистики:', safeStats);
|
||||
} else {
|
||||
console.warn('Данные статистики отсутствуют или имеют неверный формат:', statsData);
|
||||
}
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке статистики:', err);
|
||||
setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' }));
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}
|
||||
// Загружаем статистику
|
||||
await statsCache.loadData(async () => {
|
||||
const response = await statsApi.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return response || {
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, orders: true }));
|
||||
let ordersData;
|
||||
|
||||
try {
|
||||
ordersData = await fetchRecentOrders({ limit: 4 });
|
||||
console.log('Получены данные заказов:', JSON.stringify(ordersData, null, 2));
|
||||
|
||||
// Проверяем структуру данных
|
||||
if (ordersData.data === null || ordersData.data === undefined) {
|
||||
throw new Error('Данные заказов отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем, если данные приходят в другой структуре
|
||||
let ordersArray: Order[] = [];
|
||||
if (Array.isArray(ordersData.data)) {
|
||||
ordersArray = ordersData.data;
|
||||
} else if (ordersData.data && typeof ordersData.data === 'object' && 'items' in ordersData.data) {
|
||||
ordersArray = (ordersData.data as any).items;
|
||||
}
|
||||
|
||||
console.log('Массив заказов:', ordersArray);
|
||||
|
||||
// Если есть данные, но пустой массив - используем моковые данные
|
||||
if (ordersArray.length === 0) {
|
||||
throw new Error('Массив заказов пуст');
|
||||
}
|
||||
|
||||
// Устанавливаем массив заказов
|
||||
ordersData = {
|
||||
data: ordersArray,
|
||||
status: ordersData.status
|
||||
};
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка API заказов, используем моковые данные:', apiError);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Моковые данные заказов
|
||||
ordersData = {
|
||||
data: [
|
||||
{ id: 1, user_id: 101, user_name: 'Иван Иванов', created_at: '2023-03-15T14:30:00Z', status: 'delivered', total: 12500 },
|
||||
{ id: 2, user_id: 102, user_name: 'Анна Петрова', created_at: '2023-03-14T10:15:00Z', status: 'shipped', total: 8750 },
|
||||
{ id: 3, user_id: 103, user_name: 'Сергей Сидоров', created_at: '2023-03-13T18:45:00Z', status: 'processing', total: 15200 },
|
||||
{ id: 4, user_id: 104, user_name: 'Елена Смирнова', created_at: '2023-03-12T09:20:00Z', status: 'paid', total: 6300 }
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
if (ordersData && ordersData.data && Array.isArray(ordersData.data)) {
|
||||
// Нормализуем данные заказов, убедившись, что все необходимые поля существуют
|
||||
const normalizedOrders = ordersData.data.map(order => ({
|
||||
id: order.id || 0,
|
||||
user_id: order.user_id || 0,
|
||||
user_name: order.user_name || '',
|
||||
created_at: order.created_at || new Date().toISOString(),
|
||||
status: order.status || 'processing',
|
||||
total: typeof order.total === 'number' ? order.total : 0
|
||||
}));
|
||||
|
||||
setRecentOrders(normalizedOrders);
|
||||
} else {
|
||||
console.warn('Некорректные данные заказов:', ordersData);
|
||||
setRecentOrders([]);
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' }));
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
}
|
||||
// Загружаем последние заказы
|
||||
await ordersCache.loadData(async () => {
|
||||
const response = await ordersApi.get<Order[]>('/admin/orders/recent', {
|
||||
limit: 4,
|
||||
sort_by: 'created_at',
|
||||
sort_dir: 'desc'
|
||||
});
|
||||
return response || [];
|
||||
});
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
let productsData;
|
||||
|
||||
try {
|
||||
productsData = await fetchPopularProducts({ limit: 4 });
|
||||
console.log('Получены данные товаров:', JSON.stringify(productsData, null, 2));
|
||||
|
||||
// Проверяем структуру данных
|
||||
if (!productsData.data && !Array.isArray(productsData)) {
|
||||
throw new Error('Данные товаров отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем формат ответа - может быть как data.items, data[] или просто []
|
||||
let productsArray: any[] = [];
|
||||
|
||||
if (Array.isArray(productsData)) {
|
||||
// Ответ сразу в виде массива товаров без обертки
|
||||
productsArray = productsData;
|
||||
} else if (productsData.data) {
|
||||
if (Array.isArray(productsData.data)) {
|
||||
// Ответ в виде { data: [...] }
|
||||
productsArray = productsData.data;
|
||||
} else if (typeof productsData.data === 'object' && productsData.data !== null &&
|
||||
'items' in (productsData.data as Record<string, any>) &&
|
||||
Array.isArray((productsData.data as Record<string, any>).items)) {
|
||||
// Ответ в виде { data: { items: [...] } }
|
||||
productsArray = (productsData.data as Record<string, any>).items;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Обработанный массив товаров:', productsArray);
|
||||
|
||||
// Если массив пустой, используем моковые данные
|
||||
if (!productsArray || productsArray.length === 0) {
|
||||
throw new Error('Массив товаров пуст');
|
||||
}
|
||||
|
||||
// Преобразуем данные в нужный формат для отображения
|
||||
const formattedProducts = productsArray.map(product => {
|
||||
// Определяем stock как сумму остатков всех вариантов, если они есть
|
||||
let totalStock = 0;
|
||||
if (product.variants && Array.isArray(product.variants)) {
|
||||
totalStock = product.variants.reduce((sum: number, variant: any) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
} else if (typeof product.stock === 'number') {
|
||||
totalStock = product.stock;
|
||||
}
|
||||
|
||||
// Создаем корректный объект Product
|
||||
return {
|
||||
id: product.id || 0,
|
||||
name: product.name || 'Без названия',
|
||||
category: product.category ||
|
||||
(product.category_id ? { id: product.category_id, name: 'Категория ' + product.category_id } :
|
||||
{ id: 0, name: 'Без категории' }),
|
||||
category_id: product.category_id,
|
||||
sales: typeof product.sales === 'number' ? product.sales : 0,
|
||||
stock: totalStock,
|
||||
price: typeof product.price === 'number' ? product.price : 0,
|
||||
images: product.images && Array.isArray(product.images) ?
|
||||
product.images.map((img: any) => img.image_url) : [],
|
||||
description: product.description || ''
|
||||
};
|
||||
});
|
||||
|
||||
setPopularProducts(formattedProducts);
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка API товаров, используем моковые данные:', apiError);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Моковые данные товаров
|
||||
const mockProducts = [
|
||||
{ id: 1, name: 'Платье летнее', category: { id: 2, name: 'Платья' }, sales: 42, stock: 28, price: 3500 },
|
||||
{ id: 2, name: 'Брюки классические', category: { id: 7, name: 'Брюки' }, sales: 38, stock: 15, price: 4200 },
|
||||
{ id: 3, name: 'Блузка шелковая', category: { id: 3, name: 'Блузки' }, sales: 35, stock: 10, price: 2800 },
|
||||
{ id: 4, name: 'Рубашка льняная', category: { id: 6, name: 'Рубашки' }, sales: 30, stock: 22, price: 3100 }
|
||||
];
|
||||
|
||||
setPopularProducts(mockProducts);
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' }));
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
// Загружаем популярные товары
|
||||
await productsCache.loadData(async () => {
|
||||
const response = await productsApi.get<Product[]>('/admin/products/popular', {
|
||||
limit: 4
|
||||
});
|
||||
return response || [];
|
||||
});
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
// Получаем данные из кэша
|
||||
const stats = statsCache.data || {
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
};
|
||||
|
||||
const recentOrders = ordersCache.data || [];
|
||||
const popularProducts = productsCache.data || [];
|
||||
|
||||
// Состояние загрузки
|
||||
const loading = {
|
||||
stats: statsApi.isLoading || statsCache.isLoading,
|
||||
orders: ordersApi.isLoading || ordersCache.isLoading,
|
||||
products: productsApi.isLoading || productsCache.isLoading
|
||||
};
|
||||
|
||||
// Состояние ошибок
|
||||
const error = {
|
||||
stats: statsApi.error,
|
||||
orders: ordersApi.error,
|
||||
products: productsApi.error
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
||||
|
||||
|
||||
{/* Статистические карточки */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? '...' : (stats.ordersCount !== undefined ? stats.ordersCount.toLocaleString('ru-RU') : '0')}
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? '...' : stats.ordersCount.toLocaleString('ru-RU')}
|
||||
icon={<ShoppingBag size={24} className="text-white" />}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Общие продажи"
|
||||
value={loading.stats ? '...' : (stats.totalSales !== undefined ? `${stats.totalSales.toLocaleString('ru-RU')} ₽` : '0 ₽')}
|
||||
<StatCard
|
||||
title="Общие продажи"
|
||||
value={loading.stats ? '...' : `${stats.totalSales.toLocaleString('ru-RU')} ₽`}
|
||||
icon={<BarChart3 size={24} className="text-white" />}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Клиенты"
|
||||
value={loading.stats ? '...' : (stats.customersCount !== undefined ? stats.customersCount.toLocaleString('ru-RU') : '0')}
|
||||
<StatCard
|
||||
title="Клиенты"
|
||||
value={loading.stats ? '...' : stats.customersCount.toLocaleString('ru-RU')}
|
||||
icon={<Users size={24} className="text-white" />}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Товары"
|
||||
value={loading.stats ? '...' : (stats.productsCount !== undefined ? stats.productsCount.toLocaleString('ru-RU') : '0')}
|
||||
<StatCard
|
||||
title="Товары"
|
||||
value={loading.stats ? '...' : stats.productsCount.toLocaleString('ru-RU')}
|
||||
icon={<Package size={24} className="text-white" />}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Последние заказы и популярные товары */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
/>
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,586 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Loader2,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { formatDistance, format, subDays, startOfDay, endOfDay, subMonths } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
import { DateRange } from 'react-day-picker';
|
||||
|
||||
// UI компоненты
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import {
|
||||
Badge,
|
||||
} from '@/components/ui/badge';
|
||||
|
||||
// API и типы
|
||||
import api, { ApiResponse } from '@/lib/api';
|
||||
|
||||
// Расширенный интерфейс Order чтобы включить все необходимые поля
|
||||
interface Order {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
items?: any[];
|
||||
items_count?: number;
|
||||
shipping_address?: any;
|
||||
tracking_number?: string;
|
||||
payment_method?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Функция форматирования цены
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
// Функция форматирования даты
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return format(date, 'dd.MM.yyyy HH:mm', { locale: ru });
|
||||
}
|
||||
|
||||
// Статусы заказов
|
||||
const ORDER_STATUSES = [
|
||||
{ value: 'all', label: 'Все статусы' },
|
||||
{ value: 'pending', label: 'Ожидает оплаты', color: 'bg-amber-100 text-amber-800' },
|
||||
{ value: 'processing', label: 'В обработке', color: 'bg-blue-100 text-blue-800' },
|
||||
{ value: 'shipped', label: 'Отправлен', color: 'bg-purple-100 text-purple-800' },
|
||||
{ value: 'delivered', label: 'Доставлен', color: 'bg-green-100 text-green-800' },
|
||||
{ value: 'cancelled', label: 'Отменен', color: 'bg-red-100 text-red-800' },
|
||||
{ value: 'refunded', label: 'Возвращен', color: 'bg-gray-100 text-gray-800' },
|
||||
];
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
function OrderStatusBadge({ status }: { status: string }) {
|
||||
const statusInfo = ORDER_STATUSES.find((s) => s.value === status) || {
|
||||
label: status,
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${statusInfo.color} border-none whitespace-nowrap capitalize`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
import OrderManager from '@/components/admin/OrderManager';
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [filteredOrders, setFilteredOrders] = useState<Order[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<string>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
|
||||
|
||||
// Загрузка заказов
|
||||
useEffect(() => {
|
||||
const loadOrders = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Подготовка параметров запроса
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
|
||||
if (dateRange?.from && dateRange?.to) {
|
||||
params.date_from = startOfDay(dateRange.from).toISOString();
|
||||
params.date_to = endOfDay(dateRange.to).toISOString();
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
params.search = searchTerm;
|
||||
}
|
||||
|
||||
// Запрос к API
|
||||
const response = await api.get('/orders', { params });
|
||||
|
||||
// Проверяем формат ответа и обрабатываем его соответственно
|
||||
let ordersData: Order[] = [];
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
// API вернул массив заказов напрямую
|
||||
ordersData = response;
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Пробуем получить данные из объекта ответа
|
||||
const apiResponse = response as any;
|
||||
if (apiResponse.success && apiResponse.data) {
|
||||
if (Array.isArray(apiResponse.data)) {
|
||||
ordersData = apiResponse.data;
|
||||
} else if (apiResponse.data.orders && Array.isArray(apiResponse.data.orders)) {
|
||||
ordersData = apiResponse.data.orders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ordersData.length > 0) {
|
||||
setOrders(ordersData);
|
||||
|
||||
// Сортировка и фильтрация
|
||||
const sortedOrders = sortOrders(ordersData);
|
||||
setFilteredOrders(sortedOrders);
|
||||
} else {
|
||||
console.log("Получен пустой список заказов или неверный формат данных:", response);
|
||||
setOrders([]);
|
||||
setFilteredOrders([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы');
|
||||
toast.error('Ошибка при загрузке заказов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrders();
|
||||
}, [statusFilter, dateRange, searchTerm]);
|
||||
|
||||
// Функция сортировки заказов
|
||||
const sortOrders = (ordersToSort: Order[] = orders): Order[] => {
|
||||
return [...ordersToSort].sort((a, b) => {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
let valA, valB;
|
||||
|
||||
// Выбор значений для сортировки в зависимости от поля
|
||||
switch (sortField) {
|
||||
case 'id':
|
||||
valA = a.id;
|
||||
valB = b.id;
|
||||
break;
|
||||
case 'user_name':
|
||||
valA = a.user_name || '';
|
||||
valB = b.user_name || '';
|
||||
break;
|
||||
case 'created_at':
|
||||
valA = new Date(a.created_at).getTime();
|
||||
valB = new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'total':
|
||||
valA = a.total_amount || 0;
|
||||
valB = b.total_amount || 0;
|
||||
break;
|
||||
case 'status':
|
||||
valA = a.status || '';
|
||||
valB = b.status || '';
|
||||
break;
|
||||
default:
|
||||
valA = a.id;
|
||||
valB = b.id;
|
||||
}
|
||||
|
||||
// Направление сортировки
|
||||
const direction = sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
// Сравнение значений
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * direction;
|
||||
} else {
|
||||
return ((valA as number) - (valB as number)) * direction;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик изменения сортировки
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
// Если поле то же, меняем направление сортировки
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
// Если поле новое, устанавливаем его и сортируем по убыванию
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// Эффект для применения сортировки
|
||||
useEffect(() => {
|
||||
const sorted = sortOrders();
|
||||
setFilteredOrders(sorted);
|
||||
}, [sortField, sortDirection, orders]);
|
||||
|
||||
// Получение иконки сортировки
|
||||
const getSortIcon = (field: string) => {
|
||||
if (field !== sortField) {
|
||||
return <ArrowUpDown className="ml-1 h-4 w-4" />;
|
||||
}
|
||||
return sortDirection === 'asc' ? (
|
||||
<ArrowUp className="ml-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
// Предустановленные диапазоны дат
|
||||
const handleDateRangeSelect = (range: string) => {
|
||||
const today = new Date();
|
||||
|
||||
switch (range) {
|
||||
case 'today':
|
||||
setDateRange({
|
||||
from: startOfDay(today),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'yesterday':
|
||||
const yesterday = subDays(today, 1);
|
||||
setDateRange({
|
||||
from: startOfDay(yesterday),
|
||||
to: endOfDay(yesterday),
|
||||
});
|
||||
break;
|
||||
case 'week':
|
||||
setDateRange({
|
||||
from: startOfDay(subDays(today, 7)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'month':
|
||||
setDateRange({
|
||||
from: startOfDay(subDays(today, 30)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'year':
|
||||
setDateRange({
|
||||
from: startOfDay(subMonths(today, 12)),
|
||||
to: endOfDay(today),
|
||||
});
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
setDateRange(undefined);
|
||||
}
|
||||
|
||||
setIsDatePickerOpen(false);
|
||||
};
|
||||
|
||||
// Рендер страницы
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<CardTitle>Управление заказами</CardTitle>
|
||||
<CardDescription>
|
||||
Просмотр и управление заказами клиентов
|
||||
</CardDescription>
|
||||
</div>
|
||||
{filteredOrders.length > 0 && (
|
||||
<Badge variant="outline" className="bg-slate-100">
|
||||
Найдено заказов: {filteredOrders.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Фильтры */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Поиск по ID или имени клиента"
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={setStatusFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Выберите статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_STATUSES.map((status) => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Popover open={isDatePickerOpen} onOpenChange={setIsDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-[180px] justify-start">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to ? (
|
||||
<>
|
||||
{format(dateRange.from, 'dd.MM.yy')} - {format(dateRange.to, 'dd.MM.yy')}
|
||||
</>
|
||||
) : (
|
||||
format(dateRange.from, 'dd.MM.yyyy')
|
||||
)
|
||||
) : (
|
||||
'Выберите даты'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-3 border-b">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Быстрый выбор</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('today')}
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('yesterday')}
|
||||
>
|
||||
Вчера
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('week')}
|
||||
>
|
||||
Неделя
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('month')}
|
||||
>
|
||||
Месяц
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeSelect('all')}
|
||||
>
|
||||
Все время
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
defaultValue={dateRange}
|
||||
onUpdate={(range) => {
|
||||
setDateRange(range);
|
||||
if (range.to) setIsDatePickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сообщение об ошибке */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Таблица заказов */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className="text-center p-8 bg-slate-50 rounded-md">
|
||||
<Filter className="h-12 w-12 mx-auto text-slate-300 mb-2" />
|
||||
<h3 className="text-lg font-medium mb-1">Заказы не найдены</h3>
|
||||
<p className="text-slate-500 mb-4">Попробуйте изменить параметры фильтрации</p>
|
||||
{(statusFilter || dateRange?.from || searchTerm) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setDateRange(undefined);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
Сбросить все фильтры
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
className="w-[80px] cursor-pointer"
|
||||
onClick={() => handleSort('id')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
ID {getSortIcon('id')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleSort('user_name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Клиент {getSortIcon('user_name')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[180px]"
|
||||
onClick={() => handleSort('created_at')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Дата {getSortIcon('created_at')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">Товары</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer w-[140px]"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Статус {getSortIcon('status')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer text-right w-[120px]"
|
||||
onClick={() => handleSort('total')}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Сумма {getSortIcon('total')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[120px]">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.map((order) => (
|
||||
<TableRow key={order.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">#{order.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{order.user_name || `Пользователь #${order.user_id}`}
|
||||
</div>
|
||||
{order.user_email && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{order.user_email}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div title={order.created_at}>
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
{order.updated_at && order.updated_at !== order.created_at && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
title={`Обновлен: ${order.updated_at}`}
|
||||
>
|
||||
обновлен: {new Date(order.updated_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">
|
||||
{order.items_count || (order.items && order.items.length) || 0}
|
||||
</span>
|
||||
<span className="ml-1">шт.</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatPrice(order.total_amount || 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/orders/${order.id}`)}
|
||||
>
|
||||
Подробнее
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<h1 className="text-2xl font-bold mb-6">Управление заказами</h1>
|
||||
<OrderManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,637 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Plus, Edit, Trash, ChevronLeft, ChevronRight, Eye, Filter, RefreshCw } from 'lucide-react';
|
||||
import catalogService, { getImageUrl, ProductDetails } from '@/lib/catalog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { apiStatus } from '@/lib/api';
|
||||
|
||||
// Компонент таблицы товаров
|
||||
interface ProductsTableProps {
|
||||
products: ProductDetails[];
|
||||
loading: boolean;
|
||||
onDelete: (id: number) => void;
|
||||
selectedProducts: number[];
|
||||
onSelectProduct: (id: number) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const ProductsTable = ({ products, loading, onDelete, selectedProducts, onSelectProduct, onSelectAll }: ProductsTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Функция для получения URL первого изображения товара
|
||||
const getProductImageUrl = (product: ProductDetails): string => {
|
||||
if (product.primary_image) {
|
||||
return product.primary_image;
|
||||
}
|
||||
|
||||
if (product.images && Array.isArray(product.images) && product.images.length > 0) {
|
||||
// Если images - массив строк URL
|
||||
if (typeof product.images[0] === 'string') {
|
||||
return product.images[0];
|
||||
}
|
||||
// Если images - массив объектов с полем image_url
|
||||
else if (typeof product.images[0] === 'object' && product.images[0] !== null) {
|
||||
// Ищем основное изображение
|
||||
const primaryImage = product.images.find(img => img.is_primary);
|
||||
if (primaryImage) {
|
||||
return primaryImage.image_url;
|
||||
}
|
||||
// Или возвращаем первое
|
||||
return product.images[0].image_url;
|
||||
}
|
||||
}
|
||||
|
||||
return '/placeholder.jpg';
|
||||
};
|
||||
|
||||
// Функция для получения общего количества товаров в наличии
|
||||
const getProductStock = (product: ProductDetails): number => {
|
||||
if (product.variants && Array.isArray(product.variants) && product.variants.length > 0) {
|
||||
return product.variants.reduce((sum: number, variant) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
}
|
||||
return typeof product.stock === 'number' ? product.stock : 0;
|
||||
};
|
||||
|
||||
// Функция для редактирования товара - переход на страницу полного редактирования
|
||||
const handleEdit = (id: number) => {
|
||||
router.push(`/admin/products/${id}`);
|
||||
};
|
||||
|
||||
// Функция для просмотра товара в магазине
|
||||
const getProductStoreUrl = (product: ProductDetails): string => {
|
||||
return `/catalog/${product.slug || product.id}`;
|
||||
};
|
||||
|
||||
// Проверка, выбраны ли все продукты на текущей странице
|
||||
const areAllSelected = products.length > 0 && products.every(p => selectedProducts.includes(p.id));
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={areAllSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded-sm border-muted-foreground"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Изображение</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Название</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Категория</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Цена</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Остаток</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted/20">
|
||||
{products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => {
|
||||
const imageUrl = getProductImageUrl(product);
|
||||
const stockAmount = getProductStock(product);
|
||||
const isSelected = selectedProducts.includes(product.id);
|
||||
|
||||
return (
|
||||
<tr key={product.id} className={`hover:bg-muted/30 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||
<td className="px-2 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelectProduct(product.id)}
|
||||
className="h-4 w-4 rounded-sm border-muted-foreground"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-muted-foreground">#{product.id}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="h-16 w-16 rounded-md overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="max-w-[150px]">
|
||||
<div className="font-medium truncate" title={product.name}>
|
||||
{product.name || 'Без названия'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate" title={product.slug}>
|
||||
{product.slug || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm">
|
||||
<div className="max-w-[120px] truncate" title={product.category?.name}>
|
||||
{product.category?.name || (product.category_id ? `ID: ${product.category_id}` : '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm">
|
||||
{typeof product.price === 'number' && (
|
||||
<div>
|
||||
<span className="font-medium">{product.price.toLocaleString('ru-RU')} ₽</span>
|
||||
{product.discount_price && (
|
||||
<div className="text-xs text-red-600">
|
||||
{product.discount_price.toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<Badge variant={
|
||||
stockAmount > 20 ? 'secondary' :
|
||||
stockAmount > 10 ? 'default' :
|
||||
stockAmount > 0 ? 'destructive' : 'outline'
|
||||
}>
|
||||
{stockAmount > 0 ? stockAmount : 'Нет в наличии'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(product.id)}
|
||||
>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Редактировать товар</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getProductStoreUrl(product)}
|
||||
target="_blank"
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Просмотреть в магазине</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(product.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Удалить товар</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент пагинации
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Страница <span className="font-medium">{currentPage}</span> из{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Пагинация">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-l-md"
|
||||
>
|
||||
<span className="sr-only">Предыдущая</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 1)
|
||||
.reduce((acc: (number | string)[], p, i, arr) => {
|
||||
if (i > 0 && arr[i-1] !== p - 1) {
|
||||
acc.push('...');
|
||||
}
|
||||
acc.push(p);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((page, index) =>
|
||||
typeof page === 'number' ? (
|
||||
<Button
|
||||
key={index}
|
||||
variant={page === currentPage ? "default" : "outline"}
|
||||
onClick={() => onPageChange(page as number)}
|
||||
className="rounded-none"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
disabled
|
||||
className="rounded-none"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-r-md"
|
||||
>
|
||||
<span className="sr-only">Следующая</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import ProductManager from '@/components/admin/ProductManager';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const router = useRouter();
|
||||
const [products, setProducts] = useState<ProductDetails[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Обработчик ошибок
|
||||
const handleError = (err: any, message = 'Произошла ошибка') => {
|
||||
console.error(`${message}:`, err);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
// Загрузка данных
|
||||
const loadProducts = useCallback(async (page = 1, searchQuery = '') => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log(`Загрузка товаров (страница ${page}, поиск: "${searchQuery}")`);
|
||||
|
||||
const response = await catalogService.getProducts({
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: searchQuery
|
||||
});
|
||||
|
||||
console.log('Получено товаров:', response.products?.length || 0, 'из', response.total);
|
||||
setProducts(response.products as unknown as ProductDetails[] || []);
|
||||
setTotalPages(Math.ceil((response.total || 0) / pageSize) || 1);
|
||||
setCurrentPage(page);
|
||||
} catch (err) {
|
||||
handleError(err, 'Не удалось загрузить товары');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageSize]);
|
||||
|
||||
// Загрузка при монтировании компонента
|
||||
useEffect(() => {
|
||||
loadProducts(1, search);
|
||||
}, [loadProducts]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = () => {
|
||||
loadProducts(1, search);
|
||||
};
|
||||
|
||||
// Обработчик изменения страницы
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
setCurrentPage(page);
|
||||
loadProducts(page, search);
|
||||
};
|
||||
|
||||
// Обработчик обновления списка
|
||||
const handleRefresh = () => {
|
||||
loadProducts(currentPage, search);
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
setDeleting(true);
|
||||
console.log(`Удаление товара с ID: ${id}`);
|
||||
|
||||
const success = await catalogService.deleteProduct(id);
|
||||
if (success) {
|
||||
console.log(`Товар с ID ${id} успешно удален`);
|
||||
toast.success('Товар успешно удален');
|
||||
|
||||
// Обновляем список товаров
|
||||
loadProducts(
|
||||
// Если на странице остался 1 товар и мы его удалили, то переходим на предыдущую страницу
|
||||
products.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage,
|
||||
search
|
||||
);
|
||||
|
||||
// Если товар был выбран, удаляем его из выбранных
|
||||
if (selectedProducts.includes(id)) {
|
||||
setSelectedProducts(prev => prev.filter(productId => productId !== id));
|
||||
}
|
||||
} else {
|
||||
throw new Error('Не удалось удалить товар');
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'Не удалось удалить товар');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик выбора товара
|
||||
const handleSelectProduct = (id: number) => {
|
||||
setSelectedProducts(prev => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter(productId => productId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик выбора всех товаров
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProducts(products.map(p => p.id));
|
||||
} else {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Показ диалога подтверждения удаления
|
||||
const confirmDelete = (id: number) => {
|
||||
setProductToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик удаления выбранных товаров
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!selectedProducts.length) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
let successCount = 0;
|
||||
|
||||
for (const id of selectedProducts) {
|
||||
try {
|
||||
const success = await catalogService.deleteProduct(id);
|
||||
if (success) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Ошибка при удалении товара с ID ${id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Удалено ${successCount} товаров`);
|
||||
setSelectedProducts([]);
|
||||
// Обновляем список товаров
|
||||
loadProducts(currentPage, search);
|
||||
} else {
|
||||
toast.error('Не удалось удалить выбранные товары');
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'Ошибка при удалении товаров');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Товары</h1>
|
||||
<Link href="/admin/products/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать товар
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 rounded">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b px-5 py-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Список товаров</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Обновить
|
||||
</Button>
|
||||
|
||||
{selectedProducts.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Удалить выбранные ({selectedProducts.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Поиск товаров..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={loading}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value));
|
||||
loadProducts(1, search);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Показывать по" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 на странице</SelectItem>
|
||||
<SelectItem value="10">10 на странице</SelectItem>
|
||||
<SelectItem value="20">20 на странице</SelectItem>
|
||||
<SelectItem value="50">50 на странице</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProductsTable
|
||||
products={products}
|
||||
loading={loading}
|
||||
onDelete={confirmDelete}
|
||||
selectedProducts={selectedProducts}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {products.length > 0 ? (currentPage - 1) * pageSize + 1 : 0} - {Math.min(currentPage * pageSize, (currentPage - 1) * pageSize + products.length)} из {totalPages * pageSize}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center px-4">
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Подтверждение удаления</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Вы уверены, что хотите удалить этот товар? Это действие нельзя отменить.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => productToDelete && handleDelete(productToDelete)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Удаление...' : 'Удалить'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="container mx-auto py-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Управление товарами</h1>
|
||||
<ProductManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ export const metadata: Metadata = {
|
||||
description: "Натуральные ткани, утонченный дизайн, комфорт и элегантность. Создаем одежду, в которой удобно и красиво жить свою жизнь.",
|
||||
}
|
||||
|
||||
import { LazyBgLoader } from "./LazyBgLoader";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@ -28,6 +30,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} ${playfair.variable} font-sans antialiased`}>
|
||||
<LazyBgLoader />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
@ -49,4 +52,4 @@ export default function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
36
frontend/app/lazy-background.ts
Normal file
36
frontend/app/lazy-background.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Автоматическая ленивая подгрузка фоновых изображений с data-bg
|
||||
* Работает для элементов с классом .lazy-bg и атрибутом data-bg="url(...)"
|
||||
*/
|
||||
function lazyLoadBackgrounds() {
|
||||
const observer = new IntersectionObserver((entries, obs) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const el = entry.target as HTMLElement;
|
||||
const bg = el.dataset.bg;
|
||||
if (bg) {
|
||||
el.style.backgroundImage = bg;
|
||||
el.removeAttribute('data-bg');
|
||||
obs.unobserve(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '100px',
|
||||
threshold: 0.01,
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.lazy-bg[data-bg]').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', lazyLoadBackgrounds);
|
||||
} else {
|
||||
lazyLoadBackgrounds();
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
BIN
frontend/components/.DS_Store
vendored
BIN
frontend/components/.DS_Store
vendored
Binary file not shown.
63
frontend/components/admin/AdminErrorAlert.tsx
Normal file
63
frontend/components/admin/AdminErrorAlert.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, XCircle } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface AdminErrorAlertProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для отображения ошибок в админке
|
||||
*/
|
||||
export default function AdminErrorAlert({
|
||||
title = 'Ошибка',
|
||||
message,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
variant = 'destructive',
|
||||
className = ''
|
||||
}: AdminErrorAlertProps) {
|
||||
return (
|
||||
<Alert variant={variant} className={`mb-4 rounded ${className}`}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle className="ml-2">{title}</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
{message}
|
||||
</AlertDescription>
|
||||
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="text-xs"
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="text-xs"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Закрыть
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
55
frontend/components/admin/AdminLoadingState.tsx
Normal file
55
frontend/components/admin/AdminLoadingState.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface AdminLoadingStateProps {
|
||||
message?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullPage?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для отображения состояния загрузки в админке
|
||||
*/
|
||||
export default function AdminLoadingState({
|
||||
message = 'Загрузка данных...',
|
||||
size = 'md',
|
||||
fullPage = false,
|
||||
className = ''
|
||||
}: AdminLoadingStateProps) {
|
||||
// Определяем размеры в зависимости от параметра size
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
const textClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
};
|
||||
|
||||
// Если fullPage = true, отображаем на весь экран
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-white/80 z-50">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary`} />
|
||||
<p className={`mt-2 ${textClasses[size]} text-muted-foreground`}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Обычное отображение
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-4 ${className}`}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary`} />
|
||||
<p className={`mt-2 ${textClasses[size]} text-muted-foreground`}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
frontend/components/admin/OrderManager.tsx
Normal file
360
frontend/components/admin/OrderManager.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Search, RefreshCw, Eye, Calendar, Filter } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Order } from '@/lib/orders';
|
||||
import useAdminApi from '@/hooks/useAdminApi';
|
||||
import useAdminCache from '@/hooks/useAdminCache';
|
||||
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
|
||||
import AdminLoadingState from '@/components/admin/AdminLoadingState';
|
||||
|
||||
interface OrderManagerProps {
|
||||
pageSize?: number;
|
||||
showFilters?: boolean;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
// Функция форматирования цены
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
// Компонент бейджа статуса заказа
|
||||
function OrderStatusBadge({ status }: { status: string }) {
|
||||
let variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
|
||||
let label = status;
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
variant = 'secondary';
|
||||
label = 'Ожидает оплаты';
|
||||
break;
|
||||
case 'paid':
|
||||
variant = 'default';
|
||||
label = 'Оплачен';
|
||||
break;
|
||||
case 'processing':
|
||||
variant = 'outline';
|
||||
label = 'В обработке';
|
||||
break;
|
||||
case 'shipped':
|
||||
variant = 'default';
|
||||
label = 'Отправлен';
|
||||
break;
|
||||
case 'delivered':
|
||||
variant = 'default';
|
||||
label = 'Доставлен';
|
||||
break;
|
||||
case 'cancelled':
|
||||
variant = 'destructive';
|
||||
label = 'Отменен';
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
export default function OrderManager({
|
||||
pageSize = 10,
|
||||
showFilters = true,
|
||||
showSearch = true
|
||||
}: OrderManagerProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Состояние компонента
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [dateFilter, setDateFilter] = useState<string>('');
|
||||
|
||||
// Кэш для заказов
|
||||
const ordersCache = useAdminCache<{
|
||||
orders: Order[];
|
||||
total: number;
|
||||
}>({
|
||||
key: `orders-page-${currentPage}-${searchQuery}-${statusFilter}-${dateFilter}`,
|
||||
ttl: 2 * 60 * 1000, // 2 минуты
|
||||
});
|
||||
|
||||
// API для заказов
|
||||
const ordersApi = useAdminApi({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
ordersCache.setData(data);
|
||||
}
|
||||
},
|
||||
showErrorToast: true,
|
||||
errorMessage: 'Не удалось загрузить заказы'
|
||||
});
|
||||
|
||||
// Загрузка данных
|
||||
const loadOrders = useCallback(async (page = 1, query = '', status = statusFilter, date = dateFilter) => {
|
||||
await ordersCache.loadData(async () => {
|
||||
const response = await ordersApi.get<{
|
||||
orders: Order[];
|
||||
total: number;
|
||||
}>('/admin/orders', {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: query,
|
||||
status: status || undefined,
|
||||
date_filter: date || undefined
|
||||
});
|
||||
|
||||
if (response) {
|
||||
setTotalPages(Math.ceil((response.total || 0) / pageSize) || 1);
|
||||
setCurrentPage(page);
|
||||
return response;
|
||||
}
|
||||
|
||||
return { orders: [], total: 0 };
|
||||
});
|
||||
}, [pageSize, ordersApi, ordersCache, statusFilter, dateFilter]);
|
||||
|
||||
// Загрузка данных при монтировании компонента и изменении параметров
|
||||
useEffect(() => {
|
||||
loadOrders(currentPage, searchQuery, statusFilter, dateFilter);
|
||||
}, [loadOrders, currentPage, searchQuery, statusFilter, dateFilter]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadOrders(1, searchQuery, statusFilter, dateFilter);
|
||||
};
|
||||
|
||||
// Обработчик изменения фильтра статуса
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
setStatusFilter(value === 'all' ? '' : value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Обработчик изменения фильтра даты
|
||||
const handleDateFilterChange = (value: string) => {
|
||||
setDateFilter(value === 'all' ? '' : value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Обработчик перехода на страницу просмотра заказа
|
||||
const handleViewOrder = (id: number) => {
|
||||
router.push(`/admin/orders/${id}`);
|
||||
};
|
||||
|
||||
// Получаем данные из кэша
|
||||
const orders = ordersCache.data?.orders || [];
|
||||
const total = ordersCache.data?.total || 0;
|
||||
|
||||
// Состояние загрузки
|
||||
const isLoading = ordersApi.isLoading || ordersCache.isLoading;
|
||||
|
||||
// Состояние ошибки
|
||||
const error = ordersApi.error;
|
||||
|
||||
// Если данные загружаются, показываем индикатор загрузки
|
||||
if (isLoading && !orders.length) {
|
||||
return <AdminLoadingState message="Загрузка заказов..." />;
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение об ошибке
|
||||
if (error && !orders.length) {
|
||||
return (
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки заказов"
|
||||
message={error}
|
||||
onRetry={() => loadOrders(currentPage, searchQuery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Фильтры и поиск */}
|
||||
{(showSearch || showFilters) && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||
{showSearch && (
|
||||
<div className="flex w-full md:w-auto gap-2">
|
||||
<Input
|
||||
placeholder="Поиск заказов..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilterChange}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все статусы</SelectItem>
|
||||
<SelectItem value="pending">Ожидает оплаты</SelectItem>
|
||||
<SelectItem value="paid">Оплачен</SelectItem>
|
||||
<SelectItem value="processing">В обработке</SelectItem>
|
||||
<SelectItem value="shipped">Отправлен</SelectItem>
|
||||
<SelectItem value="delivered">Доставлен</SelectItem>
|
||||
<SelectItem value="cancelled">Отменен</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={dateFilter} onValueChange={handleDateFilterChange}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Период" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все время</SelectItem>
|
||||
<SelectItem value="today">Сегодня</SelectItem>
|
||||
<SelectItem value="yesterday">Вчера</SelectItem>
|
||||
<SelectItem value="week">Неделя</SelectItem>
|
||||
<SelectItem value="month">Месяц</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={() => loadOrders(currentPage, searchQuery)}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Таблица заказов */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Заказы</CardTitle>
|
||||
<CardDescription>
|
||||
Всего заказов: {total}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="px-4 py-3 text-left">ID</th>
|
||||
<th className="px-4 py-3 text-left">Клиент</th>
|
||||
<th className="px-4 py-3 text-left">Дата</th>
|
||||
<th className="px-4 py-3 text-left">Товары</th>
|
||||
<th className="px-4 py-3 text-left">Статус</th>
|
||||
<th className="px-4 py-3 text-right">Сумма</th>
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-4 text-sm text-muted-foreground">#{order.id}</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="max-w-[150px]">
|
||||
<div className="font-medium truncate">
|
||||
{order.user_name || `Пользователь #${order.user_id}`}
|
||||
</div>
|
||||
{order.user_email && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{order.user_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm">
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(order.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">
|
||||
{order.items_count || (order.items && order.items.length) || 0}
|
||||
</span>
|
||||
<span className="ml-1">шт.</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</td>
|
||||
<td className="px-4 py-4 text-right font-medium">
|
||||
{formatPrice(order.total_amount || 0)}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewOrder(order.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Подробнее
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Вперед
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
501
frontend/components/admin/ProductManager.tsx
Normal file
501
frontend/components/admin/ProductManager.tsx
Normal file
@ -0,0 +1,501 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Edit, Trash, Eye, Plus, Filter, Search, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { ProductDetails } from '@/lib/catalog';
|
||||
import useAdminApi from '@/hooks/useAdminApi';
|
||||
import useAdminCache from '@/hooks/useAdminCache';
|
||||
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
|
||||
import AdminLoadingState from '@/components/admin/AdminLoadingState';
|
||||
|
||||
interface ProductManagerProps {
|
||||
pageSize?: number;
|
||||
showFilters?: boolean;
|
||||
showSearch?: boolean;
|
||||
showActions?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
}
|
||||
|
||||
export default function ProductManager({
|
||||
pageSize = 10,
|
||||
showFilters = true,
|
||||
showSearch = true,
|
||||
showActions = true,
|
||||
showBulkActions = true
|
||||
}: ProductManagerProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Состояние компонента
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
|
||||
// Кэш для товаров
|
||||
const productsCache = useAdminCache<{
|
||||
products: ProductDetails[];
|
||||
total: number;
|
||||
}>({
|
||||
key: `products-page-${currentPage}-${searchQuery}-${categoryFilter}-${statusFilter}`,
|
||||
ttl: 2 * 60 * 1000, // 2 минуты
|
||||
});
|
||||
|
||||
// API для товаров
|
||||
const productsApi = useAdminApi({
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
productsCache.setData(data);
|
||||
}
|
||||
},
|
||||
showErrorToast: true,
|
||||
errorMessage: 'Не удалось загрузить товары'
|
||||
});
|
||||
|
||||
// API для удаления товара
|
||||
const deleteApi = useAdminApi({
|
||||
onSuccess: () => {
|
||||
toast.success('Товар успешно удален');
|
||||
// Инвалидируем кэш и перезагружаем данные
|
||||
productsCache.invalidateCache();
|
||||
loadProducts(currentPage, searchQuery);
|
||||
setSelectedProducts([]);
|
||||
},
|
||||
showSuccessToast: true,
|
||||
successMessage: 'Товар успешно удален',
|
||||
showErrorToast: true,
|
||||
errorMessage: 'Не удалось удалить товар'
|
||||
});
|
||||
|
||||
// Загрузка данных
|
||||
const loadProducts = useCallback(async (page = 1, query = '', category = categoryFilter, status = statusFilter) => {
|
||||
await productsCache.loadData(async () => {
|
||||
const response = await productsApi.get<{
|
||||
products: ProductDetails[];
|
||||
total: number;
|
||||
}>('/catalog/products', {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: query,
|
||||
category_id: category || undefined,
|
||||
is_active: status === 'active' ? true : status === 'inactive' ? false : undefined
|
||||
});
|
||||
|
||||
if (response) {
|
||||
setTotalPages(Math.ceil((response.total || 0) / pageSize) || 1);
|
||||
setCurrentPage(page);
|
||||
return response;
|
||||
}
|
||||
|
||||
return { products: [], total: 0 };
|
||||
});
|
||||
}, [pageSize, productsApi, productsCache, categoryFilter, statusFilter]);
|
||||
|
||||
// Загрузка данных при монтировании компонента и изменении параметров
|
||||
useEffect(() => {
|
||||
loadProducts(currentPage, searchQuery, categoryFilter, statusFilter);
|
||||
}, [loadProducts, currentPage, searchQuery, categoryFilter, statusFilter]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadProducts(1, searchQuery, categoryFilter, statusFilter);
|
||||
};
|
||||
|
||||
// Обработчик изменения фильтра категории
|
||||
const handleCategoryFilterChange = (value: string) => {
|
||||
setCategoryFilter(value === 'all' ? '' : value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Обработчик изменения фильтра статуса
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
setStatusFilter(value === 'all' ? '' : value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Обработчик выбора товара
|
||||
const handleSelectProduct = (id: number) => {
|
||||
setSelectedProducts(prev => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter(productId => productId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик выбора всех товаров
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allProductIds = productsCache.data?.products.map(product => product.id) || [];
|
||||
setSelectedProducts(allProductIds);
|
||||
} else {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDeleteProduct = (id: number) => {
|
||||
setProductToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик подтверждения удаления товара
|
||||
const confirmDelete = async () => {
|
||||
if (productToDelete) {
|
||||
await deleteApi.delete(`/catalog/products/${productToDelete}`);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
};
|
||||
|
||||
// Обработчик удаления выбранных товаров
|
||||
const handleDeleteSelected = () => {
|
||||
// Здесь можно реализовать массовое удаление
|
||||
toast.error('Массовое удаление пока не реализовано');
|
||||
};
|
||||
|
||||
// Обработчик перехода на страницу редактирования товара
|
||||
const handleEditProduct = (id: number) => {
|
||||
router.push(`/admin/products/${id}`);
|
||||
};
|
||||
|
||||
// Обработчик перехода на страницу просмотра товара
|
||||
const handleViewProduct = (id: number) => {
|
||||
router.push(`/product/${id}`);
|
||||
};
|
||||
|
||||
// Обработчик перехода на страницу создания товара
|
||||
const handleCreateProduct = () => {
|
||||
router.push('/admin/products/new');
|
||||
};
|
||||
|
||||
// Получаем данные из кэша
|
||||
const products = productsCache.data?.products || [];
|
||||
const total = productsCache.data?.total || 0;
|
||||
|
||||
// Состояние загрузки
|
||||
const isLoading = productsApi.isLoading || productsCache.isLoading;
|
||||
|
||||
// Состояние ошибки
|
||||
const error = productsApi.error;
|
||||
|
||||
// Если данные загружаются, показываем индикатор загрузки
|
||||
if (isLoading && !products.length) {
|
||||
return <AdminLoadingState message="Загрузка товаров..." />;
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение об ошибке
|
||||
if (error && !products.length) {
|
||||
return (
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки товаров"
|
||||
message={error}
|
||||
onRetry={() => loadProducts(currentPage, searchQuery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Фильтры и поиск */}
|
||||
{(showSearch || showFilters) && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||
{showSearch && (
|
||||
<div className="flex w-full md:w-auto gap-2">
|
||||
<Input
|
||||
placeholder="Поиск товаров..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full md:w-64"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={categoryFilter} onValueChange={handleCategoryFilterChange}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Категория" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все категории</SelectItem>
|
||||
<SelectItem value="1">Платья</SelectItem>
|
||||
<SelectItem value="2">Блузы</SelectItem>
|
||||
<SelectItem value="3">Брюки</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilterChange}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все статусы</SelectItem>
|
||||
<SelectItem value="active">Активные</SelectItem>
|
||||
<SelectItem value="inactive">Неактивные</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" onClick={() => loadProducts(currentPage, searchQuery)}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Таблица товаров */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Товары</CardTitle>
|
||||
<CardDescription>
|
||||
Всего товаров: {total}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<Button onClick={handleCreateProduct}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать товар
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{showBulkActions && (
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedProducts.length > 0 && selectedProducts.length === products.length}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 text-left">ID</th>
|
||||
<th className="px-4 py-3 text-left">Фото</th>
|
||||
<th className="px-4 py-3 text-left">Название</th>
|
||||
<th className="px-4 py-3 text-left">Категория</th>
|
||||
<th className="px-4 py-3 text-left">Цена</th>
|
||||
<th className="px-4 py-3 text-left">Статус</th>
|
||||
{showActions && (
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product) => {
|
||||
const isSelected = selectedProducts.includes(product.id);
|
||||
const imageUrl = product.images && product.images.length > 0
|
||||
? product.images[0].image_url || '/placeholder.svg'
|
||||
: '/placeholder.svg';
|
||||
|
||||
return (
|
||||
<tr key={product.id} className={`hover:bg-muted/30 transition-colors ${isSelected ? 'bg-blue-50' : ''}`}>
|
||||
{showBulkActions && (
|
||||
<td className="px-4 py-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleSelectProduct(product.id)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-4 text-sm text-muted-foreground">#{product.id}</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="h-16 w-16 rounded-md overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="max-w-[150px]">
|
||||
<div className="font-medium truncate" title={product.name}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{product.variants?.length || 0} вариантов
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{product.category?.name || 'Без категории'}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{product.price?.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Badge variant={product.is_active ? 'default' : 'secondary'}>
|
||||
{product.is_active ? 'Активен' : 'Неактивен'}
|
||||
</Badge>
|
||||
</td>
|
||||
{showActions && (
|
||||
<td className="px-4 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewProduct(product.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Просмотр</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditProduct(product.id)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Редактировать</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Удалить</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={showBulkActions ? 8 : 7} className="px-4 py-8 text-center text-muted-foreground">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Вперед
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Действия с выбранными товарами */}
|
||||
{showBulkActions && selectedProducts.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-muted rounded-md">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm">
|
||||
Выбрано товаров: {selectedProducts.length}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedProducts([])}
|
||||
>
|
||||
Отменить выбор
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
Удалить выбранные
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удаление товара</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Вы уверены, что хотите удалить этот товар? Это действие нельзя отменить.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>Удалить</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,7 +28,7 @@ export function SiteFooter() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
<Link href="/contacts" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Контакты
|
||||
</Link>
|
||||
</li>
|
||||
@ -40,12 +40,12 @@ export function SiteFooter() {
|
||||
<h3 className="font-serif text-xl mb-6">Помощь</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/delivery" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
<Link href="/faq" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Доставка и оплата
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/returns" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
<Link href="/faq" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Возврат
|
||||
</Link>
|
||||
</li>
|
||||
@ -66,9 +66,9 @@ export function SiteFooter() {
|
||||
<div>
|
||||
<h3 className="font-serif text-xl mb-6">Контакты</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="text-white/80">Тел: 8 (800) 123-45-67</li>
|
||||
<li className="text-white/80">Тел: +7 905 967 7125</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors flex items-center gap-1">
|
||||
<a href="https://instagram.com/dressed_for_success_" target="_blank" rel="noopener noreferrer" className="text-white/80 hover:text-tertiary transition-colors flex items-center gap-1">
|
||||
<Instagram className="h-5 w-5" />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
@ -87,41 +87,41 @@ export function SiteFooter() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Подписка */}
|
||||
{/* юридическая информация */}
|
||||
<div>
|
||||
<h3 className="font-serif text-xl mb-6">Следующие дропы</h3>
|
||||
<p className="text-white/80 mb-4">
|
||||
Будь среди первых, кто узнает о новых коллекциях и специальных предложениях
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Ваш e-mail"
|
||||
className="bg-white/10 border-white/20 text-white placeholder:text-white/50 focus:border-tertiary"
|
||||
/>
|
||||
<Button className="bg-tertiary hover:bg-tertiary/90 text-primary w-full flex items-center justify-center gap-2">
|
||||
<span>Подписаться</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="font-serif text-xl mb-6">Юридическая информация</h3>
|
||||
{/* <p className="text-white/80 mb-4">
|
||||
Индивидуальный предприниматель Плотников Михаил Владимирович
|
||||
</p> */}
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/privacy" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Политика конфиденциальности
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/terms" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Пользовательское соглашение
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contacts" className="text-white/80 hover:text-tertiary transition-colors">
|
||||
Реквизиты
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Копирайт и правовая информация */}
|
||||
<div className="border-t border-white/20 pt-8 flex flex-col md:flex-row justify-between items-center text-sm text-white/60">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<div className="border-t border-white/20 pt-8 flex flex-col md:flex-row justify-center items-center text-sm text-white/60">
|
||||
<div className="mb-4 md:mb-0 text-center">
|
||||
© {new Date().getFullYear()} Dressed for Success. Все права защищены.
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Link href="/privacy" className="hover:text-tertiary transition-colors">
|
||||
Политика конфиденциальности
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-tertiary transition-colors">
|
||||
Пользовательское соглашение
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { Heart, Menu, ShoppingBag, User } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useState, useMemo, useEffect } from "react" // Добавляем useEffect
|
||||
// Добавляем импорты useCart и useWishlist обратно
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -11,11 +12,77 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
import Image from "next/image"
|
||||
|
||||
// Новый дочерний компонент для иконок
|
||||
function HeaderIcons() {
|
||||
// Вызываем хуки здесь, внутри компонента, который рендерится только на клиенте
|
||||
const { itemCount: cartCount } = useCart()
|
||||
const { itemCount: wishlistCount } = useWishlist()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Профиль - только десктоп */}
|
||||
<div className="hidden md:block">
|
||||
<Link href="/account" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Профиль"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Избранное */}
|
||||
<Link href="/wishlist" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Избранное"
|
||||
className="relative h-10 w-10"
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
{/* isClient здесь уже не нужен, т.к. весь компонент рендерится на клиенте */}
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Корзина */}
|
||||
<Link href="/cart" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Корзина"
|
||||
className="relative h-10 w-10"
|
||||
>
|
||||
<ShoppingBag className="h-5 w-5" />
|
||||
{/* isClient здесь уже не нужен */}
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function SiteHeader() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const { itemCount: cartCount } = useCart()
|
||||
const { itemCount: wishlistCount } = useWishlist()
|
||||
// Убираем вызовы useCart и useWishlist отсюда
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full bg-white/80 backdrop-blur-sm border-b border-gray-100">
|
||||
@ -146,55 +213,19 @@ export function SiteHeader() {
|
||||
|
||||
{/* Правая часть - Иконки */}
|
||||
<div className="flex items-center space-x-1 md:space-x-2">
|
||||
{/* Профиль - только десктоп */}
|
||||
<div className="hidden md:block">
|
||||
<Link href="/account" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Профиль"
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<User className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Избранное */}
|
||||
<Link href="/wishlist" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Избранное"
|
||||
className="relative h-10 w-10"
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
{wishlistCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{wishlistCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Корзина */}
|
||||
<Link href="/cart" prefetch={false}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Корзина"
|
||||
className="relative h-10 w-10"
|
||||
>
|
||||
<ShoppingBag className="h-5 w-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
{/* Рендерим иконки только на клиенте */}
|
||||
{isClient ? <HeaderIcons /> : (
|
||||
// Placeholder или ничего на сервере и при первом рендере клиента
|
||||
<>
|
||||
<div className="hidden md:block">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10" disabled><User className="h-5 w-5" /></Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10" disabled><Heart className="h-5 w-5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10" disabled><ShoppingBag className="h-5 w-5" /></Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,298 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Check, Minus, Plus, ShoppingBag, Heart, ArrowRight } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { ProductDetails as ProductType, normalizeProductImage } from "@/lib/catalog"
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { ProductDetails as ProductType } from "@/lib/catalog"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { AddToCartButton } from "@/components/product/AddToCartButton"
|
||||
import { toast } from "sonner"
|
||||
import { SizeModal } from "@/components/size-guide/size-modal"
|
||||
|
||||
interface ProductDetailsProps {
|
||||
product: ProductType
|
||||
}
|
||||
|
||||
export function ProductDetails({ product }: ProductDetailsProps) {
|
||||
const [selectedSize, setSelectedSize] = useState<number | null>(null)
|
||||
const [selectedSize, setSelectedSize] = useState<string>("")
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [addedToCart, setAddedToCart] = useState(false)
|
||||
const [inWishlist, setInWishlist] = useState(false)
|
||||
const { addToCart, loading } = useCart()
|
||||
|
||||
// Безопасная обработка данных продукта
|
||||
const safeProduct = {
|
||||
...product,
|
||||
name: typeof product.name === 'string' ? product.name : 'Товар без названия',
|
||||
description: typeof product.description === 'string' ? product.description : '',
|
||||
price: typeof product.price === 'number' ? product.price : 0,
|
||||
discount_price: typeof product.discount_price === 'number' ? product.discount_price : null,
|
||||
care_instructions: typeof product.care_instructions === 'string' ? product.care_instructions : '',
|
||||
category_name: typeof product.category_name === 'string' ? product.category_name : '',
|
||||
variants: Array.isArray(product.variants) ? product.variants : []
|
||||
};
|
||||
|
||||
// Сбрасываем состояние успешного добавления в корзину через 3 секунды
|
||||
useEffect(() => {
|
||||
if (addedToCart) {
|
||||
const timer = setTimeout(() => {
|
||||
setAddedToCart(false);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [addedToCart]);
|
||||
|
||||
// Обрабатываем размеры из вариантов
|
||||
const availableSizes = safeProduct.variants
|
||||
?.filter(variant => variant.is_active && variant.stock > 0)
|
||||
.map(variant => ({
|
||||
id: variant.id,
|
||||
size_id: variant.size_id,
|
||||
size_name: variant.size?.name || variant.size?.value || '',
|
||||
stock: variant.stock
|
||||
}))
|
||||
.filter((size, index, self) =>
|
||||
self.findIndex(s => s.size_name === size.size_name) === index
|
||||
) || [];
|
||||
|
||||
// Увеличение количества
|
||||
const increaseQuantity = () => {
|
||||
const maxStock = getStockForCurrentSize();
|
||||
if (quantity < maxStock) {
|
||||
setQuantity(quantity + 1);
|
||||
} else {
|
||||
toast.info(`Доступно только ${maxStock} шт.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Уменьшение количества
|
||||
const decreaseQuantity = () => {
|
||||
if (quantity > 1) {
|
||||
setQuantity(quantity - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяет, доступен ли размер для выбора (есть ли в наличии)
|
||||
const isSizeInStock = (sizeId: number): boolean => {
|
||||
return safeProduct.variants.some(
|
||||
variant =>
|
||||
variant.id === sizeId &&
|
||||
variant.is_active &&
|
||||
variant.stock > 0
|
||||
);
|
||||
};
|
||||
|
||||
// Получаем доступное количество для выбранного размера
|
||||
const getStockForCurrentSize = (): number => {
|
||||
if (!selectedSize) return 0;
|
||||
|
||||
const variant = safeProduct.variants.find(v => v.id === selectedSize);
|
||||
return variant?.stock || 0;
|
||||
};
|
||||
|
||||
// Автоматический выбор размера, если доступен только один
|
||||
useEffect(() => {
|
||||
if (availableSizes.length === 1 && !selectedSize) {
|
||||
setSelectedSize(availableSizes[0].id);
|
||||
}
|
||||
}, [availableSizes, selectedSize]);
|
||||
|
||||
// Добавление в корзину
|
||||
const handleAddToCart = () => {
|
||||
if (!selectedSize && safeProduct.variants && safeProduct.variants.length > 0) {
|
||||
toast.warning("Пожалуйста, выберите размер")
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedSize) {
|
||||
toast.error("Пожалуйста, выберите размер")
|
||||
return
|
||||
}
|
||||
|
||||
addToCart({
|
||||
product_id: safeProduct.id,
|
||||
variant_id: selectedSize || 0,
|
||||
quantity: quantity
|
||||
} as any)
|
||||
.then(() => {
|
||||
setAddedToCart(true);
|
||||
|
||||
try {
|
||||
await addToCart({
|
||||
product_id: product.id,
|
||||
variant_id: parseInt(selectedSize),
|
||||
quantity
|
||||
})
|
||||
toast.success("Товар добавлен в корзину")
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка при добавлении в корзину:", error)
|
||||
toast.error("Не удалось добавить товар в корзину")
|
||||
})
|
||||
}
|
||||
|
||||
// Быстрая покупка (перенаправление в корзину)
|
||||
const handleBuyNow = () => {
|
||||
if (!selectedSize && safeProduct.variants && safeProduct.variants.length > 0) {
|
||||
toast.warning("Пожалуйста, выберите размер")
|
||||
return
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при добавлении товара в корзину")
|
||||
}
|
||||
|
||||
addToCart({
|
||||
product_id: safeProduct.id,
|
||||
variant_id: selectedSize || 0,
|
||||
quantity: quantity
|
||||
} as any)
|
||||
.then(() => {
|
||||
// Перенаправление на страницу корзины
|
||||
window.location.href = '/cart';
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Ошибка при добавлении в корзину:", error)
|
||||
toast.error("Не удалось добавить товар в корзину")
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
{/* Выбор размера */}
|
||||
{safeProduct.variants && safeProduct.variants.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-primary">РАЗМЕР</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={() => window.location.href = '/size-guide'}
|
||||
>
|
||||
Таблица размеров
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSizes.map((sizeOption) => {
|
||||
const isOutOfStock = sizeOption.stock === 0;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={sizeOption.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
min-w-[3rem] h-12 px-3 rounded-lg text-sm font-medium
|
||||
transition-all duration-200
|
||||
${selectedSize === sizeOption.id
|
||||
? "bg-primary text-white border-transparent"
|
||||
: isOutOfStock
|
||||
? "bg-tertiary/10 text-neutral-400 border-transparent cursor-not-allowed"
|
||||
: "bg-white border border-tertiary/20 hover:border-primary/30"
|
||||
}
|
||||
`}
|
||||
onClick={() => !isOutOfStock && setSelectedSize(sizeOption.id)}
|
||||
disabled={isOutOfStock}
|
||||
>
|
||||
{sizeOption.size_name}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{isOutOfStock && (
|
||||
<TooltipContent side="top">
|
||||
<p>Нет в наличии</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Доступность размера */}
|
||||
{selectedSize && (
|
||||
<div className="mt-2 text-xs font-medium">
|
||||
{isSizeInStock(selectedSize) ? (
|
||||
<span className="text-emerald-600 flex items-center">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
В наличии
|
||||
{getStockForCurrentSize() < 5 && (
|
||||
<span className="ml-1 text-amber-600">
|
||||
(осталось {getStockForCurrentSize()} шт.)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500">Нет в наличии</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">Размер</Label>
|
||||
<SizeModal />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Выбор количества */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium mb-3 text-primary">КОЛИЧЕСТВО</h3>
|
||||
<div className="flex items-center h-12 w-32 border border-tertiary/20 rounded-lg overflow-hidden bg-white">
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-full flex items-center justify-center text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={decreaseQuantity}
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 text-center font-medium text-sm">{quantity}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-10 h-full flex items-center justify-center text-primary/70 hover:text-primary transition-colors"
|
||||
onClick={increaseQuantity}
|
||||
disabled={selectedSize ? quantity >= getStockForCurrentSize() : true}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки добавления в корзину и быстрой покупки */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={addedToCart ? 'added' : 'not-added'}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="col-span-1 sm:col-span-2 lg:col-span-1"
|
||||
>
|
||||
{addedToCart ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-lg text-sm font-medium tracking-wide bg-emerald-600 hover:bg-emerald-700"
|
||||
disabled={true}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
ДОБАВЛЕНО В КОРЗИНУ
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-12 rounded-lg text-sm font-medium tracking-wide bg-primary hover:bg-primary/90"
|
||||
onClick={handleAddToCart}
|
||||
disabled={loading || (safeProduct.variants && safeProduct.variants.length > 0 && !selectedSize)}
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />
|
||||
В КОРЗИНУ
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 rounded-lg text-sm font-medium tracking-wide border-tertiary/20 hover:bg-tertiary/5 hover:border-primary/30 text-primary"
|
||||
onClick={handleBuyNow}
|
||||
disabled={loading || (safeProduct.variants && safeProduct.variants.length > 0 && !selectedSize)}
|
||||
<RadioGroup
|
||||
value={selectedSize}
|
||||
onValueChange={setSelectedSize}
|
||||
className="grid grid-cols-4 gap-2"
|
||||
>
|
||||
КУПИТЬ СЕЙЧАС
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
{product.variants?.map((variant) => (
|
||||
<div key={variant.id}>
|
||||
<RadioGroupItem
|
||||
value={variant.id.toString()}
|
||||
id={`size-${variant.id}`}
|
||||
className="peer sr-only"
|
||||
disabled={variant.stock === 0}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`size-${variant.id}`}
|
||||
className="flex h-10 items-center justify-center rounded-md border border-muted bg-popover p-2 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary peer-data-[state=checked]:text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
||||
>
|
||||
{variant.size.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Количество */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Количество</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setQuantity(q => Math.max(1, q - 1))}
|
||||
disabled={quantity === 1}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<span className="w-12 text-center">{quantity}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setQuantity(q => q + 1)}
|
||||
disabled={quantity === 10}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления в корзину */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedSize || loading}
|
||||
>
|
||||
{loading ? "Добавление..." : "Добавить в корзину"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
31
frontend/components/size-guide/size-modal.tsx
Normal file
31
frontend/components/size-guide/size-modal.tsx
Normal file
@ -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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
Таблица размеров
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-gray-900 p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">Таблица размеров</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<SizeTable />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
133
frontend/components/size-guide/size-table.tsx
Normal file
133
frontend/components/size-guide/size-table.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { Ruler, Info } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
|
||||
export function SizeTable() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-8">
|
||||
{/* Таблица размеров */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse bg-white dark:bg-gray-900">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Российский размер</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Размер производ-ля (INT)</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват груди, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват талии, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват бедер, см</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">40-42</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">XS</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">88-90</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">60-64</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">88-90</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">42-44</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">S</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">90-92</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">64-68</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">90-94</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">44-46</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">M</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">92-96</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">68-75</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">94-96</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">46-48</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">L</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">96-100</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">75-80</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">96-104</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">48-50</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">XL</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">100-108</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">80-84</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">104-108</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Инструкции по измерению */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="relative aspect-[3/4] bg-muted rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src="/images/size-guide.svg"
|
||||
alt="Схема измерений"
|
||||
fill
|
||||
className="object-contain p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
1. ОБХВАТ ГРУДИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента должна проходить по наиболее выступающим точкам груди, сбоку - под подмышечными впадинами, обхватываю лопатки сзади.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
2. ОБХВАТ ТАЛИИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется горизонтально в самой узкой части талии. При измерении лента должна плотно (без натяжения) прилегать к телу.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
3. ОБХВАТ БЕДЕР
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента проходит строго горизонтально по наиболее выступающим точкам ягодиц.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
4. ДЛИНА РУКАВОВ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется сантиметровой лентой от шва соединения с проймой до нижнего края рукава.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
5. ДЛИНА БРЮЧИН
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Данная мерка снимается по боковому шву от верхнего края пояса до нижнего края брюк.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 flex-shrink-0 mt-1" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Для наиболее точного определения размера рекомендуем снять мерки с себя или похожей одежды, сверить их с таблицей размеров и только после этого сделать заказ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
277
frontend/hooks/use-cart-provider.tsx
Normal file
277
frontend/hooks/use-cart-provider.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useMemo, useRef } from "react" // Добавляем useRef
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import type { CartItemCreate } from "@/lib/cart"
|
||||
// Объединяем импорты из cart-store
|
||||
import cartStore, { Cart, createEmptyCart } from "@/lib/cart-store"
|
||||
import { apiStatus } from "@/lib/api"
|
||||
|
||||
// Глобальная переменная для отслеживания последней синхронизации
|
||||
let lastSyncTimestamp = 0
|
||||
const SYNC_THROTTLE_MS = 5000 // Минимальное время между синхронизациями (5 секунд)
|
||||
|
||||
// Интерфейс контекста корзины
|
||||
interface CartContextType {
|
||||
cart: Cart
|
||||
loading: boolean
|
||||
error: string | null
|
||||
itemCount: number
|
||||
addToCart: (item: CartItemCreate) => Promise<boolean>
|
||||
updateCartItem: (id: number, quantity: number) => Promise<boolean>
|
||||
removeFromCart: (id: number) => Promise<boolean>
|
||||
clearCart: () => Promise<boolean>
|
||||
synchronizeCart: () => Promise<void>
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
// Создание контекста
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined)
|
||||
|
||||
// Удаляем дублирующийся импорт
|
||||
|
||||
// Провайдер контекста
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
// Инициализируем пустой корзиной, чтобы серверный и первый клиентский рендер совпадали
|
||||
const [cart, setCart] = useState<Cart>(createEmptyCart())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<CartContext.Provider value={value}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Хук для использования контекста корзины
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useCart должен использоваться внутри CartProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
210
frontend/hooks/useAdminApi.ts
Normal file
210
frontend/hooks/useAdminApi.ts
Normal file
@ -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<ApiStatus>('idle');
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [error, setError] = useState<any>(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 <T>(url: string, params = {}) => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
const response = await api.get<T>(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 <T>(url: string, data = {}, config = {}) => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
const response = await api.post<T>(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 <T>(url: string, data = {}, config = {}) => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
const response = await api.put<T>(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 <T>(url: string, config = {}) => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
const response = await api.delete<T>(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;
|
||||
191
frontend/hooks/useAdminCache.ts
Normal file
191
frontend/hooks/useAdminCache.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Интерфейс для кэшированных данных
|
||||
*/
|
||||
interface CacheItem<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для опций хука
|
||||
*/
|
||||
interface UseAdminCacheOptions {
|
||||
key: string;
|
||||
ttl?: number; // Время жизни кэша в миллисекундах
|
||||
storage?: 'memory' | 'session' | 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Глобальный кэш для хранения данных в памяти
|
||||
*/
|
||||
const memoryCache = new Map<string, CacheItem<any>>();
|
||||
|
||||
/**
|
||||
* Хук для кэширования данных в админке
|
||||
* @param options Опции хука
|
||||
* @returns Объект с функциями и состоянием
|
||||
*/
|
||||
export function useAdminCache<T>(options: UseAdminCacheOptions) {
|
||||
const {
|
||||
key,
|
||||
ttl = 5 * 60 * 1000, // 5 минут по умолчанию
|
||||
storage = 'memory'
|
||||
} = options;
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Получение данных из кэша
|
||||
*/
|
||||
const getFromCache = useCallback((): CacheItem<T> | null => {
|
||||
try {
|
||||
if (storage === 'memory') {
|
||||
return memoryCache.get(key) as CacheItem<T> || 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<T> = {
|
||||
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<T>) => {
|
||||
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;
|
||||
271
frontend/hooks/useApiRequest.ts
Normal file
271
frontend/hooks/useApiRequest.ts
Normal file
@ -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<T> {
|
||||
// URL для запроса
|
||||
url: string;
|
||||
// Метод запроса
|
||||
method?: 'get' | 'post' | 'put' | 'delete';
|
||||
// Параметры запроса
|
||||
params?: any;
|
||||
// Данные для отправки (для POST, PUT)
|
||||
data?: any;
|
||||
// Хедеры запроса
|
||||
headers?: Record<string, string>;
|
||||
// Автоматически выполнять запрос при монтировании
|
||||
autoFetch?: boolean;
|
||||
// Интервал для повторного запроса в миллисекундах
|
||||
refreshInterval?: number;
|
||||
// Количество автоматических повторных попыток при ошибке
|
||||
retries?: number;
|
||||
// Интервал между повторными попытками в миллисекундах
|
||||
retryInterval?: number;
|
||||
// Преобразователь для данных ответа
|
||||
dataTransformer?: (data: any) => T;
|
||||
// Функция для определения успешности ответа
|
||||
isSuccessful?: (response: any) => boolean;
|
||||
// Максимальное время запроса в миллисекундах
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface UseApiRequestResult<T> {
|
||||
// Данные ответа
|
||||
data: T | null;
|
||||
// Состояние загрузки
|
||||
loading: boolean;
|
||||
// Ошибка, если есть
|
||||
error: Error | null;
|
||||
// Функция для выполнения запроса
|
||||
fetchData: (config?: Partial<UseApiRequestOptions<T>>) => Promise<T | null>;
|
||||
// Функция для отмены текущего запроса
|
||||
cancelRequest: () => void;
|
||||
// Функция для сброса состояния
|
||||
reset: () => void;
|
||||
// Статус запроса
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
}
|
||||
|
||||
// AbortController используется вместо CancelTokenSource
|
||||
|
||||
/**
|
||||
* Хук для выполнения API-запросов с оптимизацией
|
||||
* @param options Параметры запроса
|
||||
* @returns Результат запроса и функции управления
|
||||
*/
|
||||
export function useApiRequest<T = any>(
|
||||
options: UseApiRequestOptions<T>
|
||||
): UseApiRequestResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
// Для хранения AbortController
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Для хранения таймера обновления
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Для отслеживания количества повторных попыток
|
||||
const retriesCountRef = useRef<number>(0);
|
||||
|
||||
// Кэш последних успешных ответов по URL
|
||||
const responseCache = useRef<Map<string, { data: T, timestamp: number }>>(new Map());
|
||||
|
||||
// Создаем ключ кэша на основе URL и параметров
|
||||
const getCacheKey = useCallback((url: string, params?: any, data?: any): string => {
|
||||
return `${url}${params ? `?${JSON.stringify(params)}` : ''}${data ? `|${JSON.stringify(data)}` : ''}`;
|
||||
}, []);
|
||||
|
||||
// Функция для отмены текущего запроса
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort('Запрос отменен пользователем'); // Используем abort()
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Функция для сброса состояния
|
||||
const reset = useCallback(() => {
|
||||
cancelRequest();
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setStatus('idle');
|
||||
retriesCountRef.current = 0;
|
||||
}, [cancelRequest]);
|
||||
|
||||
// Функция для создания источника отмены - больше не нужна, используем axios.CancelToken.source()
|
||||
|
||||
// Основная функция для выполнения запроса
|
||||
const fetchData = useCallback(
|
||||
async (overrideOptions?: Partial<UseApiRequestOptions<T>>): Promise<T | null> => {
|
||||
try {
|
||||
// Отменяем предыдущий запрос, если он есть
|
||||
cancelRequest();
|
||||
|
||||
// Обновляем опции запроса
|
||||
const currentOptions = { ...options, ...overrideOptions };
|
||||
const {
|
||||
url,
|
||||
method = 'get',
|
||||
params,
|
||||
data: requestData,
|
||||
headers,
|
||||
retries = 0,
|
||||
retryInterval = 1000,
|
||||
dataTransformer,
|
||||
isSuccessful = response => true,
|
||||
timeout = 30000
|
||||
} = currentOptions;
|
||||
|
||||
setLoading(true);
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
|
||||
// Проверяем наличие в кэше (только для GET запросов)
|
||||
if (method === 'get') {
|
||||
const cacheKey = getCacheKey(url || '', params || {}, null);
|
||||
const cachedResponse = responseCache.current.get(cacheKey);
|
||||
|
||||
// Используем кэш, если он не старше 5 минут
|
||||
if (cachedResponse && Date.now() - cachedResponse.timestamp < 5 * 60 * 1000) {
|
||||
console.log(`Используются кэшированные данные для ${url}`);
|
||||
setData(cachedResponse.data);
|
||||
setLoading(false);
|
||||
setStatus('success');
|
||||
return cachedResponse.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новый AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
// Выполняем запрос с помощью api-клиента, передавая signal
|
||||
let response;
|
||||
try {
|
||||
const config = { headers, timeout, signal }; // Добавляем signal в конфиг
|
||||
switch (method) {
|
||||
case 'get':
|
||||
response = await api.get(url, { params: params || {}, ...config }); // Передаем config
|
||||
break;
|
||||
case 'post':
|
||||
response = await api.post(url, requestData || {}, config); // Передаем config
|
||||
break;
|
||||
case 'put':
|
||||
response = await api.put(url, requestData || {}, config); // Передаем config
|
||||
break;
|
||||
case 'delete':
|
||||
response = await api.delete(url, config); // Передаем config
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Неподдерживаемый метод: ${method}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Проверяем успешность ответа
|
||||
if (!isSuccessful(response)) {
|
||||
throw new Error('Ответ API не соответствует ожидаемому формату');
|
||||
}
|
||||
|
||||
// Преобразуем данные, если указан трансформер
|
||||
const processedData = dataTransformer ? dataTransformer(response) : (response as T);
|
||||
|
||||
// Кэшируем ответ для GET запросов
|
||||
if (method === 'get') {
|
||||
const cacheKey = getCacheKey(url || '', params || {}, null);
|
||||
responseCache.current.set(cacheKey, {
|
||||
data: processedData,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Ограничиваем размер кэша
|
||||
if (responseCache.current.size > 50) {
|
||||
const oldestKey = responseCache.current.keys().next().value;
|
||||
responseCache.current.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем состояние с полученными данными
|
||||
setData(processedData);
|
||||
setLoading(false);
|
||||
setStatus('success');
|
||||
retriesCountRef.current = 0;
|
||||
|
||||
// Устанавливаем таймер для автоматического обновления, если задан
|
||||
if (currentOptions.refreshInterval && currentOptions.refreshInterval > 0) {
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
fetchData(overrideOptions);
|
||||
}, currentOptions.refreshInterval);
|
||||
}
|
||||
|
||||
return processedData;
|
||||
} catch (err: unknown) { // Явно типизируем err как unknown
|
||||
// Обработка ошибки отмены (AbortError)
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// Используем сообщение из AbortSignal, если оно есть, иначе стандартное
|
||||
const abortReason = (abortControllerRef.current?.signal.reason as string) || 'Запрос отменен';
|
||||
console.log('Запрос отменен:', abortReason);
|
||||
setLoading(false);
|
||||
setStatus('idle');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Обработка других ошибок
|
||||
console.error('Ошибка API-запроса:', err);
|
||||
const errorObj = err instanceof Error ? err : new Error('Неизвестная ошибка при запросе к API');
|
||||
setError(errorObj);
|
||||
setLoading(false);
|
||||
setStatus('error');
|
||||
|
||||
// Повторная попытка, если указано количество повторов
|
||||
const { retries = 0, retryInterval = 1000 } = { ...options, ...overrideOptions };
|
||||
|
||||
if (retriesCountRef.current < retries) {
|
||||
console.log(`Повторная попытка ${retriesCountRef.current + 1}/${retries} через ${retryInterval}мс`);
|
||||
retriesCountRef.current++;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||||
return fetchData(overrideOptions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// Убираем createCancelToken из зависимостей
|
||||
[options, cancelRequest, getCacheKey]
|
||||
);
|
||||
|
||||
// Автоматический запрос при монтировании компонента
|
||||
useEffect(() => {
|
||||
if (options.autoFetch) {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
// Очистка
|
||||
return () => {
|
||||
cancelRequest();
|
||||
};
|
||||
}, [fetchData, cancelRequest, options.autoFetch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
fetchData,
|
||||
cancelRequest,
|
||||
reset,
|
||||
status
|
||||
};
|
||||
}
|
||||
@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Cart, CartItemCreate } from '@/types/cart';
|
||||
import type { CartItemCreate } from '@/lib/cart';
|
||||
import type { Cart } from '@/lib/cart-store';
|
||||
import cartStore from '@/lib/cart-store';
|
||||
import { apiStatus } from '@/lib/api';
|
||||
|
||||
// Глобальная переменная для отслеживания последней синхронизации
|
||||
let lastSyncTimestamp = 0;
|
||||
const SYNC_THROTTLE_MS = 5000; // Минимальное время между синхронизациями (5 секунд)
|
||||
|
||||
export function useCart() {
|
||||
const [cart, setCart] = useState<Cart>(cartStore.getState());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const syncInProgressRef = useRef(false);
|
||||
|
||||
// Подписка на изменения корзины
|
||||
useEffect(() => {
|
||||
@ -23,14 +29,26 @@ export function useCart() {
|
||||
// Проверка аутентификации и синхронизация корзины
|
||||
useEffect(() => {
|
||||
if (apiStatus.isAuthenticated) {
|
||||
synchronizeCart();
|
||||
// Проверяем, не происходит ли уже синхронизация и не слишком ли рано для новой
|
||||
const now = Date.now();
|
||||
if (!syncInProgressRef.current && now - lastSyncTimestamp > SYNC_THROTTLE_MS) {
|
||||
synchronizeCart();
|
||||
}
|
||||
}
|
||||
}, [apiStatus.isAuthenticated]);
|
||||
|
||||
// Синхронизация локальной корзины с сервером
|
||||
// Синхронизация локальной корзины с сервером с дроттлингом
|
||||
const synchronizeCart = useCallback(async () => {
|
||||
try {
|
||||
// Если синхронизация уже идет, не запускаем новую
|
||||
if (syncInProgressRef.current) return;
|
||||
|
||||
syncInProgressRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// Фиксируем время последней синхронизации
|
||||
lastSyncTimestamp = Date.now();
|
||||
|
||||
await cartStore.syncWithServer();
|
||||
} catch (err) {
|
||||
setError('Не удалось синхронизировать корзину');
|
||||
@ -41,6 +59,7 @@ export function useCart() {
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
syncInProgressRef.current = false;
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
@ -185,6 +204,8 @@ export function useCart() {
|
||||
synchronizeCart,
|
||||
itemsCount: cartStore.getItemsCount(),
|
||||
itemCount: cartStore.getItemsCount(),
|
||||
isAuthenticated: apiStatus.isAuthenticated
|
||||
isAuthenticated: apiStatus.isAuthenticated,
|
||||
items_count: cart.items_count,
|
||||
total_amount: cart.total_amount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
406
frontend/hooks/useCatalogData.ts
Normal file
406
frontend/hooks/useCatalogData.ts
Normal file
@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'; // Добавляем useCallback
|
||||
import catalogService, {
|
||||
Category,
|
||||
Collection,
|
||||
Size,
|
||||
ProductsResponse,
|
||||
Product,
|
||||
ProductDetails
|
||||
} from '@/lib/catalog';
|
||||
|
||||
// Типы для параметров запроса продуктов
|
||||
export interface ProductQueryParams {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
category_id?: number;
|
||||
collection_id?: number;
|
||||
search?: string;
|
||||
min_price?: number;
|
||||
max_price?: number;
|
||||
is_active?: boolean;
|
||||
include_variants?: boolean;
|
||||
sort?: string;
|
||||
size_ids?: number[]; // Добавляем фильтр по размерам
|
||||
}
|
||||
|
||||
// Типы для кэша
|
||||
interface CategoriesCache {
|
||||
data: Category[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CollectionsCache {
|
||||
data: Collection[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SizesCache {
|
||||
data: Size[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ProductsCache {
|
||||
params: string; // Строковое представление параметров
|
||||
data: ProductsResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ProductCache {
|
||||
id: number;
|
||||
data: ProductDetails;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Глобальный кэш для хранения данных
|
||||
const globalCache = {
|
||||
categories: null as CategoriesCache | null,
|
||||
collections: null as CollectionsCache | null,
|
||||
sizes: null as SizesCache | null,
|
||||
products: [] as ProductsCache[],
|
||||
productDetails: [] as ProductCache[],
|
||||
};
|
||||
|
||||
// Время жизни кэша (в миллисекундах)
|
||||
const CACHE_TTL = {
|
||||
categories: 30 * 60 * 1000, // 30 минут
|
||||
collections: 30 * 60 * 1000, // 30 минут
|
||||
sizes: 60 * 60 * 1000, // 1 час
|
||||
products: 5 * 60 * 1000, // 5 минут
|
||||
productDetails: 10 * 60 * 1000, // 10 минут
|
||||
};
|
||||
|
||||
// Функция для проверки актуальности кэша
|
||||
const isCacheValid = (timestamp: number, ttl: number): boolean => {
|
||||
return Date.now() - timestamp < ttl;
|
||||
};
|
||||
|
||||
// Функция для преобразования параметров в строку для кэширования
|
||||
const paramsToString = (params: ProductQueryParams): string => {
|
||||
// Копируем параметры, чтобы не изменять оригинал
|
||||
const paramsCopy = { ...params };
|
||||
|
||||
// Сортируем массив size_ids для консистентности ключа кэша
|
||||
if (paramsCopy.size_ids && Array.isArray(paramsCopy.size_ids)) {
|
||||
// Убедимся, что сортируем копию массива
|
||||
paramsCopy.size_ids = [...paramsCopy.size_ids].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// Получаем записи объекта [ключ, значение]
|
||||
const entries = Object.entries(paramsCopy)
|
||||
// Фильтруем записи с undefined значением, чтобы они не попадали в ключ кэша
|
||||
.filter(([_, value]) => value !== undefined);
|
||||
|
||||
// Сортируем записи по ключу (лексикографически)
|
||||
entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
|
||||
|
||||
// Собираем отсортированные записи обратно в объект
|
||||
const sortedParams = Object.fromEntries(entries);
|
||||
|
||||
return JSON.stringify(sortedParams,);
|
||||
};
|
||||
|
||||
export function useCatalogData() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
const [loading, setLoading] = useState<{
|
||||
categories: boolean;
|
||||
collections: boolean;
|
||||
sizes: boolean;
|
||||
products: boolean;
|
||||
productDetails: boolean;
|
||||
}>({
|
||||
categories: false,
|
||||
collections: false,
|
||||
sizes: false,
|
||||
products: false,
|
||||
productDetails: false,
|
||||
});
|
||||
const [error, setError] = useState<{
|
||||
categories: string | null;
|
||||
collections: string | null;
|
||||
sizes: string | null;
|
||||
products: string | null;
|
||||
productDetails: string | null;
|
||||
}>({
|
||||
categories: null,
|
||||
collections: null,
|
||||
sizes: null,
|
||||
products: null,
|
||||
productDetails: null,
|
||||
});
|
||||
|
||||
// Загрузка категорий с кэшированием
|
||||
const fetchCategories = useCallback(async (): Promise<Category[]> => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, categories: true }));
|
||||
setError(prev => ({ ...prev, categories: null }));
|
||||
|
||||
// Проверяем кэш
|
||||
if (globalCache.categories && isCacheValid(globalCache.categories.timestamp, CACHE_TTL.categories)) {
|
||||
console.log('Используются кэшированные категории');
|
||||
setCategories(globalCache.categories.data);
|
||||
return globalCache.categories.data;
|
||||
}
|
||||
|
||||
console.log('Загрузка категорий с сервера...');
|
||||
const data = await catalogService.getCategoriesTree();
|
||||
|
||||
console.log('Полученные категории:', data);
|
||||
|
||||
// Проверяем, что данные не пустые
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
console.warn('Получен пустой список категорий');
|
||||
}
|
||||
|
||||
// Обновляем кэш и состояние
|
||||
globalCache.categories = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setCategories(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке категорий:", err);
|
||||
setError(prev => ({ ...prev, categories: "Не удалось загрузить категории" }));
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, categories: false }));
|
||||
}
|
||||
}, []); // Пустой массив зависимостей, т.к. функция не зависит от пропсов или состояния хука
|
||||
|
||||
// Загрузка коллекций с кэшированием
|
||||
const fetchCollections = useCallback(async (): Promise<Collection[]> => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, collections: true }));
|
||||
setError(prev => ({ ...prev, collections: null }));
|
||||
|
||||
// Проверяем кэш
|
||||
if (globalCache.collections && isCacheValid(globalCache.collections.timestamp, CACHE_TTL.collections)) {
|
||||
console.log('Используются кэшированные коллекции');
|
||||
setCollections(globalCache.collections.data);
|
||||
return globalCache.collections.data;
|
||||
}
|
||||
|
||||
console.log('Загрузка коллекций с сервера...');
|
||||
const response = await catalogService.getCollections();
|
||||
const data = response.collections || [];
|
||||
|
||||
// Обновляем кэш и состояние
|
||||
globalCache.collections = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setCollections(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке коллекций:", err);
|
||||
setError(prev => ({ ...prev, collections: "Не удалось загрузить коллекции" }));
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, collections: false }));
|
||||
}
|
||||
}, []); // Пустой массив зависимостей
|
||||
|
||||
// Загрузка размеров с кэшированием
|
||||
const fetchSizes = useCallback(async (): Promise<Size[]> => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, sizes: true }));
|
||||
setError(prev => ({ ...prev, sizes: null }));
|
||||
|
||||
// Проверяем кэш
|
||||
if (globalCache.sizes && isCacheValid(globalCache.sizes.timestamp, CACHE_TTL.sizes)) {
|
||||
console.log('Используются кэшированные размеры');
|
||||
setSizes(globalCache.sizes.data);
|
||||
return globalCache.sizes.data;
|
||||
}
|
||||
|
||||
console.log('Загрузка размеров с сервера...');
|
||||
const data = await catalogService.getSizes();
|
||||
|
||||
console.log('Полученные размеры:', data);
|
||||
|
||||
// Проверяем, что данные не пустые
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
console.warn('Получен пустой список размеров');
|
||||
}
|
||||
|
||||
// Обновляем кэш и состояние
|
||||
globalCache.sizes = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setSizes(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке размеров:", err);
|
||||
setError(prev => ({ ...prev, sizes: "Не удалось загрузить размеры" }));
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, sizes: false }));
|
||||
}
|
||||
}, []); // Пустой массив зависимостей
|
||||
|
||||
// Загрузка продуктов с кэшированием
|
||||
const fetchProducts = useCallback(async (params: ProductQueryParams): Promise<ProductsResponse> => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
setError(prev => ({ ...prev, products: null }));
|
||||
|
||||
const paramsString = paramsToString(params);
|
||||
|
||||
// Проверяем кэш
|
||||
const cachedProducts = globalCache.products.find(
|
||||
cache => cache.params === paramsString && isCacheValid(cache.timestamp, CACHE_TTL.products)
|
||||
);
|
||||
|
||||
if (cachedProducts) {
|
||||
console.log('Используются кэшированные продукты');
|
||||
return cachedProducts.data;
|
||||
}
|
||||
|
||||
console.log('Загрузка продуктов с сервера с параметрами:', params);
|
||||
const data = await catalogService.getProducts(params) as ProductsResponse;
|
||||
|
||||
// Обновляем кэш
|
||||
const existingIndex = globalCache.products.findIndex(cache => cache.params === paramsString);
|
||||
if (existingIndex !== -1) {
|
||||
globalCache.products[existingIndex] = {
|
||||
params: paramsString,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} else {
|
||||
// Ограничиваем размер кэша до 20 запросов
|
||||
if (globalCache.products.length >= 20) {
|
||||
globalCache.products.shift(); // Удаляем самый старый запрос
|
||||
}
|
||||
globalCache.products.push({
|
||||
params: paramsString,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке продуктов:", err);
|
||||
setError(prev => ({ ...prev, products: "Не удалось загрузить продукты" }));
|
||||
return { products: [], total: 0 };
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
}, []); // Пустой массив зависимостей (params передаются как аргумент)
|
||||
|
||||
// Загрузка деталей продукта с кэшированием
|
||||
const fetchProductDetails = useCallback(async (productId: number): Promise<ProductDetails | null> => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, productDetails: true }));
|
||||
setError(prev => ({ ...prev, productDetails: null }));
|
||||
|
||||
// Проверяем кэш
|
||||
const cachedProduct = globalCache.productDetails.find(
|
||||
cache => cache.id === productId && isCacheValid(cache.timestamp, CACHE_TTL.productDetails)
|
||||
);
|
||||
|
||||
if (cachedProduct) {
|
||||
console.log(`Используются кэшированные детали продукта ${productId}`);
|
||||
return cachedProduct.data;
|
||||
}
|
||||
|
||||
console.log(`Загрузка деталей продукта ${productId} с сервера...`);
|
||||
const data = await catalogService.getProductById(productId);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Обновляем кэш
|
||||
const existingIndex = globalCache.productDetails.findIndex(cache => cache.id === productId);
|
||||
if (existingIndex !== -1) {
|
||||
globalCache.productDetails[existingIndex] = {
|
||||
id: productId,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} else {
|
||||
// Ограничиваем размер кэша до 50 продуктов
|
||||
if (globalCache.productDetails.length >= 50) {
|
||||
globalCache.productDetails.shift(); // Удаляем самый старый продукт
|
||||
}
|
||||
globalCache.productDetails.push({
|
||||
id: productId,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`Ошибка при загрузке деталей продукта ${productId}:`, err);
|
||||
setError(prev => ({ ...prev, productDetails: "Не удалось загрузить детали продукта" }));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, productDetails: false }));
|
||||
}
|
||||
}, []); // Пустой массив зависимостей (productId передается как аргумент)
|
||||
|
||||
// Очистка кэша (при необходимости)
|
||||
const clearCache = useCallback((type?: 'categories' | 'collections' | 'sizes' | 'products' | 'productDetails') => {
|
||||
if (!type) {
|
||||
// Очистка всего кэша
|
||||
globalCache.categories = null;
|
||||
globalCache.collections = null;
|
||||
globalCache.sizes = null;
|
||||
globalCache.products = [];
|
||||
globalCache.productDetails = [];
|
||||
console.log('Весь кэш очищен');
|
||||
} else {
|
||||
// Очистка конкретного типа кэша
|
||||
switch (type) {
|
||||
case 'categories':
|
||||
globalCache.categories = null;
|
||||
break;
|
||||
case 'collections':
|
||||
globalCache.collections = null;
|
||||
break;
|
||||
case 'sizes':
|
||||
globalCache.sizes = null;
|
||||
break;
|
||||
case 'products':
|
||||
globalCache.products = [];
|
||||
break;
|
||||
case 'productDetails':
|
||||
globalCache.productDetails = [];
|
||||
break;
|
||||
}
|
||||
console.log(`Кэш типа ${type} очищен`);
|
||||
}
|
||||
}, []); // Пустой массив зависимостей
|
||||
|
||||
return {
|
||||
// Данные
|
||||
categories,
|
||||
collections,
|
||||
sizes,
|
||||
|
||||
// Состояния загрузки
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Методы для загрузки данных
|
||||
fetchCategories,
|
||||
fetchCollections,
|
||||
fetchSizes,
|
||||
fetchProducts,
|
||||
fetchProductDetails,
|
||||
|
||||
// Управление кэшем
|
||||
clearCache,
|
||||
};
|
||||
}
|
||||
27
frontend/hooks/useDebounce.ts
Normal file
27
frontend/hooks/useDebounce.ts
Normal file
@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Хук для создания дебаунсинга значения
|
||||
* @param value Значение, которое нужно дебаунсить
|
||||
* @param delay Задержка в миллисекундах (по умолчанию 500мс)
|
||||
* @returns Дебаунсированное значение
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем таймер, который обновит значение через delay мс
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Очищаем таймер при изменении value или при размонтировании
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import api from './api';
|
||||
import { Order } from './orders';
|
||||
import { Product } from './catalog';
|
||||
import { handleApiError } from './api-error-handler';
|
||||
|
||||
/**
|
||||
* Интерфейс статистики для дашборда
|
||||
@ -45,7 +46,7 @@ export interface AdminProduct {
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс ответа API
|
||||
* Интерфейс ответа API
|
||||
*/
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
@ -63,18 +64,8 @@ export async function fetchDashboardStats(): Promise<ApiResponse<DashboardStats>
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении статистики дашборда:', error);
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: {
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
},
|
||||
status: 200
|
||||
};
|
||||
const apiError = handleApiError(error, 'Ошибка при получении статистики дашборда');
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +85,7 @@ export interface OrdersParams {
|
||||
*/
|
||||
export async function fetchRecentOrders(params?: OrdersParams): Promise<ApiResponse<AdminOrder[]>> {
|
||||
try {
|
||||
const response = await api.get<AdminOrder[]>('/admin/orders/recent', {
|
||||
const response = await api.get<AdminOrder[]>('/admin/orders/recent', {
|
||||
params: {
|
||||
limit: params?.limit || 5,
|
||||
offset: params?.offset || 0,
|
||||
@ -108,18 +99,8 @@ export async function fetchRecentOrders(params?: OrdersParams): Promise<ApiRespo
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении последних заказов:', error);
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: [
|
||||
{ id: 1, user_id: 101, user_name: 'Иван Иванов', created_at: '2023-03-15T14:30:00Z', status: 'delivered', total: 12500 },
|
||||
{ id: 2, user_id: 102, user_name: 'Анна Петрова', created_at: '2023-03-14T10:15:00Z', status: 'shipped', total: 8750 },
|
||||
{ id: 3, user_id: 103, user_name: 'Сергей Сидоров', created_at: '2023-03-13T18:45:00Z', status: 'processing', total: 15200 },
|
||||
{ id: 4, user_id: 104, user_name: 'Елена Смирнова', created_at: '2023-03-12T09:20:00Z', status: 'paid', total: 6300 }
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
const apiError = handleApiError(error, 'Ошибка при получении последних заказов');
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,44 +133,44 @@ export async function fetchPopularProducts(params?: ProductsParams): Promise<Api
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении популярных товаров:', error);
|
||||
|
||||
|
||||
// Возвращаем моковые данные для тестирования
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Платье классическое',
|
||||
{
|
||||
id: 1,
|
||||
name: 'Платье классическое',
|
||||
price: 7999,
|
||||
category: { id: 1, name: 'Платья' },
|
||||
sales: 124,
|
||||
stock: 23
|
||||
category: { id: 1, name: 'Платья' },
|
||||
sales: 124,
|
||||
stock: 23
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Блуза белая',
|
||||
{
|
||||
id: 2,
|
||||
name: 'Блуза белая',
|
||||
price: 4999,
|
||||
category: { id: 2, name: 'Блузы' },
|
||||
sales: 98,
|
||||
stock: 15
|
||||
category: { id: 2, name: 'Блузы' },
|
||||
sales: 98,
|
||||
stock: 15
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Брюки прямые',
|
||||
{
|
||||
id: 3,
|
||||
name: 'Брюки прямые',
|
||||
price: 5999,
|
||||
category: { id: 3, name: 'Брюки' },
|
||||
sales: 87,
|
||||
stock: 8
|
||||
category: { id: 3, name: 'Брюки' },
|
||||
sales: 87,
|
||||
stock: 8
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Юбка миди',
|
||||
{
|
||||
id: 4,
|
||||
name: 'Юбка миди',
|
||||
price: 4599,
|
||||
category: { id: 4, name: 'Юбки' },
|
||||
sales: 76,
|
||||
stock: 12
|
||||
category: { id: 4, name: 'Юбки' },
|
||||
sales: 76,
|
||||
stock: 12
|
||||
}
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
122
frontend/lib/api-cache.ts
Normal file
122
frontend/lib/api-cache.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Создаем экземпляр QueryClient для использования в приложении
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Настройки по умолчанию для всех запросов
|
||||
staleTime: 5 * 60 * 1000, // 5 минут
|
||||
cacheTime: 10 * 60 * 1000, // 10 минут
|
||||
retry: 1, // Одна повторная попытка при ошибке
|
||||
refetchOnWindowFocus: false, // Не обновлять данные при фокусе окна
|
||||
refetchOnMount: true, // Обновлять данные при монтировании компонента
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ключи кэша для различных типов данных
|
||||
*/
|
||||
export const cacheKeys = {
|
||||
// Дашборд
|
||||
dashboardStats: 'dashboard-stats',
|
||||
recentOrders: 'recent-orders',
|
||||
popularProducts: 'popular-products',
|
||||
|
||||
// Каталог
|
||||
products: 'products',
|
||||
product: (id: number | string) => ['product', id.toString()],
|
||||
categories: 'categories',
|
||||
collections: 'collections',
|
||||
sizes: 'sizes',
|
||||
|
||||
// Заказы
|
||||
orders: 'orders',
|
||||
order: (id: number | string) => ['order', id.toString()],
|
||||
|
||||
// Пользователи
|
||||
customers: 'customers',
|
||||
customer: (id: number | string) => ['customer', id.toString()],
|
||||
};
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша при изменении данных
|
||||
* @param keys Массив ключей кэша для инвалидации
|
||||
*/
|
||||
export function invalidateCache(keys: string | string[] | (string | string[])[]): void {
|
||||
const keysArray = Array.isArray(keys) ? keys : [keys];
|
||||
|
||||
keysArray.forEach(key => {
|
||||
if (Array.isArray(key)) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша продуктов
|
||||
*/
|
||||
export function invalidateProductsCache(): void {
|
||||
invalidateCache([
|
||||
cacheKeys.products,
|
||||
cacheKeys.popularProducts,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.dashboardStats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша заказов
|
||||
*/
|
||||
export function invalidateOrdersCache(): void {
|
||||
invalidateCache([
|
||||
cacheKeys.orders,
|
||||
cacheKeys.recentOrders,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.dashboardStats
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша категорий
|
||||
*/
|
||||
export function invalidateCategoriesCache(): void {
|
||||
invalidateCache([
|
||||
cacheKeys.categories,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.products
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша коллекций
|
||||
*/
|
||||
export function invalidateCollectionsCache(): void {
|
||||
invalidateCache([
|
||||
cacheKeys.collections,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.products
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для инвалидации кэша размеров
|
||||
*/
|
||||
export function invalidateSizesCache(): void {
|
||||
invalidateCache([
|
||||
cacheKeys.sizes
|
||||
]);
|
||||
}
|
||||
|
||||
export default {
|
||||
queryClient,
|
||||
cacheKeys,
|
||||
invalidateCache,
|
||||
invalidateProductsCache,
|
||||
invalidateOrdersCache,
|
||||
invalidateCategoriesCache,
|
||||
invalidateCollectionsCache,
|
||||
invalidateSizesCache
|
||||
};
|
||||
156
frontend/lib/api-error-handler.ts
Normal file
156
frontend/lib/api-error-handler.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Типы ошибок API
|
||||
*/
|
||||
export enum ApiErrorType {
|
||||
NETWORK = 'network',
|
||||
VALIDATION = 'validation',
|
||||
AUTHENTICATION = 'authentication',
|
||||
AUTHORIZATION = 'authorization',
|
||||
NOT_FOUND = 'not_found',
|
||||
SERVER = 'server',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс ошибки API
|
||||
*/
|
||||
export interface ApiError {
|
||||
type: ApiErrorType;
|
||||
message: string;
|
||||
details?: any;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для обработки ошибок API
|
||||
* @param error Ошибка, полученная при запросе
|
||||
* @param defaultMessage Сообщение по умолчанию
|
||||
* @returns Объект ApiError с информацией об ошибке
|
||||
*/
|
||||
export function handleApiError(error: any, defaultMessage: string = 'Произошла ошибка при выполнении запроса'): ApiError {
|
||||
console.error('API Error:', error);
|
||||
|
||||
// Если ошибка уже в нужном формате, возвращаем её
|
||||
if (error && error.type && Object.values(ApiErrorType).includes(error.type)) {
|
||||
return error as ApiError;
|
||||
}
|
||||
|
||||
// Определяем тип ошибки и сообщение
|
||||
let type = ApiErrorType.UNKNOWN;
|
||||
let message = defaultMessage;
|
||||
let details = undefined;
|
||||
let status = undefined;
|
||||
|
||||
// Проверяем, есть ли у ошибки response (axios error)
|
||||
if (error?.response) {
|
||||
status = error.response.status;
|
||||
|
||||
// Получаем данные из ответа
|
||||
const responseData = error.response.data;
|
||||
|
||||
// Определяем тип ошибки по статусу
|
||||
switch (status) {
|
||||
case 400:
|
||||
type = ApiErrorType.VALIDATION;
|
||||
message = responseData?.detail || responseData?.message || 'Ошибка валидации данных';
|
||||
details = responseData?.errors || responseData?.detail;
|
||||
break;
|
||||
case 401:
|
||||
type = ApiErrorType.AUTHENTICATION;
|
||||
message = 'Необходима авторизация';
|
||||
break;
|
||||
case 403:
|
||||
type = ApiErrorType.AUTHORIZATION;
|
||||
message = 'Доступ запрещен';
|
||||
break;
|
||||
case 404:
|
||||
type = ApiErrorType.NOT_FOUND;
|
||||
message = responseData?.detail || 'Ресурс не найден';
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
type = ApiErrorType.SERVER;
|
||||
message = 'Ошибка сервера. Пожалуйста, попробуйте позже';
|
||||
break;
|
||||
default:
|
||||
message = responseData?.detail || responseData?.message || defaultMessage;
|
||||
}
|
||||
} else if (error?.request) {
|
||||
// Ошибка сети (запрос был отправлен, но ответ не получен)
|
||||
type = ApiErrorType.NETWORK;
|
||||
message = 'Не удалось соединиться с сервером. Проверьте подключение к интернету';
|
||||
} else if (error?.message) {
|
||||
// Другие ошибки с сообщением
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
return { type, message, details, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для отображения ошибки API пользователю
|
||||
* @param error Объект ошибки API или любая другая ошибка
|
||||
* @param defaultMessage Сообщение по умолчанию
|
||||
*/
|
||||
export function showApiError(error: any, defaultMessage: string = 'Произошла ошибка при выполнении запроса'): void {
|
||||
const apiError = handleApiError(error, defaultMessage);
|
||||
|
||||
// Отображаем ошибку пользователю
|
||||
toast.error(apiError.message);
|
||||
|
||||
// Для ошибок валидации можно отобразить детали
|
||||
if (apiError.type === ApiErrorType.VALIDATION && apiError.details) {
|
||||
// Если details - это объект с полями ошибок
|
||||
if (typeof apiError.details === 'object' && !Array.isArray(apiError.details)) {
|
||||
Object.entries(apiError.details).forEach(([field, errors]) => {
|
||||
if (Array.isArray(errors)) {
|
||||
errors.forEach(error => toast.error(`${field}: ${error}`));
|
||||
} else if (typeof errors === 'string') {
|
||||
toast.error(`${field}: ${errors}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Если details - это массив ошибок
|
||||
else if (Array.isArray(apiError.details)) {
|
||||
apiError.details.forEach(error => {
|
||||
if (typeof error === 'string') {
|
||||
toast.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция для обработки ошибок API в компонентах
|
||||
* @param error Ошибка, полученная при запросе
|
||||
* @param defaultMessage Сообщение по умолчанию
|
||||
* @param setError Функция для установки ошибки в состоянии компонента
|
||||
*/
|
||||
export function handleComponentError(
|
||||
error: any,
|
||||
defaultMessage: string = 'Произошла ошибка при выполнении запроса',
|
||||
setError?: (error: string) => void
|
||||
): void {
|
||||
const apiError = handleApiError(error, defaultMessage);
|
||||
|
||||
// Отображаем ошибку пользователю
|
||||
showApiError(apiError);
|
||||
|
||||
// Если передана функция setError, устанавливаем сообщение об ошибке
|
||||
if (setError) {
|
||||
setError(apiError.message);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
handleApiError,
|
||||
showApiError,
|
||||
handleComponentError,
|
||||
ApiErrorType
|
||||
};
|
||||
@ -9,11 +9,12 @@ const TOKEN_KEY = 'token';
|
||||
|
||||
// Отладочный режим для API
|
||||
export const apiStatus = {
|
||||
debugMode: process.env.NEXT_PUBLIC_DEBUG === 'true',
|
||||
debugMode: true, // Временно включаем отладочный режим для диагностики проблем
|
||||
// debugMode: process.env.NEXT_PUBLIC_DEBUG === 'true',
|
||||
connectionError: false,
|
||||
lastConnectionError: null as Error | null,
|
||||
isAuthenticated: false, // Флаг аутентификации пользователя
|
||||
|
||||
|
||||
// Метод для проверки статуса соединения с API
|
||||
checkConnection: async (): Promise<boolean> => {
|
||||
try {
|
||||
@ -21,7 +22,7 @@ export const apiStatus = {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
|
||||
apiStatus.connectionError = !response.ok;
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
@ -63,7 +64,7 @@ instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// Получаем токен из localStorage (если он есть)
|
||||
const token = typeof window !== 'undefined' ? getToken() : null;
|
||||
|
||||
|
||||
// Если токен есть, добавляем его в заголовки запроса
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
@ -72,12 +73,12 @@ instance.interceptors.request.use(
|
||||
} else {
|
||||
apiStatus.isAuthenticated = false;
|
||||
}
|
||||
|
||||
|
||||
// Логируем URL запроса в режиме отладки
|
||||
if (apiStatus.debugMode) {
|
||||
console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
}
|
||||
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@ -102,19 +103,19 @@ instance.interceptors.response.use(
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Для запросов к корзине, когда пользователь не аутентифицирован,
|
||||
// просто возвращаем пустой объект без логирования ошибки
|
||||
if (!apiStatus.isAuthenticated &&
|
||||
error.config.url &&
|
||||
if (!apiStatus.isAuthenticated &&
|
||||
error.config.url &&
|
||||
(error.config.url.includes('/cart') || error.config.url.includes('/wishlist'))) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
|
||||
// Логируем 401 ошибку, но НЕ удаляем токен, чтобы избежать проблем при перезагрузке
|
||||
console.log('Получен 401 ответ от API. Возможно, токен истек или недействителен.');
|
||||
|
||||
|
||||
// Больше не перенаправляем на страницу входа автоматически
|
||||
// это должно делаться на уровне компонентов проверки авторизации
|
||||
}
|
||||
|
||||
|
||||
// Логируем ошибки в режиме отладки
|
||||
if (apiStatus.debugMode) {
|
||||
if (error.response) {
|
||||
@ -126,7 +127,7 @@ instance.interceptors.response.use(
|
||||
console.error('API Request Setup Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@ -152,7 +153,7 @@ const api = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// POST запрос
|
||||
post: async <T>(url: string, data = {}, config = {}): Promise<T> => {
|
||||
try {
|
||||
@ -164,7 +165,7 @@ const api = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// PUT запрос
|
||||
put: async <T>(url: string, data = {}, config = {}): Promise<T> => {
|
||||
try {
|
||||
@ -176,7 +177,7 @@ const api = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// DELETE запрос
|
||||
delete: async <T>(url: string, config = {}): Promise<T> => {
|
||||
try {
|
||||
@ -197,24 +198,24 @@ export async function fetchApi<T>(
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const token = typeof window !== 'undefined' ? getToken() : null;
|
||||
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(options.headers as Record<string, string> || {})
|
||||
};
|
||||
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(`${PUBLIC_API_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@ -222,7 +223,7 @@ export async function fetchApi<T>(
|
||||
message: data.message || 'Произошла ошибка при выполнении запроса',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data as T,
|
||||
@ -236,4 +237,4 @@ export async function fetchApi<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export default api;
|
||||
export default api;
|
||||
@ -1,8 +1,93 @@
|
||||
// Хранилище корзины с поддержкой LocalStorage и серверной синхронизации
|
||||
import { Cart, CartItem, CartItemCreate, CartItemUpdate } from '@/types/cart';
|
||||
import catalogService from './catalog';
|
||||
import cartService from './cart';
|
||||
import { apiStatus } from './api';
|
||||
import type { Cart as ServerCart } from './cart';
|
||||
import type { Cart as ServerCart, CartItemCreate, CartItemUpdate } from './cart';
|
||||
import type { ProductDetails } from './catalog';
|
||||
|
||||
// Кэш для продуктов, чтобы избежать повторных запросов
|
||||
interface ProductCache {
|
||||
[productId: number]: {
|
||||
data: ProductDetails;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Глобальный кэш для хранения данных
|
||||
const productCache: ProductCache = {};
|
||||
|
||||
// Максимальное время жизни кэша (в миллисекундах)
|
||||
const PRODUCT_CACHE_TTL = 10 * 60 * 1000; // 10 минут
|
||||
|
||||
// Интервал между запросами к серверу
|
||||
const API_REQUEST_THROTTLE = 300; // 300 мс между запросами
|
||||
|
||||
// Для троттлинга запросов
|
||||
let lastRequestTime = 0;
|
||||
|
||||
// Функция для троттлинга запросов
|
||||
const throttledRequest = async <T>(requestFn: () => Promise<T>): Promise<T> => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - lastRequestTime;
|
||||
|
||||
// Если время с последнего запроса меньше минимального интервала, ждем
|
||||
if (timeSinceLastRequest < API_REQUEST_THROTTLE) {
|
||||
await new Promise(resolve => setTimeout(resolve, API_REQUEST_THROTTLE - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
lastRequestTime = Date.now();
|
||||
return requestFn();
|
||||
};
|
||||
|
||||
// Функция для получения данных о продукте с использованием кэша
|
||||
const getCachedProductDetails = async (productId: number): Promise<ProductDetails | null> => {
|
||||
try {
|
||||
// Проверяем наличие в кэше и актуальность
|
||||
const cached = productCache[productId];
|
||||
if (cached && (Date.now() - cached.timestamp) < PRODUCT_CACHE_TTL) {
|
||||
console.log(`Используются кэшированные данные о продукте ${productId}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Если нет в кэше или устарел, делаем запрос с троттлингом
|
||||
console.log(`Загрузка данных о продукте ${productId} с сервера...`);
|
||||
const productData = await throttledRequest(() => catalogService.getProductById(productId));
|
||||
|
||||
if (productData) {
|
||||
// Обновляем кэш
|
||||
productCache[productId] = {
|
||||
data: productData,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return productData;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Не удалось загрузить данные о продукте ${productId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Локальный тип корзины для хранения в localStorage
|
||||
export interface Cart {
|
||||
id: number;
|
||||
items: Array<{
|
||||
id: number;
|
||||
product_id: number;
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
product_name: string;
|
||||
variant_name: string;
|
||||
price: number;
|
||||
total_price: number;
|
||||
slug: string;
|
||||
product_image?: string;
|
||||
}>;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Ключ для хранения данных корзины в localStorage
|
||||
const CART_STORAGE_KEY = 'user_cart';
|
||||
@ -11,29 +96,32 @@ const CART_STORAGE_KEY = 'user_cart';
|
||||
export const createEmptyCart = (): Cart => ({
|
||||
id: 0,
|
||||
items: [],
|
||||
total_items: 0,
|
||||
total_price: 0,
|
||||
items_count: 0,
|
||||
total_amount: 0,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Преобразование серверной модели корзины в локальную
|
||||
function convertServerCartToLocalCart(serverCart: ServerCart): Cart {
|
||||
return {
|
||||
id: 0, // Серверная корзина может не иметь ID
|
||||
id: 0, // У локальной корзины нет ID с сервера
|
||||
items: serverCart.items.map(item => ({
|
||||
id: item.id,
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity,
|
||||
product_name: item.product?.name || 'Товар',
|
||||
variant_name: 'Вариант', // Заглушка
|
||||
price: item.product?.price || 0,
|
||||
total_price: (item.product?.price || 0) * item.quantity,
|
||||
slug: item.product?.slug || ''
|
||||
// Используем прямые поля из ответа API
|
||||
product_name: item.product_name || 'Товар',
|
||||
variant_name: item.variant_name || 'Вариант',
|
||||
price: item.product_price || 0, // Используем product_price
|
||||
total_price: item.total_price || 0, // Используем total_price
|
||||
slug: item.slug || '',
|
||||
product_image: item.product_image || undefined // Используем product_image
|
||||
})),
|
||||
total_items: serverCart.total_items,
|
||||
total_price: serverCart.total_amount || 0,
|
||||
created_at: new Date().toISOString()
|
||||
// Используем правильные поля для итогов
|
||||
items_count: serverCart.items_count || 0,
|
||||
total_amount: serverCart.total_amount || 0,
|
||||
created_at: new Date().toISOString() // Оставляем локальное время создания/обновления
|
||||
};
|
||||
}
|
||||
|
||||
@ -41,6 +129,8 @@ function convertServerCartToLocalCart(serverCart: ServerCart): Cart {
|
||||
class CartStore {
|
||||
private cart: Cart = createEmptyCart();
|
||||
private listeners: Array<() => void> = [];
|
||||
private syncInProgress: boolean = false;
|
||||
private syncRequested: boolean = false;
|
||||
|
||||
// Инициализация корзины при запуске
|
||||
constructor() {
|
||||
@ -79,6 +169,7 @@ class CartStore {
|
||||
const storedCart = localStorage.getItem(CART_STORAGE_KEY);
|
||||
if (storedCart) {
|
||||
this.cart = JSON.parse(storedCart);
|
||||
this.recalculateCart();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке корзины из localStorage:', error);
|
||||
@ -89,74 +180,37 @@ class CartStore {
|
||||
|
||||
// Синхронизация с сервером если пользователь авторизован
|
||||
async syncWithServer(): Promise<void> {
|
||||
// Если уже идет синхронизация, отмечаем флаг запроса
|
||||
if (this.syncInProgress) {
|
||||
this.syncRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiStatus.isAuthenticated) {
|
||||
try {
|
||||
// Получаем корзину с сервера
|
||||
const serverCart = await cartService.getCart();
|
||||
|
||||
// Если локальная корзина не пуста, синхронизируем её с сервером
|
||||
if (this.cart.items.length > 0) {
|
||||
// Для каждого товара в локальной корзине проверяем наличие на сервере
|
||||
// и добавляем если его там нет
|
||||
for (const item of this.cart.items) {
|
||||
const serverItem = serverCart.items.find(
|
||||
i => i.product_id === item.product_id && i.variant_id === item.variant_id
|
||||
);
|
||||
|
||||
if (!serverItem) {
|
||||
// Добавляем товар на сервер
|
||||
await cartService.addToCart({
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity
|
||||
});
|
||||
} else if (serverItem.quantity !== item.quantity) {
|
||||
// Обновляем количество если отличается
|
||||
await cartService.updateCartItem(serverItem.id, { quantity: item.quantity });
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем обновленную корзину с сервера
|
||||
const updatedServerCart = await cartService.getCart();
|
||||
this.cart = convertServerCartToLocalCart(updatedServerCart);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
} else {
|
||||
// Если локальная корзина пуста, просто берем данные с сервера
|
||||
this.cart = convertServerCartToLocalCart(serverCart);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при синхронизации корзины с сервером:', error);
|
||||
}
|
||||
// Синхронизация с сервером отключена, используем только локальную корзину
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление корзины и уведомление подписчиков
|
||||
private updateCart(newCart: Cart): void {
|
||||
this.cart = newCart;
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
}
|
||||
// Удаляем метод updateCart, его логика переносится
|
||||
|
||||
// Пересчет итогов корзины
|
||||
// Пересчет итогов корзины (без сохранения и уведомления)
|
||||
private recalculateCart(): void {
|
||||
// Правильно подсчитываем количество товаров
|
||||
this.cart.total_items = this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
this.cart.items_count = this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
// Пересчитываем общую стоимость
|
||||
this.cart.total_price = this.cart.items.reduce(
|
||||
this.cart.total_amount = this.cart.items.reduce(
|
||||
(sum, item) => sum + (item.price || 0) * item.quantity,
|
||||
0
|
||||
);
|
||||
|
||||
// Обязательно сохраняем в хранилище и уведомляем слушателей
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
// НЕ сохраняем и НЕ уведомляем здесь
|
||||
// this.saveToStorage();
|
||||
// this.notify();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log(`Корзина обновлена: ${this.cart.total_items} товаров на сумму ${this.cart.total_price}`);
|
||||
console.log(`Корзина обновлена: ${this.cart.items_count} товаров на сумму ${this.cart.total_amount}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,41 +225,36 @@ class CartStore {
|
||||
// Создаем копию корзины
|
||||
const newCart = { ...this.cart, items: [...this.cart.items] };
|
||||
|
||||
// Загружаем данные о продукте через catalogService
|
||||
let productData: any = null;
|
||||
// Загружаем данные о продукте из кэша или API
|
||||
let productData: ProductDetails | null = null;
|
||||
try {
|
||||
// Импорт нужен здесь, чтобы избежать циклических зависимостей
|
||||
const catalogService = await import('./catalog').then(module => module.default);
|
||||
productData = await catalogService.getProductById(item.product_id);
|
||||
console.log('Загружены данные о продукте:', productData?.name, productData?.id);
|
||||
productData = await getCachedProductDetails(item.product_id);
|
||||
} catch (error) {
|
||||
console.error(`Не удалось загрузить данные о продукте ${item.product_id}:`, error);
|
||||
}
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
// Если товар уже есть, увеличиваем количество и обновляем данные
|
||||
const variant = productData?.variants?.find((v: any) => v.id === item.variant_id);
|
||||
const variantName = variant?.size?.name || 'Вариант';
|
||||
// Если товар уже есть, обновляем его количество и данные через map
|
||||
const updatedItems = newCart.items.map((cartItem, index) => {
|
||||
if (index === existingItemIndex) {
|
||||
// Только обновляем количество и общую цену, остальные данные не трогаем
|
||||
const newQuantity = cartItem.quantity + item.quantity;
|
||||
|
||||
return {
|
||||
...cartItem, // Сохраняем старые ссылки на product_name, product_image и т.д.
|
||||
quantity: newQuantity,
|
||||
total_price: cartItem.price * newQuantity, // Пересчитываем total_price на основе старой цены
|
||||
};
|
||||
}
|
||||
return cartItem; // Возвращаем старый объект для неизмененных элементов
|
||||
});
|
||||
newCart.items = updatedItems; // Заменяем массив items на обновленный
|
||||
|
||||
newCart.items[existingItemIndex] = {
|
||||
...newCart.items[existingItemIndex],
|
||||
quantity: newCart.items[existingItemIndex].quantity + item.quantity,
|
||||
product_name: productData?.name || newCart.items[existingItemIndex].product_name,
|
||||
variant_name: variantName || newCart.items[existingItemIndex].variant_name,
|
||||
price: productData?.discount_price || productData?.price || newCart.items[existingItemIndex].price,
|
||||
product_image: productData?.primary_image ||
|
||||
(productData?.images && productData.images.length > 0
|
||||
? productData.images.find((img: any) => img.is_primary)?.image_url || productData.images[0].image_url
|
||||
: newCart.items[existingItemIndex].product_image)
|
||||
};
|
||||
|
||||
// Обновляем total_price на основе новой цены и количества
|
||||
newCart.items[existingItemIndex].total_price =
|
||||
newCart.items[existingItemIndex].price * newCart.items[existingItemIndex].quantity;
|
||||
} else {
|
||||
// Если товара нет, добавляем новый с правильными данными
|
||||
const tempId = Date.now();
|
||||
const variant = productData?.variants?.find((v: any) => v.id === item.variant_id);
|
||||
const tempId = Date.now(); // Используем временный ID для нового элемента
|
||||
const variant = productData?.variants?.find(v => v.id === item.variant_id);
|
||||
const variantName = variant?.size?.name || 'Вариант';
|
||||
|
||||
newCart.items.push({
|
||||
@ -220,25 +269,19 @@ class CartStore {
|
||||
slug: productData?.slug || '',
|
||||
product_image: productData?.primary_image || (productData?.images && productData.images.length > 0
|
||||
? productData.images.find((img: any) => img.is_primary)?.image_url || productData.images[0].image_url
|
||||
: null)
|
||||
: undefined)
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем корзину
|
||||
this.updateCart(newCart);
|
||||
// Обновляем внутреннее состояние корзины
|
||||
this.cart = newCart;
|
||||
this.recalculateCart();
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
|
||||
// Если пользователь авторизован, синхронизируем с сервером
|
||||
// Если пользователь авторизован, отправляем запрос на синхронизацию с сервером
|
||||
if (apiStatus.isAuthenticated) {
|
||||
try {
|
||||
await cartService.addToCart(item);
|
||||
// Обновляем данные из сервера
|
||||
const serverCart = await cartService.getCart();
|
||||
this.updateCart(convertServerCartToLocalCart(serverCart));
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении товара в корзину на сервере:', error);
|
||||
// Не возвращаем ошибку, т.к. в localStorage товар уже добавлен
|
||||
}
|
||||
this.syncWithServer();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -260,26 +303,30 @@ class CartStore {
|
||||
return this.removeFromCart(itemId);
|
||||
}
|
||||
|
||||
// Создаем копию корзины и обновляем количество
|
||||
const newCart = { ...this.cart, items: [...this.cart.items] };
|
||||
newCart.items[itemIndex] = {
|
||||
...newCart.items[itemIndex],
|
||||
quantity,
|
||||
total_price: newCart.items[itemIndex].price * quantity
|
||||
};
|
||||
// Создаем новый массив items, сохраняя ссылки на неизмененные элементы
|
||||
const newItems = this.cart.items.map(item => {
|
||||
if (item.id === itemId) {
|
||||
// Возвращаем новый объект только для измененного элемента
|
||||
return {
|
||||
...item,
|
||||
quantity,
|
||||
total_price: item.price * quantity
|
||||
};
|
||||
}
|
||||
// Возвращаем старый объект для неизмененных элементов
|
||||
return item;
|
||||
});
|
||||
|
||||
// Обновляем корзину
|
||||
this.updateCart(newCart);
|
||||
// Обновляем внутреннее состояние корзины с новым массивом items
|
||||
this.cart = { ...this.cart, items: newItems };
|
||||
// Пересчитываем сумму и количество
|
||||
this.recalculateCart();
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
|
||||
// Если пользователь авторизован, синхронизируем с сервером
|
||||
if (apiStatus.isAuthenticated) {
|
||||
try {
|
||||
await cartService.updateCartItem(itemId, { quantity });
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при обновлении товара ${itemId} в корзине на сервере:`, error);
|
||||
// Не возвращаем ошибку, т.к. в localStorage товар уже обновлен
|
||||
}
|
||||
this.syncWithServer();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -302,18 +349,15 @@ class CartStore {
|
||||
items: this.cart.items.filter(i => i.id !== itemId)
|
||||
};
|
||||
|
||||
// Обновляем корзину
|
||||
this.updateCart(newCart);
|
||||
// Обновляем внутреннее состояние корзины
|
||||
this.cart = newCart;
|
||||
this.recalculateCart();
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
|
||||
// Если пользователь авторизован, синхронизируем с сервером
|
||||
if (apiStatus.isAuthenticated) {
|
||||
try {
|
||||
await cartService.removeFromCart(itemId);
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при удалении товара ${itemId} из корзины на сервере:`, error);
|
||||
// Не возвращаем ошибку, т.к. из localStorage товар уже удален
|
||||
}
|
||||
this.syncWithServer();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -327,16 +371,15 @@ class CartStore {
|
||||
async clearCart(): Promise<boolean> {
|
||||
try {
|
||||
// Создаем пустую корзину
|
||||
this.updateCart(createEmptyCart());
|
||||
this.cart = createEmptyCart();
|
||||
// Пересчет не нужен, т.к. итоги уже 0
|
||||
// this.recalculateCart();
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
|
||||
// Если пользователь авторизован, очищаем корзину на сервере
|
||||
if (apiStatus.isAuthenticated) {
|
||||
try {
|
||||
await cartService.clearCart();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при очистке корзины на сервере:', error);
|
||||
// Не возвращаем ошибку, т.к. в localStorage корзина уже очищена
|
||||
}
|
||||
this.syncWithServer();
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -346,19 +389,18 @@ class CartStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Получение количества товаров в корзине
|
||||
// Получение количества товаров в корзине (чистый расчет)
|
||||
getItemsCount(): number {
|
||||
// Гарантируем возврат правильного количества, пересчитывая его
|
||||
const count = this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
// Убедимся, что значение в корзине соответствует рассчитанному
|
||||
if (this.cart.total_items !== count) {
|
||||
console.warn(`Несоответствие в количестве товаров: хранилище=${this.cart.total_items}, рассчитанное=${count}`);
|
||||
this.cart.total_items = count;
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
return count;
|
||||
// Просто вычисляем сумму quantity по всем элементам
|
||||
return this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
// Очистка кэша продуктов (для тестирования)
|
||||
clearProductCache(): void {
|
||||
Object.keys(productCache).forEach(key => {
|
||||
delete productCache[parseInt(key)];
|
||||
});
|
||||
console.log('Кэш продуктов очищен');
|
||||
}
|
||||
}
|
||||
|
||||
@ -366,4 +408,4 @@ class CartStore {
|
||||
const cartStore = new CartStore();
|
||||
|
||||
// Экспортируем созданный экземпляр
|
||||
export default cartStore;
|
||||
export default cartStore;
|
||||
|
||||
@ -5,18 +5,22 @@ import { Product, ProductVariant } from './catalog';
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
product_id: number;
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
product?: Product;
|
||||
variant?: ProductVariant;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_price: number;
|
||||
product_image?: string | null;
|
||||
slug: string;
|
||||
variant_name: string;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
items: CartItem[];
|
||||
total_items: number;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
@ -43,7 +47,7 @@ interface ApiResponse<T> {
|
||||
const createEmptyCart = (): Cart => {
|
||||
return {
|
||||
items: [],
|
||||
total_items: 0,
|
||||
items_count: 0,
|
||||
total_amount: 0
|
||||
};
|
||||
};
|
||||
|
||||
@ -140,25 +140,26 @@ export interface Collection {
|
||||
export interface Size {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
category_id?: number;
|
||||
is_active: boolean;
|
||||
code: string; // Изменено с value на code для соответствия бэкенду
|
||||
value?: string; // Оставляем для обратной совместимости
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface SizeCreate {
|
||||
name: string;
|
||||
value: string;
|
||||
category_id?: number;
|
||||
code: string; // Изменено с value на code для соответствия бэкенду
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface SizeUpdate {
|
||||
id: number;
|
||||
name?: string;
|
||||
value?: string;
|
||||
category_id?: number;
|
||||
code?: string; // Изменено с value на code для соответствия бэкенду
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
@ -235,7 +236,7 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
|
||||
// Если это уже полный URL (http, https, data URI, blob)
|
||||
if (imageUrl.startsWith('http') || imageUrl.startsWith('data:') || imageUrl.startsWith('blob:')) {
|
||||
if (apiStatus.debugMode) {
|
||||
@ -243,7 +244,7 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
}
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
|
||||
// Если это старый путь /uploads (на всякий случай, если где-то остались)
|
||||
if (imageUrl.startsWith('/uploads/')) {
|
||||
// В идеале, этот блок нужно будет удалить после миграции всех данных
|
||||
@ -253,7 +254,7 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
}
|
||||
return oldUrl;
|
||||
}
|
||||
|
||||
|
||||
// Если есть базовый URL MinIO и imageUrl не пустой - формируем полный URL
|
||||
if (minioBaseUrl && typeof imageUrl === 'string' && imageUrl.trim() !== '') {
|
||||
// Убираем возможный слэш в конце базового URL и в начале ключа
|
||||
@ -265,7 +266,7 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
}
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
|
||||
// Если базовый URL MinIO не задан или ключ пустой, возвращаем плейсхолдер
|
||||
if (apiStatus.debugMode) {
|
||||
// console.warn(`Не удалось сформировать URL MinIO (базовый URL: ${minioBaseUrl}, ключ: ${imageUrl}). Возвращаю плейсхолдер.`);
|
||||
@ -281,7 +282,7 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
export function processProductImages(product: ProductDetails): ProductDetails {
|
||||
// Создаем копию продукта для обработки
|
||||
const processedProduct = { ...product };
|
||||
|
||||
|
||||
// Обрабатываем основное изображение
|
||||
if (processedProduct.primary_image) {
|
||||
processedProduct.primary_image = normalizeProductImage(processedProduct.primary_image);
|
||||
@ -292,7 +293,7 @@ export function processProductImages(product: ProductDetails): ProductDetails {
|
||||
primaryImage ? primaryImage.image_url : processedProduct.images[0].image_url
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Обрабатываем все изображения продукта
|
||||
if (processedProduct.images && Array.isArray(processedProduct.images)) {
|
||||
processedProduct.images = processedProduct.images.map(image => ({
|
||||
@ -300,7 +301,7 @@ export function processProductImages(product: ProductDetails): ProductDetails {
|
||||
image_url: normalizeProductImage(image.image_url)
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
return processedProduct;
|
||||
}
|
||||
|
||||
@ -319,59 +320,72 @@ const catalogService = {
|
||||
max_price?: number;
|
||||
is_active?: boolean;
|
||||
include_variants?: boolean;
|
||||
size_ids?: number[]; // Добавляем параметр size_ids
|
||||
}): Promise<ProductsResponse> => {
|
||||
try {
|
||||
const response = await api.get<ProductDetails[] | ProductsResponse>('/catalog/products', { params });
|
||||
|
||||
// Создаем копию параметров для модификации
|
||||
const requestParams = { ...params };
|
||||
|
||||
// Преобразуем массив size_ids в строку для URL, если он есть
|
||||
if (requestParams.size_ids && requestParams.size_ids.length > 0) {
|
||||
// @ts-ignore - Преобразуем number[] в string для параметра запроса
|
||||
requestParams.size_ids = requestParams.size_ids.join(',');
|
||||
} else {
|
||||
// Удаляем параметр, если он пустой, чтобы не отправлять его
|
||||
delete requestParams.size_ids;
|
||||
}
|
||||
|
||||
const response = await api.get<ProductDetails[] | ProductsResponse>('/catalog/products', requestParams); // Используем requestParams
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении товаров:', response);
|
||||
}
|
||||
|
||||
|
||||
// Обработка случая, когда API возвращает массив товаров вместо объекта ProductsResponse
|
||||
if (Array.isArray(response)) {
|
||||
// Нормализация изображений для каждого товара
|
||||
const processedProducts = response.map(product => {
|
||||
// Базовая обработка товара
|
||||
let processed = { ...product };
|
||||
|
||||
|
||||
// Нормализация изображений
|
||||
if (processed.images && Array.isArray(processed.images)) {
|
||||
processed = processProductImages(processed);
|
||||
} else if (processed.primary_image) {
|
||||
processed.primary_image = normalizeProductImage(processed.primary_image);
|
||||
}
|
||||
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
|
||||
// Возвращаем в формате ProductsResponse
|
||||
return {
|
||||
products: processedProducts,
|
||||
total: processedProducts.length
|
||||
return {
|
||||
products: processedProducts,
|
||||
total: processedProducts.length
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Если пришел правильный формат ProductsResponse
|
||||
if (response && typeof response === 'object' && 'products' in response && Array.isArray(response.products)) {
|
||||
const result = { ...response } as ProductsResponse;
|
||||
|
||||
|
||||
// Обрабатываем продукты в ответе
|
||||
result.products = result.products.map(product => {
|
||||
// Если это расширенный товар с изображениями
|
||||
if ('images' in product && Array.isArray(product.images)) {
|
||||
return processProductImages(product as ProductDetails) as Product;
|
||||
}
|
||||
|
||||
|
||||
// Если это обычный товар с основным изображением
|
||||
return {
|
||||
...product,
|
||||
primary_image: product.primary_image ? normalizeProductImage(product.primary_image) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// Возвращаем пустой результат, если формат ответа неизвестен
|
||||
console.error('Неизвестный формат ответа API:', response);
|
||||
return { products: [], total: 0 };
|
||||
@ -387,23 +401,23 @@ const catalogService = {
|
||||
getProductById: async (productId: number): Promise<ProductDetails | null> => {
|
||||
try {
|
||||
const response = await api.get<{success: boolean, product: ProductDetails} | ProductDetails>(`/catalog/products/${productId}`);
|
||||
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении товара по ID:', response);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем успешность ответа и наличие продукта
|
||||
if (response && typeof response === 'object') {
|
||||
// Если ответ имеет структуру { success, product }
|
||||
if ('success' in response && 'product' in response && response.success) {
|
||||
return processProductImages(response.product as ProductDetails);
|
||||
}
|
||||
}
|
||||
// Если ответ сразу содержит продукт (старый формат)
|
||||
if ('id' in response && 'name' in response && !('success' in response)) {
|
||||
return processProductImages(response as unknown as ProductDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.error('Неожиданный формат ответа API в getProductById:', response);
|
||||
return null;
|
||||
} catch (error) {
|
||||
@ -418,23 +432,23 @@ const catalogService = {
|
||||
getProductBySlug: async (slug: string): Promise<ProductDetails | null> => {
|
||||
try {
|
||||
const response = await api.get<{success: boolean, product: ProductDetails} | ProductDetails>(`/catalog/products/slug/${slug}`);
|
||||
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении товара по slug:', response);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем успешность ответа и наличие продукта
|
||||
if (response && typeof response === 'object') {
|
||||
// Если ответ имеет структуру { success, product }
|
||||
if ('success' in response && 'product' in response && response.success) {
|
||||
return processProductImages(response.product as ProductDetails);
|
||||
}
|
||||
}
|
||||
// Если ответ сразу содержит продукт (старый формат)
|
||||
if ('id' in response && 'name' in response && !('success' in response)) {
|
||||
return processProductImages(response as unknown as ProductDetails);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.error('Неожиданный формат ответа API в getProductBySlug:', response);
|
||||
return null;
|
||||
} catch (error) {
|
||||
@ -529,13 +543,13 @@ const catalogService = {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('is_primary', isPrimary ? 'true' : 'false');
|
||||
|
||||
|
||||
const response = await api.post<ProductImage>(`/catalog/products/${productId}/images`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return response as ProductImage;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при загрузке изображения для продукта ${productId}:`, error);
|
||||
@ -577,8 +591,24 @@ const catalogService = {
|
||||
limit?: number;
|
||||
}): Promise<Size[]> => {
|
||||
try {
|
||||
const response = await api.get<Size[]>('/catalog/sizes', { params });
|
||||
return response as Size[];
|
||||
const response = await api.get<{success: boolean, sizes: Size[], total: number}>('/catalog/sizes', { params });
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении размеров:', response);
|
||||
}
|
||||
|
||||
// Проверяем, что ответ содержит поле sizes
|
||||
if (response && typeof response === 'object' && 'sizes' in response && Array.isArray(response.sizes)) {
|
||||
return response.sizes;
|
||||
}
|
||||
|
||||
// Если ответ уже является массивом
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API для размеров:', response);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка размеров:', error);
|
||||
return [];
|
||||
@ -642,14 +672,30 @@ const catalogService = {
|
||||
*/
|
||||
getCategoriesTree: async (): Promise<Category[]> => {
|
||||
try {
|
||||
const response = await api.get<Category[]>('/catalog/categories');
|
||||
return response as Category[];
|
||||
const response = await api.get<{success: boolean, categories: Category[], total: number}>('/catalog/categories');
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении категорий:', response);
|
||||
}
|
||||
|
||||
// Проверяем, что ответ содержит поле categories
|
||||
if (response && typeof response === 'object' && 'categories' in response && Array.isArray(response.categories)) {
|
||||
return response.categories;
|
||||
}
|
||||
|
||||
// Если ответ уже является массивом
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API для категорий:', response);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении дерева категорий:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Получить список коллекций
|
||||
*/
|
||||
@ -658,8 +704,36 @@ const catalogService = {
|
||||
limit?: number;
|
||||
}): Promise<CollectionsResponse> => {
|
||||
try {
|
||||
const response = await api.get<CollectionsResponse>('/catalog/collections', { params });
|
||||
return response;
|
||||
const response = await api.get<{success: boolean, collections: Collection[], total: number}>('/catalog/collections', { params });
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API при получении коллекций:', response);
|
||||
}
|
||||
|
||||
// Проверяем, что ответ содержит поле collections
|
||||
if (response && typeof response === 'object' && 'collections' in response && Array.isArray(response.collections)) {
|
||||
return {
|
||||
collections: response.collections,
|
||||
total: response.total || response.collections.length
|
||||
};
|
||||
}
|
||||
|
||||
// Если ответ уже является объектом CollectionsResponse
|
||||
if (response && typeof response === 'object' && 'collections' in response) {
|
||||
return response as CollectionsResponse;
|
||||
}
|
||||
|
||||
// Если ответ является массивом
|
||||
if (Array.isArray(response)) {
|
||||
const collections = response as Collection[];
|
||||
return {
|
||||
collections,
|
||||
total: collections.length
|
||||
};
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API для коллекций:', response);
|
||||
return { collections: [], total: 0 };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка коллекций:', error);
|
||||
return { collections: [], total: 0 };
|
||||
@ -732,30 +806,30 @@ const catalogService = {
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Создание товара с вариантами и изображениями:', data);
|
||||
}
|
||||
|
||||
|
||||
const response = await api.post<{success: boolean; product?: ProductDetails; error?: string}>('/catalog/products/complete', data);
|
||||
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Результат создания товара:', response);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем успешность и структуру ответа
|
||||
if (!response || response.success === false) {
|
||||
throw new Error(response?.error || 'Ошибка при создании товара');
|
||||
}
|
||||
|
||||
|
||||
// Если успешно, но нет продукта в ответе
|
||||
if (!response.product) {
|
||||
throw new Error('Сервер не вернул данные о созданном товаре');
|
||||
}
|
||||
|
||||
|
||||
return processProductImages(response.product);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании товара:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Обновить товар с вариантами и изображениями в одном запросе
|
||||
* @param productId ID товара
|
||||
@ -767,23 +841,23 @@ const catalogService = {
|
||||
if (apiStatus.debugMode) {
|
||||
console.log(`Обновление товара #${productId} с вариантами и изображениями:`, data);
|
||||
}
|
||||
|
||||
|
||||
const response = await api.put<{success: boolean; product?: ProductDetails; error?: string}>(`/catalog/products/${productId}/complete`, data);
|
||||
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Результат обновления товара:', response);
|
||||
}
|
||||
|
||||
|
||||
// Проверяем успешность и структуру ответа
|
||||
if (!response || response.success === false) {
|
||||
throw new Error(response?.error || 'Ошибка при обновлении товара');
|
||||
}
|
||||
|
||||
|
||||
// Если успешно, но нет продукта в ответе
|
||||
if (!response.product) {
|
||||
throw new Error('Сервер не вернул данные об обновленном товаре');
|
||||
}
|
||||
|
||||
|
||||
return processProductImages(response.product);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении товара:', error);
|
||||
@ -805,7 +879,7 @@ export const categoryService = {
|
||||
getCategories: async () => {
|
||||
return await catalogService.getCategoriesTree();
|
||||
},
|
||||
|
||||
|
||||
// Получить категорию по ID
|
||||
getCategoryById: async (categoryId: number) => {
|
||||
try {
|
||||
@ -816,7 +890,7 @@ export const categoryService = {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Создать категорию
|
||||
createCategory: async (data: CategoryCreate): Promise<Category | null> => {
|
||||
try {
|
||||
@ -827,7 +901,7 @@ export const categoryService = {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Обновить категорию
|
||||
updateCategory: async (categoryId: number, data: CategoryUpdate): Promise<Category | null> => {
|
||||
try {
|
||||
@ -838,7 +912,7 @@ export const categoryService = {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Удалить категорию
|
||||
deleteCategory: async (categoryId: number): Promise<boolean> => {
|
||||
try {
|
||||
@ -991,4 +1065,4 @@ export const collectionService = {
|
||||
}
|
||||
};
|
||||
|
||||
export default catalogService;
|
||||
export default catalogService;
|
||||
|
||||
@ -14,7 +14,15 @@ const nextConfig = {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
unoptimized: false,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '45.129.128.113',
|
||||
port: '9000',
|
||||
pathname: '/dressedforsuccess/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
|
||||
142
frontend/package-lock.json
generated
142
frontend/package-lock.json
generated
@ -67,7 +67,8 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.6",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
@ -82,7 +83,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@ -540,7 +540,6 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@ -558,7 +557,6 @@
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
@ -573,7 +571,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@ -583,7 +580,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@ -593,14 +589,12 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@ -745,7 +739,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@ -759,7 +752,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@ -769,7 +761,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@ -792,7 +783,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@ -2213,7 +2203,7 @@
|
||||
"version": "19.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@ -2223,7 +2213,7 @@
|
||||
"version": "19.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz",
|
||||
"integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@ -2233,7 +2223,6 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -2246,7 +2235,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -2259,14 +2247,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@ -2280,7 +2266,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@ -2353,14 +2338,12 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -2373,7 +2356,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@ -2383,7 +2365,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@ -2452,7 +2433,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -2482,7 +2462,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@ -2507,7 +2486,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@ -2577,7 +2555,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@ -2590,7 +2567,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
@ -2620,7 +2596,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -2639,7 +2614,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@ -2654,7 +2628,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@ -2835,14 +2808,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
@ -2873,7 +2844,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@ -2920,7 +2890,6 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
@ -2996,7 +2965,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@ -3013,7 +2981,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@ -3026,7 +2993,6 @@
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@ -3036,7 +3002,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@ -3069,7 +3034,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
@ -3141,7 +3105,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@ -3220,7 +3183,6 @@
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@ -3241,7 +3203,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@ -3340,7 +3301,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@ -3353,7 +3313,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@ -3369,7 +3328,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -3379,7 +3337,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -3389,7 +3346,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@ -3402,7 +3358,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@ -3412,14 +3367,12 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@ -3435,7 +3388,6 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@ -3460,7 +3412,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@ -3473,7 +3424,6 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
@ -3498,7 +3448,6 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
@ -3523,7 +3472,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@ -3533,7 +3481,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@ -3568,7 +3515,6 @@
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@ -3584,7 +3530,6 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@ -3609,7 +3554,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@ -3769,7 +3713,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -3803,7 +3746,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -3858,14 +3800,12 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -3875,14 +3815,12 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@ -3905,7 +3843,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@ -3918,7 +3855,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -3928,7 +3864,6 @@
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
|
||||
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@ -3938,7 +3873,6 @@
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
|
||||
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@ -3967,7 +3901,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@ -3985,7 +3918,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
|
||||
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
@ -4005,7 +3937,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
|
||||
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@ -4041,7 +3972,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@ -4067,7 +3997,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@ -4138,7 +4067,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -4371,7 +4299,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@ -4381,7 +4308,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@ -4432,7 +4358,6 @@
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.0",
|
||||
@ -4453,7 +4378,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@ -4464,7 +4388,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -4550,7 +4473,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@ -4563,7 +4485,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -4573,7 +4494,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@ -4623,7 +4543,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@ -4642,7 +4561,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@ -4657,7 +4575,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -4667,14 +4584,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -4687,7 +4602,6 @@
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@ -4704,7 +4618,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -4717,7 +4630,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -4750,7 +4662,6 @@
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@ -4773,7 +4684,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -4796,7 +4706,6 @@
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@ -4843,7 +4752,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@ -4853,7 +4761,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@ -4872,7 +4779,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@ -4885,7 +4791,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
@ -5001,7 +4906,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
@ -5052,7 +4956,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@ -5068,7 +4971,6 @@
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@ -5087,7 +4989,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@ -5105,7 +5006,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -5115,7 +5015,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@ -5131,14 +5030,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@ -5153,7 +5050,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -5172,7 +5068,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
|
||||
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
@ -5189,6 +5084,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
|
||||
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,7 +68,8 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.6",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
|
||||
BIN
frontend/public/.DS_Store
vendored
BIN
frontend/public/.DS_Store
vendored
Binary file not shown.
BIN
frontend/public/images/.DS_Store
vendored
BIN
frontend/public/images/.DS_Store
vendored
Binary file not shown.
BIN
frontend/public/images/hero/image-3.jpeg
Normal file
BIN
frontend/public/images/hero/image-3.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
1
frontend/public/images/home/air-dress.jpeg
Normal file
1
frontend/public/images/home/air-dress.jpeg
Normal file
@ -0,0 +1 @@
|
||||
placeholder
|
||||
1
frontend/public/images/home/ibiza-shorts.jpeg
Normal file
1
frontend/public/images/home/ibiza-shorts.jpeg
Normal file
@ -0,0 +1 @@
|
||||
placeholder
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user