Добавлены настройки для Meilisearch в конфигурацию приложения, включая переменные окружения и новый сервис. Обновлены файлы docker-compose для добавления Redis и Meilisearch. Исправлены зависимости в requirements.txt. Обновлены компоненты фронтенда для работы с новыми API ответами и улучшения пользовательского интерфейса. Удалены устаревшие файлы и исправлены ошибки в обработке изображений.

This commit is contained in:
ilya_zahvatkin 2025-04-14 17:42:14 +07:00
parent 6aef5fb7ce
commit 0a56297ad7
103 changed files with 7712 additions and 4633 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

13
backend/.env Normal file
View 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

View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -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()

View File

@ -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"}

View File

@ -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)}"
)
)

View File

@ -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()

View File

@ -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

View File

@ -0,0 +1 @@

View 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()

View File

@ -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

View File

@ -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)
}
}

View 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
View 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()

View 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()

View File

@ -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

View 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()

View 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()

View 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()

View 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()

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@ -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

View File

@ -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

Binary file not shown.

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}
}

View File

@ -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))}

View File

@ -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>
)
}
}

View 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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View 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>
)
}

View File

@ -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

Binary file not shown.

View File

@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
export function LazyBgLoader() {
useEffect(() => {
import("./lazy-background");
}, []);
return null;
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -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>
)
}
}

View 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 {};

Binary file not shown.

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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">
&copy; {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>
)
}
}

View File

@ -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>
)
}
}

View File

@ -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>
)
}
}

View 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>
)
}

View 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>
)
}

View File

@ -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}

View 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
}

View 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;

View 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;

View 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
};
}

View File

@ -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
};
}
}

View 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,
};
}

View 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;
}

View File

@ -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
View 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
};

View 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
};

View File

@ -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;

View File

@ -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;

View File

@ -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
};
};

View File

@ -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;

View File

@ -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,

View File

@ -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
}
}
}
}
}

View File

@ -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",

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -0,0 +1 @@
placeholder

View File

@ -0,0 +1 @@
placeholder

Some files were not shown because too many files have changed in this diff Show More