заказы!!
This commit is contained in:
parent
0a56297ad7
commit
9974d41bd8
@ -3,6 +3,8 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
use context7 always for docs frameworks
|
||||
|
||||
Ты — мой ИИ-ассистент для разработки веб-приложения. Ты эксперт в стеке: Python, FastAPI, SQLAlchemy 2.0, PostgreSQL (бэкенд) и Next.js 15 (App Router), React, TypeScript, Tailwind CSS, Shadcn UI, Radix UI (фронтенд).
|
||||
|
||||
Твоя главная цель: Помогать мне в улучшении существующего кода и написании нового, строго следуя приведенным ниже гайдлайнам, ориентируясь на существующие паттерны в проекте, и используя предоставленный контекст.
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
@ -1,5 +1,5 @@
|
||||
# Настройки базы данных
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/shop_db
|
||||
DATABASE_URL=postgresql://gen_user:F%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
|
||||
|
||||
# Настройки безопасности
|
||||
SECRET_KEY=supersecretkey
|
||||
|
||||
@ -10,5 +10,5 @@ SECRET_KEY=supersecretkey
|
||||
FRONTEND_URL=http://frontend:3000
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL=http://meilisearch:7700
|
||||
MEILISEARCH_URL=http://localhost:7700
|
||||
MEILISEARCH_KEY=dNmMmxymWpqTFyWSSFyg3xaGlEY6Pn7Ld-dyrnKRMzM
|
||||
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@ -0,0 +1,10 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Для разработки код монтируется через volumes, а в продакшн-билде можно добавить COPY . /app
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # hot-reload
|
||||
@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db
|
||||
sqlalchemy.url = postgresql://gen_user:F%%2BgEEiP3h7yB6d@93.183.81.86:5432/shop_db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
42
backend/alembic/versions/7192b0707277_add_new_order.py
Normal file
42
backend/alembic/versions/7192b0707277_add_new_order.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""add_new_order
|
||||
|
||||
Revision ID: 7192b0707277
|
||||
Revises: 9773b0186faa
|
||||
Create Date: 2025-04-27 01:56:31.170476
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7192b0707277'
|
||||
down_revision: Union[str, None] = '9773b0186faa'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('orders', sa.Column('user_info_json', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('delivery_method', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('city', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('delivery_address', sa.String(), nullable=True))
|
||||
op.add_column('orders', sa.Column('cdek_info', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('courier_info', sa.JSON(), nullable=True))
|
||||
op.add_column('orders', sa.Column('items_json', sa.JSON(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('orders', 'items_json')
|
||||
op.drop_column('orders', 'courier_info')
|
||||
op.drop_column('orders', 'cdek_info')
|
||||
op.drop_column('orders', 'delivery_address')
|
||||
op.drop_column('orders', 'city')
|
||||
op.drop_column('orders', 'delivery_method')
|
||||
op.drop_column('orders', 'user_info_json')
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
30
backend/alembic/versions/f89a59b0e814_add_new_order_.py
Normal file
30
backend/alembic/versions/f89a59b0e814_add_new_order_.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""add_new_order_
|
||||
|
||||
Revision ID: f89a59b0e814
|
||||
Revises: 7192b0707277
|
||||
Create Date: 2025-04-27 02:49:23.818786
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f89a59b0e814'
|
||||
down_revision: Union[str, None] = '7192b0707277'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('orders', sa.Column('payment_method', sa.Enum('CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'CASH_ON_DELIVERY', 'SBP', 'CARD', name='paymentmethod'), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('orders', 'payment_method')
|
||||
# ### end Alembic commands ###
|
||||
BIN
backend/app/.DS_Store
vendored
BIN
backend/app/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
@ -100,7 +100,7 @@ class Settings(BaseSettings):
|
||||
EMAIL_FROM: str = MAIL_FROM
|
||||
|
||||
# Настройки Meilisearch
|
||||
MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://localhost:7700")
|
||||
MEILISEARCH_URL: str = os.getenv("MEILISEARCH_URL", "http://0.0.0.0:7700")
|
||||
MEILISEARCH_KEY: str = os.getenv("MEILISEARCH_KEY", "masterKey")
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, Enum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
@ -20,6 +20,8 @@ class PaymentMethod(str, enum.Enum):
|
||||
PAYPAL = "paypal"
|
||||
BANK_TRANSFER = "bank_transfer"
|
||||
CASH_ON_DELIVERY = "cash_on_delivery"
|
||||
SBP = "sbp"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
class CartItem(Base):
|
||||
@ -44,9 +46,28 @@ class Order(Base):
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
|
||||
total_amount = Column(Float, nullable=False)
|
||||
|
||||
# JSON-поле с информацией о пользователе (для резервного копирования)
|
||||
user_info_json = Column(JSON, nullable=True)
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method = Column(String, nullable=True) # cdek, courier
|
||||
city = Column(String, nullable=True)
|
||||
delivery_address = Column(String, nullable=True) # Форматированный адрес
|
||||
cdek_info = Column(JSON, nullable=True) # Информация о доставке CDEK
|
||||
courier_info = Column(JSON, nullable=True) # Информация о курьерской доставке
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
shipping_address_id = Column(Integer, ForeignKey("user_addresses.id"), nullable=True)
|
||||
|
||||
# Информация об оплате
|
||||
payment_method = Column(Enum(PaymentMethod), nullable=True)
|
||||
payment_details = Column(Text, nullable=True)
|
||||
|
||||
# JSON-поле со списком заказанных товаров (для резервного копирования)
|
||||
items_json = Column(JSON, nullable=True)
|
||||
|
||||
# Дополнительная информация
|
||||
tracking_number = Column(String, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@ -70,4 +91,4 @@ class OrderItem(Base):
|
||||
|
||||
# Отношения
|
||||
order = relationship("Order", back_populates="items")
|
||||
variant = relationship("ProductVariant")
|
||||
variant = relationship("ProductVariant")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -511,15 +511,35 @@ def delete_product(db: Session, product_id: int) -> bool:
|
||||
detail="Продукт не найден"
|
||||
)
|
||||
|
||||
# Получаем ID всех вариантов продукта
|
||||
variant_ids = [variant.id for variant in db_product.variants]
|
||||
|
||||
# Проверяем, используются ли варианты в корзинах
|
||||
from app.models.order_models import CartItem
|
||||
cart_items_count = db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).count()
|
||||
if cart_items_count > 0:
|
||||
# Удаляем все элементы корзины, связанные с вариантами продукта
|
||||
db.query(CartItem).filter(CartItem.variant_id.in_(variant_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Проверяем, используются ли варианты в заказах
|
||||
from app.models.order_models import OrderItem
|
||||
order_items_count = db.query(OrderItem).filter(OrderItem.variant_id.in_(variant_ids)).count()
|
||||
if order_items_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно удалить продукт, так как его варианты используются в {order_items_count} заказах"
|
||||
)
|
||||
|
||||
try:
|
||||
# Удаляем продукт (варианты и изображения удалятся автоматически благодаря cascade)
|
||||
db.delete(db_product)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при удалении продукта"
|
||||
detail=f"Ошибка при удалении продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ def generate_slug(title: str) -> str:
|
||||
slug = re.sub(r'-+', '-', slug)
|
||||
# Удаляем дефисы в начале и конце
|
||||
slug = slug.strip('-')
|
||||
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
@ -35,16 +35,16 @@ def get_page_by_slug(db: Session, slug: str) -> Optional[Page]:
|
||||
|
||||
|
||||
def get_pages(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
published_only: bool = True
|
||||
) -> List[Page]:
|
||||
query = db.query(Page)
|
||||
|
||||
|
||||
if published_only:
|
||||
query = query.filter(Page.is_published == True)
|
||||
|
||||
|
||||
return query.order_by(Page.title).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@ -52,14 +52,14 @@ def create_page(db: Session, page: PageCreate) -> Page:
|
||||
# Если slug не предоставлен, генерируем его из заголовка
|
||||
if not page.slug:
|
||||
page.slug = generate_slug(page.title)
|
||||
|
||||
|
||||
# Проверяем, что страница с таким slug не существует
|
||||
if get_page_by_slug(db, page.slug):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новую страницу
|
||||
db_page = Page(
|
||||
title=page.title,
|
||||
@ -69,7 +69,7 @@ def create_page(db: Session, page: PageCreate) -> Page:
|
||||
meta_description=page.meta_description,
|
||||
is_published=page.is_published
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_page)
|
||||
db.commit()
|
||||
@ -90,10 +90,10 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Страница не найдена"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = page.dict(exclude_unset=True)
|
||||
|
||||
update_data = page.model_dump(exclude_unset=True)
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if "slug" in update_data and update_data["slug"] != db_page.slug:
|
||||
if get_page_by_slug(db, update_data["slug"]):
|
||||
@ -101,7 +101,7 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Если заголовок изменяется и slug не предоставлен, генерируем новый slug
|
||||
if "title" in update_data and "slug" not in update_data:
|
||||
update_data["slug"] = generate_slug(update_data["title"])
|
||||
@ -111,11 +111,11 @@ def update_page(db: Session, page_id: int, page: PageUpdate) -> Page:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Страница с таким slug уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_page, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_page)
|
||||
@ -135,7 +135,7 @@ def delete_page(db: Session, page_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Страница не найдена"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_page)
|
||||
db.commit()
|
||||
@ -152,7 +152,7 @@ def delete_page(db: Session, page_id: int) -> bool:
|
||||
def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
# Создаем новую запись аналитики
|
||||
db_log = AnalyticsLog(
|
||||
user_id=log.user_id,
|
||||
user_id=log.user_id, # Может быть None для неавторизованных пользователей
|
||||
event_type=log.event_type,
|
||||
page_url=log.page_url,
|
||||
product_id=log.product_id,
|
||||
@ -162,7 +162,7 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
referrer=log.referrer,
|
||||
additional_data=log.additional_data
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_log)
|
||||
db.commit()
|
||||
@ -177,9 +177,9 @@ def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog:
|
||||
|
||||
|
||||
def get_analytics_logs(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
event_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
product_id: Optional[int] = None,
|
||||
@ -188,31 +188,31 @@ def get_analytics_logs(
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[AnalyticsLog]:
|
||||
query = db.query(AnalyticsLog)
|
||||
|
||||
|
||||
# Применяем фильтры
|
||||
if event_type:
|
||||
query = query.filter(AnalyticsLog.event_type == event_type)
|
||||
|
||||
|
||||
if user_id:
|
||||
query = query.filter(AnalyticsLog.user_id == user_id)
|
||||
|
||||
|
||||
if product_id:
|
||||
query = query.filter(AnalyticsLog.product_id == product_id)
|
||||
|
||||
|
||||
if category_id:
|
||||
query = query.filter(AnalyticsLog.category_id == category_id)
|
||||
|
||||
|
||||
if start_date:
|
||||
query = query.filter(AnalyticsLog.created_at >= start_date)
|
||||
|
||||
|
||||
if end_date:
|
||||
query = query.filter(AnalyticsLog.created_at <= end_date)
|
||||
|
||||
|
||||
return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def get_analytics_report(
|
||||
db: Session,
|
||||
db: Session,
|
||||
period: str = "day",
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
@ -229,44 +229,44 @@ def get_analytics_report(
|
||||
start_date = datetime.utcnow() - timedelta(days=365)
|
||||
else:
|
||||
start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней
|
||||
|
||||
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
|
||||
# Получаем все события за указанный период
|
||||
logs = db.query(AnalyticsLog).filter(
|
||||
AnalyticsLog.created_at >= start_date,
|
||||
AnalyticsLog.created_at <= end_date
|
||||
).all()
|
||||
|
||||
|
||||
# Подсчитываем статистику
|
||||
total_visits = len(logs)
|
||||
unique_visitors = len(set([log.ip_address for log in logs if log.ip_address]))
|
||||
|
||||
|
||||
# Подсчитываем просмотры страниц
|
||||
page_views = {}
|
||||
for log in logs:
|
||||
if log.event_type == "page_view" and log.page_url:
|
||||
page_views[log.page_url] = page_views.get(log.page_url, 0) + 1
|
||||
|
||||
|
||||
# Подсчитываем просмотры продуктов
|
||||
product_views = {}
|
||||
for log in logs:
|
||||
if log.event_type == "product_view" and log.product_id:
|
||||
product_id = str(log.product_id)
|
||||
product_views[product_id] = product_views.get(product_id, 0) + 1
|
||||
|
||||
|
||||
# Подсчитываем добавления в корзину
|
||||
cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart")
|
||||
|
||||
|
||||
# Подсчитываем заказы и выручку
|
||||
orders_count = sum(1 for log in logs if log.event_type == "order_created")
|
||||
|
||||
|
||||
# Для расчета выручки и среднего чека нам нужны данные о заказах
|
||||
# В данном примере мы просто используем заглушки
|
||||
revenue = 0
|
||||
average_order_value = 0
|
||||
|
||||
|
||||
# Формируем отчет
|
||||
report = {
|
||||
"period": period,
|
||||
@ -281,5 +281,5 @@ def get_analytics_report(
|
||||
"revenue": revenue,
|
||||
"average_order_value": average_order_value
|
||||
}
|
||||
|
||||
return report
|
||||
|
||||
return report
|
||||
@ -1,13 +1,15 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod
|
||||
from app.models.catalog_models import Product, ProductImage, ProductVariant, Size
|
||||
from app.models.user_models import User, UserAddress
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, OrderCreateNew
|
||||
from app.repositories import user_repo
|
||||
|
||||
|
||||
# Функции для работы с корзиной
|
||||
@ -34,7 +36,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Вариант продукта не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, что продукт активен
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product or not product.is_active:
|
||||
@ -42,7 +44,7 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Продукт не активен или не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем, есть ли уже такой товар в корзине
|
||||
existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id)
|
||||
if existing_item:
|
||||
@ -58,14 +60,14 @@ def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> Ca
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении элемента корзины"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новый элемент корзины
|
||||
db_cart_item = CartItem(
|
||||
user_id=user_id,
|
||||
variant_id=cart_item.variant_id,
|
||||
quantity=cart_item.quantity
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_cart_item)
|
||||
db.commit()
|
||||
@ -84,20 +86,20 @@ def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate,
|
||||
CartItem.id == cart_item_id,
|
||||
CartItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_cart_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Элемент корзины не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = cart_item.dict(exclude_unset=True)
|
||||
|
||||
update_data = cart_item.model_dump(exclude_unset=True)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_cart_item, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_cart_item)
|
||||
@ -115,13 +117,13 @@ def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool:
|
||||
CartItem.id == cart_item_id,
|
||||
CartItem.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_cart_item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Элемент корзины не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_cart_item)
|
||||
db.commit()
|
||||
@ -152,38 +154,211 @@ def get_order(db: Session, order_id: int) -> Optional[Order]:
|
||||
return db.query(Order).filter(Order.id == order_id).first()
|
||||
|
||||
|
||||
def create_order_new(db: Session, order: OrderCreateNew, user_id: Optional[int] = None) -> Order:
|
||||
"""
|
||||
Создает новый заказ на основе новой структуры данных.
|
||||
Если пользователь не авторизован (user_id=None), создает нового пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
order: Данные для создания заказа
|
||||
user_id: ID пользователя (None, если пользователь не авторизован)
|
||||
|
||||
Returns:
|
||||
Созданный заказ
|
||||
|
||||
Raises:
|
||||
HTTPException: Если произошла ошибка при создании заказа
|
||||
"""
|
||||
# Подготовим информацию о пользователе для сохранения в JSON
|
||||
user_info_dict = {
|
||||
"first_name": order.user_info.first_name,
|
||||
"last_name": order.user_info.last_name,
|
||||
"email": order.user_info.email,
|
||||
"phone": order.user_info.phone
|
||||
}
|
||||
|
||||
# Если пользователь не авторизован, создаем нового пользователя или находим существующего
|
||||
user_created_message = None
|
||||
if user_id is None:
|
||||
try:
|
||||
user, is_new, message = user_repo.find_or_create_user_from_order(db, user_info_dict)
|
||||
user_id = user.id
|
||||
user_created_message = message
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании пользователя: {str(e)}"
|
||||
)
|
||||
|
||||
# Подготовим информацию о товарах для сохранения в JSON
|
||||
items_json = []
|
||||
for item in order.items:
|
||||
# Получаем информацию о варианте и продукте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант товара с ID {item.variant_id} не найден"
|
||||
)
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {item.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Получаем размер для варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first()
|
||||
size_name = size.name if size else "Unknown"
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
if not image:
|
||||
# Если нет основного изображения, берем первое доступное
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
image_url = image.image_url if image else None
|
||||
|
||||
# Добавляем информацию о товаре в JSON
|
||||
items_json.append({
|
||||
"product_id": product.id,
|
||||
"variant_id": variant.id,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price,
|
||||
"product_name": product.name,
|
||||
"variant_name": size_name,
|
||||
"product_image": image_url,
|
||||
"slug": product.slug
|
||||
})
|
||||
|
||||
# Преобразуем строковое значение payment_method в значение перечисления PaymentMethod
|
||||
payment_method_str = order.payment_method
|
||||
|
||||
# Прямое сопоставление с известными значениями
|
||||
if payment_method_str == "sbp":
|
||||
payment_method = PaymentMethod.SBP
|
||||
elif payment_method_str == "card":
|
||||
payment_method = PaymentMethod.CARD
|
||||
else:
|
||||
# Если не нашли соответствия, используем значение по умолчанию
|
||||
payment_method = PaymentMethod.CARD
|
||||
# Логируем ошибку
|
||||
print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: {payment_method}")
|
||||
|
||||
print(f"Метод оплаты: {payment_method} {type(payment_method)}")
|
||||
print(f"Тип payment_method_str: {type(payment_method_str)}")
|
||||
print(f"payment_method_str: {payment_method.value}")
|
||||
payment_method_str = payment_method.value
|
||||
print(f"payment_method_str: {payment_method_str} {type(payment_method_str)}")
|
||||
payment_method = payment_method_str
|
||||
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
user_id=user_id,
|
||||
status=OrderStatus.PENDING,
|
||||
|
||||
# JSON с информацией о пользователе
|
||||
user_info_json=user_info_dict,
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method=order.delivery.method,
|
||||
city=order.delivery.address.city,
|
||||
delivery_address=order.delivery.address.formatted_address or f"{order.delivery.address.city}, {order.delivery.address.street} {order.delivery.address.house}",
|
||||
|
||||
# Информация о CDEK
|
||||
cdek_info=order.delivery.cdek_info.model_dump() if order.delivery.cdek_info else None,
|
||||
|
||||
# JSON с информацией о товарах
|
||||
items_json=items_json,
|
||||
|
||||
# Информация об оплате
|
||||
payment_method=payment_method,
|
||||
notes=order.comment,
|
||||
|
||||
# Общая сумма заказа (будет обновлена после добавления товаров)
|
||||
total_amount=0
|
||||
)
|
||||
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
# Добавляем товары в заказ через разводную таблицу
|
||||
for item in order.items:
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
variant_id=item.variant_id,
|
||||
quantity=item.quantity,
|
||||
price=item.price
|
||||
)
|
||||
db.add(order_item)
|
||||
new_order.total_amount += item.price * item.quantity
|
||||
|
||||
# Проверяем, были ли добавлены элементы заказа
|
||||
if new_order.total_amount == 0:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
|
||||
# Добавляем информацию о создании пользователя в заказ
|
||||
if user_created_message:
|
||||
new_order.user_created_message = user_created_message
|
||||
|
||||
return new_order
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
|
||||
return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def get_all_orders(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[OrderStatus] = None
|
||||
) -> List[Order]:
|
||||
query = db.query(Order)
|
||||
|
||||
|
||||
if status:
|
||||
query = query.filter(Order.status == status)
|
||||
|
||||
|
||||
return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
def create_order(db: Session, order: OrderCreate, user_id: Optional[int] = None) -> Order:
|
||||
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(
|
||||
UserAddress.id == order.shipping_address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
|
||||
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
user_id=user_id,
|
||||
@ -193,13 +368,13 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
notes=order.notes,
|
||||
total_amount=0 # Будет обновлено после добавления товаров
|
||||
)
|
||||
|
||||
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = []
|
||||
|
||||
|
||||
# Если указаны конкретные элементы корзины
|
||||
if order.cart_items:
|
||||
cart_items = db.query(CartItem).filter(
|
||||
@ -218,7 +393,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Получаем продукт для варианта
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
@ -227,10 +402,10 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
@ -243,7 +418,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
# Иначе используем все элементы корзины пользователя
|
||||
else:
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
|
||||
|
||||
# Если используем элементы корзины
|
||||
if cart_items:
|
||||
# Создаем элементы заказа из элементов корзины
|
||||
@ -252,15 +427,15 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
|
||||
# Определяем цену для товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
order_id=new_order.id,
|
||||
@ -270,7 +445,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
)
|
||||
db.add(order_item)
|
||||
new_order.total_amount += price * cart_item.quantity
|
||||
|
||||
|
||||
# Очищаем корзину пользователя после создания заказа
|
||||
if cart_items and not order.cart_items:
|
||||
# Если используем все элементы корзины, очищаем всю корзину
|
||||
@ -281,7 +456,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
CartItem.id.in_([item.id for item in cart_items]),
|
||||
CartItem.user_id == user_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
# Проверяем, были ли добавлены элементы заказа
|
||||
if new_order.total_amount == 0:
|
||||
db.rollback()
|
||||
@ -289,7 +464,7 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Не удалось создать заказ: корзина пуста или товары недоступны"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(new_order)
|
||||
@ -305,16 +480,16 @@ def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin: bool = False) -> Order:
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
order_id: ID заказа
|
||||
order_update: Данные для обновления
|
||||
is_admin: Флаг, указывающий, является ли пользователь администратором
|
||||
|
||||
|
||||
Returns:
|
||||
Обновленный заказ
|
||||
|
||||
|
||||
Raises:
|
||||
HTTPException: Если заказ не найден или нет прав на обновление
|
||||
"""
|
||||
@ -325,7 +500,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем возможность обновления статуса
|
||||
if order_update.status:
|
||||
# Если заказ уже отменен или доставлен, нельзя менять его статус
|
||||
@ -334,14 +509,14 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно изменить статус заказа из {order.status}"
|
||||
)
|
||||
|
||||
|
||||
# Если пользователь не админ, он может только отменить заказ
|
||||
if not is_admin and order_update.status != OrderStatus.CANCELLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
|
||||
# Если пользователь хочет отменить заказ, проверяем возможность отмены
|
||||
if order_update.status == OrderStatus.CANCELLED:
|
||||
if order.status not in [OrderStatus.PENDING, OrderStatus.PROCESSING]:
|
||||
@ -349,7 +524,7 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя отменить заказ, который уже отправлен или доставлен"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем другие поля для обновления
|
||||
if not is_admin:
|
||||
# Обычные пользователи могут обновлять только статус (отмена)
|
||||
@ -359,10 +534,10 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Пользователи не могут изменять поле {field}"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем поля заказа
|
||||
update_data = order_update.dict(exclude_unset=True)
|
||||
|
||||
update_data = order_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Если указан адрес доставки, проверяем его существование
|
||||
if "shipping_address_id" in update_data:
|
||||
address = db.query(UserAddress).filter(UserAddress.id == update_data["shipping_address_id"]).first()
|
||||
@ -371,11 +546,11 @@ def update_order(db: Session, order_id: int, order_update: OrderUpdate, is_admin
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(order, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
@ -395,14 +570,14 @@ def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Только администраторы могут удалять заказы
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для удаления заказа"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_order)
|
||||
db.commit()
|
||||
@ -423,29 +598,29 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
result = []
|
||||
|
||||
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем продукт
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
size_name = size.name if size else ''
|
||||
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
product_image = None
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
@ -453,10 +628,10 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
any_image = db.query(ProductImage).filter(ProductImage.product_id == product.id).first()
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
|
||||
# Определяем цену товара (используем discount_price если есть, иначе price)
|
||||
price = product.discount_price if product.discount_price and product.discount_price > 0 else product.price
|
||||
|
||||
|
||||
# Формируем элемент корзины с деталями
|
||||
cart_item_details = {
|
||||
"id": cart_item.id,
|
||||
@ -473,9 +648,9 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
"variant_name": size_name,
|
||||
"total_price": price * cart_item.quantity
|
||||
}
|
||||
|
||||
|
||||
result.append(cart_item_details)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -487,12 +662,12 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
return None
|
||||
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
user_email = user.email if user else None
|
||||
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name and user.last_name else None
|
||||
|
||||
|
||||
# Получаем адрес доставки
|
||||
shipping_address = None
|
||||
if order.shipping_address_id:
|
||||
@ -508,41 +683,41 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"country": address.country,
|
||||
"is_default": address.is_default
|
||||
}
|
||||
|
||||
|
||||
# Получаем элементы заказа с информацией о продуктах
|
||||
items = []
|
||||
order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all()
|
||||
|
||||
|
||||
for item in order_items:
|
||||
# Получаем вариант продукта
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
variant_name = None
|
||||
size_name = None
|
||||
|
||||
|
||||
if variant:
|
||||
# Получаем размер варианта
|
||||
size = db.query(Size).filter(Size.id == variant.size_id).first() if variant.size_id else None
|
||||
if size:
|
||||
size_name = size.name
|
||||
variant_name = f"{size.name}"
|
||||
|
||||
|
||||
# Получаем информацию о продукте
|
||||
product = None
|
||||
product_name = "Удаленный продукт"
|
||||
product_image = None
|
||||
|
||||
|
||||
if variant:
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
|
||||
|
||||
if product:
|
||||
product_name = product.name
|
||||
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
primary_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
|
||||
if primary_image:
|
||||
product_image = primary_image.image_url
|
||||
else:
|
||||
@ -550,10 +725,10 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
any_image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
|
||||
if any_image:
|
||||
product_image = any_image.image_url
|
||||
|
||||
|
||||
# Добавляем информацию об элементе заказа
|
||||
items.append({
|
||||
"id": item.id,
|
||||
@ -568,17 +743,43 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"size": size_name,
|
||||
"total_price": item.price * item.quantity
|
||||
})
|
||||
|
||||
|
||||
# Получаем информацию о пользователе из JSON
|
||||
user_info = order.user_info_json or {}
|
||||
|
||||
# Формируем результат
|
||||
return {
|
||||
result = {
|
||||
"id": order.id,
|
||||
"user_id": order.user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"status": order.status,
|
||||
"total_amount": order.total_amount,
|
||||
|
||||
# Информация о пользователе из JSON
|
||||
"user_info_json": order.user_info_json,
|
||||
|
||||
# Извлекаем информацию о пользователе для отображения
|
||||
"first_name": user_info.get("first_name", ""),
|
||||
"last_name": user_info.get("last_name", ""),
|
||||
"email": user_info.get("email", user_email),
|
||||
"phone": user_info.get("phone", ""),
|
||||
"user_email": user_email, # Для обратной совместимости
|
||||
"user_name": user_name, # Для обратной совместимости
|
||||
|
||||
# Информация о доставке
|
||||
"delivery_method": order.delivery_method,
|
||||
"city": order.city,
|
||||
"delivery_address": order.delivery_address,
|
||||
"cdek_info": order.cdek_info,
|
||||
"courier_info": order.courier_info,
|
||||
|
||||
# Информация о товарах из JSON
|
||||
"items_json": order.items_json,
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
"shipping_address_id": order.shipping_address_id,
|
||||
"shipping_address": shipping_address,
|
||||
|
||||
# Дополнительная информация
|
||||
"payment_method": order.payment_method,
|
||||
"payment_details": order.payment_details,
|
||||
"tracking_number": order.tracking_number,
|
||||
@ -586,4 +787,6 @@ def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"items": items
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@ -1,7 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException, status
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.models.user_models import User, UserAddress
|
||||
from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate
|
||||
@ -33,7 +35,7 @@ def create_user(db: Session, user: UserCreate) -> User:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Создаем нового пользователя
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = User(
|
||||
@ -45,7 +47,7 @@ def create_user(db: Session, user: UserCreate) -> User:
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
@ -66,17 +68,17 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = user.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если предоставлен новый пароль, хешируем его
|
||||
if "password" in update_data and update_data["password"]:
|
||||
update_data["password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
|
||||
# Удаляем поле password_confirm, если оно есть
|
||||
update_data.pop("password_confirm", None)
|
||||
|
||||
|
||||
# Проверяем уникальность email, если он изменяется
|
||||
if "email" in update_data and update_data["email"] != db_user.email:
|
||||
if get_user_by_email(db, update_data["email"]):
|
||||
@ -84,11 +86,11 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_user, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
@ -108,7 +110,7 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
@ -130,6 +132,57 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
return user
|
||||
|
||||
|
||||
def find_or_create_user_from_order(db: Session, user_info: Dict[str, Any]) -> Tuple[User, bool, str]:
|
||||
"""
|
||||
Ищет пользователя по email или создает нового на основе данных заказа.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user_info: Информация о пользователе из заказа
|
||||
|
||||
Returns:
|
||||
Кортеж (пользователь, создан_новый, сообщение)
|
||||
"""
|
||||
email = user_info.get("email")
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email обязателен для создания пользователя"
|
||||
)
|
||||
|
||||
# Ищем пользователя по email
|
||||
existing_user = get_user_by_email(db, email)
|
||||
if existing_user:
|
||||
return existing_user, False, "Пользователь с таким email уже существует"
|
||||
|
||||
# Генерируем случайный пароль
|
||||
password = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Создаем нового пользователя
|
||||
new_user = User(
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
first_name=user_info.get("first_name", ""),
|
||||
last_name=user_info.get("last_name", ""),
|
||||
phone=user_info.get("phone", ""),
|
||||
is_active=True,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user, True, f"Создан новый пользователь с email {email}"
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при создании пользователя"
|
||||
)
|
||||
|
||||
|
||||
# Функции для работы с адресами пользователей
|
||||
def get_address(db: Session, address_id: int) -> Optional[UserAddress]:
|
||||
return db.query(UserAddress).filter(UserAddress.id == address_id).first()
|
||||
@ -146,7 +199,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
|
||||
UserAddress.user_id == user_id,
|
||||
UserAddress.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
|
||||
db_address = UserAddress(
|
||||
user_id=user_id,
|
||||
address_line1=address.address_line1,
|
||||
@ -157,7 +210,7 @@ def create_address(db: Session, address: AddressCreate, user_id: int):
|
||||
country=address.country,
|
||||
is_default=address.is_default
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.add(db_address)
|
||||
db.commit()
|
||||
@ -178,16 +231,16 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
|
||||
UserAddress.id == address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = address.dict(exclude_unset=True)
|
||||
|
||||
|
||||
# Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя
|
||||
if "is_default" in update_data and update_data["is_default"]:
|
||||
db.query(UserAddress).filter(
|
||||
@ -195,11 +248,11 @@ def update_address(db: Session, address_id: int, address: AddressUpdate, user_id
|
||||
UserAddress.id != address_id,
|
||||
UserAddress.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_address, key, value)
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_address)
|
||||
@ -217,13 +270,13 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
|
||||
UserAddress.id == address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
|
||||
if not db_address:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес не найден или не принадлежит пользователю"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
db.delete(db_address)
|
||||
db.commit()
|
||||
@ -245,10 +298,10 @@ def update_password(db: Session, user_id: int, new_password: str) -> bool:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
|
||||
hashed_password = get_password_hash(new_password)
|
||||
db_user.password = hashed_password
|
||||
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
return True
|
||||
@ -264,14 +317,14 @@ def create_password_reset_token(db: Session, user_id: int) -> str:
|
||||
"""Создает токен для сброса пароля"""
|
||||
import secrets
|
||||
import datetime
|
||||
|
||||
|
||||
# В реальном приложении здесь должна быть модель для токенов сброса пароля
|
||||
# Для примера просто генерируем случайный токен
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
|
||||
# и временем истечения срока действия
|
||||
|
||||
|
||||
return token
|
||||
|
||||
|
||||
@ -279,7 +332,7 @@ def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
|
||||
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
|
||||
# В реальном приложении проверяем токен в базе данных
|
||||
# и его срок действия
|
||||
|
||||
|
||||
# Для примера просто возвращаем фиктивный ID пользователя
|
||||
# В реальном приложении это должна быть проверка в базе данных
|
||||
return 1 # Фиктивный ID пользователя
|
||||
return 1 # Фиктивный ID пользователя
|
||||
Binary file not shown.
Binary file not shown.
@ -209,14 +209,59 @@ async def update_product_endpoint(product_id: int, product: ProductUpdate, curre
|
||||
@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)
|
||||
logging.warning(f"Удаление продукта с ID {product_id} в маршруте")
|
||||
result = services.delete_product(db, product_id)
|
||||
|
||||
# Если удаление не удалось и есть сообщение об ошибке, возвращаем соответствующий статус ошибки
|
||||
if not result.get("success") and "error" in result:
|
||||
if "заказах" in result.get("error", ""):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["error"])
|
||||
elif "не найден" in result.get("error", ""):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=result["error"])
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@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)):
|
||||
# Используем синхронную версию для получения деталей продукта
|
||||
product = services.get_product_details(db, product_id)
|
||||
return {"success": True, "product": product}
|
||||
# Сначала пробуем найти продукт в Meilisearch
|
||||
from app.services import meilisearch_service
|
||||
|
||||
# Используем поиск по ID
|
||||
result = meilisearch_service.get_product(product_id)
|
||||
|
||||
# Если продукт найден в 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.")
|
||||
|
||||
# Проверяем существование продукта
|
||||
from app.models.catalog_models import Product
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {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.get("/products/slug/{slug}", response_model=Dict[str, Any])
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
from app.core import get_db, get_current_active_user
|
||||
from app import services
|
||||
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order
|
||||
from app.schemas.order_schemas import OrderCreate, OrderUpdate, Order, OrderCreateNew
|
||||
from app.models.user_models import User as UserModel
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
@ -13,31 +13,61 @@ order_router = APIRouter(prefix="/orders", tags=["Заказы"])
|
||||
|
||||
@order_router.post("/", response_model=Dict[str, Any])
|
||||
async def create_order_endpoint(
|
||||
order: OrderCreate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order: OrderCreate,
|
||||
# current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Создает новый заказ.
|
||||
|
||||
Создает новый заказ (старый формат).
|
||||
|
||||
- **shipping_address_id**: ID адреса доставки
|
||||
- **payment_method**: Способ оплаты
|
||||
- **notes**: Примечания к заказу (опционально)
|
||||
- **cart_items**: Список ID элементов корзины (опционально)
|
||||
- **items**: Прямые элементы заказа (опционально)
|
||||
"""
|
||||
return services.create_order(db, current_user.id, order)
|
||||
return services.create_order(db, None, order)
|
||||
|
||||
|
||||
@order_router.post("/new", response_model=Dict[str, Any])
|
||||
async def create_order_new_endpoint(
|
||||
order: OrderCreateNew,
|
||||
db: Session = Depends(get_db),
|
||||
# current_user: Optional[UserModel] = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Создает новый заказ с новой структурой данных.
|
||||
Если пользователь не авторизован, создает нового пользователя на основе данных заказа.
|
||||
|
||||
- **user_info**: Информация о пользователе (имя, фамилия, email, телефон)
|
||||
- **delivery**: Информация о доставке (метод, адрес, информация о CDEK)
|
||||
- **items**: Товары в заказе
|
||||
- **payment_method**: Способ оплаты
|
||||
- **comment**: Комментарий к заказу (опционально)
|
||||
"""
|
||||
# Если пользователь авторизован, используем его ID, иначе передаем None
|
||||
user_id = None # current_user.id if current_user else None
|
||||
|
||||
# Проверяем наличие email в данных заказа
|
||||
if not order.user_info.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email обязателен для оформления заказа"
|
||||
)
|
||||
|
||||
# Создаем заказ (если пользователь не авторизован, будет создан новый пользователь)
|
||||
return services.create_order_new(db, user_id, order)
|
||||
|
||||
|
||||
@order_router.get("/{order_id}", response_model=Dict[str, Any])
|
||||
async def get_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.get_order(db, current_user.id, order_id, current_user.is_admin)
|
||||
@ -45,14 +75,14 @@ async def get_order_endpoint(
|
||||
|
||||
@order_router.put("/{order_id}", response_model=Dict[str, Any])
|
||||
async def update_order_endpoint(
|
||||
order_id: int,
|
||||
order: OrderUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
order: OrderUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Обновляет информацию о заказе.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
- **status**: Новый статус заказа (опционально, только для админов)
|
||||
- **shipping_address_id**: ID нового адреса доставки (опционально, только для админов)
|
||||
@ -66,13 +96,13 @@ async def update_order_endpoint(
|
||||
|
||||
@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any])
|
||||
async def cancel_order_endpoint(
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
order_id: int,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Отменяет заказ.
|
||||
|
||||
|
||||
- **order_id**: ID заказа
|
||||
"""
|
||||
return services.cancel_order(db, current_user.id, order_id)
|
||||
@ -80,15 +110,15 @@ async def cancel_order_endpoint(
|
||||
|
||||
@order_router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def get_orders_endpoint(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[OrderStatus] = None,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Получает список заказов пользователя.
|
||||
|
||||
|
||||
- **skip**: Количество пропускаемых записей
|
||||
- **limit**: Максимальное количество записей
|
||||
- **status**: Фильтр по статусу заказа (опционально)
|
||||
@ -98,6 +128,6 @@ async def get_orders_endpoint(
|
||||
orders = services.order_repo.get_all_orders(db, skip, limit, status)
|
||||
else:
|
||||
orders = services.order_repo.get_user_orders(db, current_user.id, skip, limit)
|
||||
|
||||
|
||||
# Получаем полную информацию о каждом заказе
|
||||
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
|
||||
return [services.order_repo.get_order_with_details(db, order.id) for order in orders]
|
||||
Binary file not shown.
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
@ -62,7 +62,107 @@ class OrderItemWithProduct(OrderItem):
|
||||
variant_name: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для заказов
|
||||
# Схемы для информации о пользователе
|
||||
class UserInfoBase(BaseModel):
|
||||
first_name: str
|
||||
last_name: Optional[str] = ""
|
||||
email: str
|
||||
phone: str
|
||||
|
||||
|
||||
# Схемы для адреса доставки
|
||||
class AddressBase(BaseModel):
|
||||
city: str
|
||||
street: Optional[str] = ""
|
||||
house: Optional[str] = ""
|
||||
apartment: Optional[str] = ""
|
||||
postal_code: Optional[str] = ""
|
||||
formatted_address: Optional[str] = ""
|
||||
|
||||
|
||||
# Схемы для информации о доставке CDEK
|
||||
class CDEKPvzInfo(BaseModel):
|
||||
city_code: Optional[int] = None
|
||||
city: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
have_cashless: Optional[bool] = None
|
||||
have_cash: Optional[bool] = None
|
||||
allowed_cod: Optional[bool] = None
|
||||
is_dressing_room: Optional[bool] = None
|
||||
code: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
work_time: Optional[str] = None
|
||||
location: Optional[List[float]] = None
|
||||
weight_min: Optional[int] = None
|
||||
weight_max: Optional[int] = None
|
||||
dimensions: Optional[Any] = None
|
||||
|
||||
|
||||
class CDEKTariffInfo(BaseModel):
|
||||
tariff_code: Optional[int] = None
|
||||
tariff_name: Optional[str] = None
|
||||
tariff_description: Optional[str] = None
|
||||
delivery_mode: Optional[int] = None
|
||||
delivery_sum: Optional[int] = None
|
||||
period_min: Optional[int] = None
|
||||
period_max: Optional[int] = None
|
||||
calendar_min: Optional[int] = None
|
||||
calendar_max: Optional[int] = None
|
||||
delivery_date_range: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class CDEKInfo(BaseModel):
|
||||
pvz: Optional[CDEKPvzInfo] = None
|
||||
tariff: Optional[CDEKTariffInfo] = None
|
||||
delivery_type: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для информации о доставке курьером
|
||||
class CourierInfo(BaseModel):
|
||||
geo_lat: Optional[str] = None
|
||||
geo_lon: Optional[str] = None
|
||||
fias_id: Optional[str] = None
|
||||
kladr_id: Optional[str] = None
|
||||
|
||||
|
||||
# Схемы для информации о доставке
|
||||
class DeliveryInfo(BaseModel):
|
||||
method: str # cdek или courier
|
||||
address: AddressBase
|
||||
cdek_info: Optional[CDEKInfo] = None
|
||||
|
||||
|
||||
# Схемы для элементов заказа в новом формате
|
||||
class OrderItemNew(BaseModel):
|
||||
product_id: int
|
||||
variant_id: int
|
||||
quantity: int
|
||||
price: float
|
||||
|
||||
|
||||
# Схемы для заказов в новом формате
|
||||
class OrderCreateNew(BaseModel):
|
||||
user_info: UserInfoBase
|
||||
delivery: DeliveryInfo
|
||||
items: List[OrderItemNew]
|
||||
payment_method: str
|
||||
comment: Optional[str] = ""
|
||||
|
||||
@field_validator('payment_method')
|
||||
def validate_payment_method(cls, v):
|
||||
# Проверяем, что значение payment_method соответствует одному из допустимых значений
|
||||
valid_methods = ["sbp", "card"]
|
||||
if v.lower() not in valid_methods:
|
||||
# Если не соответствует, преобразуем к значению по умолчанию
|
||||
return "card"
|
||||
return v.lower() # Возвращаем значение в нижнем регистре
|
||||
|
||||
|
||||
# Старые схемы для заказов (для обратной совместимости)
|
||||
class OrderBase(BaseModel):
|
||||
shipping_address_id: Optional[int] = None
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
@ -82,12 +182,35 @@ class OrderUpdate(BaseModel):
|
||||
tracking_number: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
# Новые поля
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class Order(OrderBase):
|
||||
id: int
|
||||
user_id: int
|
||||
status: OrderStatus
|
||||
total_amount: float
|
||||
|
||||
# Информация о пользователе (из JSON)
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о товарах (из JSON)
|
||||
items_json: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Дополнительная информация
|
||||
payment_details: Optional[str] = None
|
||||
tracking_number: Optional[str] = None
|
||||
created_at: datetime
|
||||
@ -119,7 +242,25 @@ class OrderWithDetails(BaseModel):
|
||||
user_id: int
|
||||
status: OrderStatus
|
||||
total_amount: float
|
||||
|
||||
# Информация о пользователе (из JSON)
|
||||
user_info_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о доставке
|
||||
delivery_method: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
delivery_address: Optional[str] = None
|
||||
cdek_info: Optional[Dict[str, Any]] = None
|
||||
courier_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Информация о товарах (из JSON)
|
||||
items_json: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# Старые поля (для обратной совместимости)
|
||||
shipping_address_id: Optional[int] = None
|
||||
shipping_address: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Дополнительная информация
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
payment_details: Optional[str] = None
|
||||
tracking_number: Optional[str] = None
|
||||
@ -127,5 +268,4 @@ class OrderWithDetails(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
user_email: Optional[str] = None
|
||||
shipping_address: Optional[Dict[str, Any]] = None
|
||||
items: List[Dict[str, Any]] = []
|
||||
items: List[Dict[str, Any]] = []
|
||||
@ -16,7 +16,7 @@ from app.services.catalog_service import (
|
||||
|
||||
from app.services.order_service import (
|
||||
add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart,
|
||||
create_order, get_order, update_order, cancel_order,
|
||||
create_order, create_order_new, get_order, update_order, cancel_order,
|
||||
)
|
||||
|
||||
from app.services.review_service import (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -308,8 +308,10 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict
|
||||
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
"""Удалить продукт"""
|
||||
try:
|
||||
logging.warning(f"Удаление продукта с ID {product_id}")
|
||||
# Проверяем, что продукт существует
|
||||
db_product = catalog_repo.get_product(db, product_id)
|
||||
logging.warning(f"Продукт: {db_product}, {db_product.id if db_product else 'не найден'}")
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@ -318,7 +320,7 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
|
||||
# Удаляем продукт
|
||||
success = catalog_repo.delete_product(db, product_id)
|
||||
|
||||
logging.warning(f"Удаление продукта с ID {product_id} успешно: {success}")
|
||||
# Удаляем продукт из Meilisearch
|
||||
if success:
|
||||
meilisearch_service.delete_product(product_id)
|
||||
@ -327,11 +329,14 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
"success": success
|
||||
}
|
||||
except HTTPException as e:
|
||||
logging.error(f"HTTP ошибка при удалении продукта с ID {product_id}: {e.detail}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"Неожиданная ошибка при удалении продукта с ID {product_id}: {str(e)}")
|
||||
logging.error(traceback.format_exc())
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
@ -990,6 +995,10 @@ def create_product_complete(db: Session, product_data: ProductCreateComplete) ->
|
||||
"updated_at": image.updated_at
|
||||
})
|
||||
|
||||
# Индексируем продукт в Meilisearch
|
||||
product_data = format_product_for_meilisearch(db_product, variants, images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"id": db_product.id,
|
||||
@ -1150,20 +1159,27 @@ def update_product_complete(db: Session, product_id: int, product_data: ProductU
|
||||
# Коммитим транзакцию
|
||||
db.commit()
|
||||
|
||||
# Обновляем продукт в Meilisearch
|
||||
# Получаем все варианты и изображения продукта после обновления
|
||||
updated_variants = db_product.variants
|
||||
updated_images = db_product.images
|
||||
product_data = format_product_for_meilisearch(db_product, updated_variants, updated_images)
|
||||
meilisearch_service.index_product(product_data)
|
||||
|
||||
# Собираем полный ответ
|
||||
product_schema = Product.from_orm(db_product)
|
||||
product_schema = Product.model_validate(db_product)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_schema,
|
||||
"variants": {
|
||||
"updated": [ProductVariant.from_orm(variant) for variant in variants_updated],
|
||||
"created": [ProductVariant.from_orm(variant) for variant in variants_created],
|
||||
"updated": [ProductVariant.model_validate(variant) for variant in variants_updated],
|
||||
"created": [ProductVariant.model_validate(variant) for variant in variants_created],
|
||||
"removed": variants_removed
|
||||
},
|
||||
"images": {
|
||||
"updated": [ProductImage.from_orm(image) for image in images_updated],
|
||||
"created": [ProductImage.from_orm(image) for image in images_created],
|
||||
"updated": [ProductImage.model_validate(image) for image in images_updated],
|
||||
"created": [ProductImage.model_validate(image) for image in images_created],
|
||||
"removed": images_removed
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from app.repositories import order_repo, content_repo
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order
|
||||
from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate, Order, OrderCreateNew
|
||||
from app.schemas.content_schemas import AnalyticsLogCreate
|
||||
from app.models.order_models import OrderStatus
|
||||
|
||||
@ -14,7 +14,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
|
||||
Добавляет товар в корзину пользователя.
|
||||
"""
|
||||
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
|
||||
|
||||
|
||||
# Логируем событие добавления в корзину
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -25,7 +25,7 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар успешно добавлен в корзину",
|
||||
@ -43,7 +43,7 @@ def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: Ca
|
||||
Обновляет количество товара в корзине пользователя.
|
||||
"""
|
||||
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Товар в корзине успешно обновлен",
|
||||
@ -61,7 +61,7 @@ def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str,
|
||||
Удаляет товар из корзины пользователя.
|
||||
"""
|
||||
success = order_repo.delete_cart_item(db, cart_item_id, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Товар успешно удален из корзины" if success else "Не удалось удалить товар из корзины"
|
||||
@ -73,7 +73,7 @@ def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
Очищает корзину пользователя.
|
||||
"""
|
||||
success = order_repo.clear_cart(db, user_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Корзина успешно очищена" if success else "Не удалось очистить корзину"
|
||||
@ -86,10 +86,10 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
# Получаем элементы корзины с деталями продуктов
|
||||
cart_items = order_repo.get_cart_with_product_details(db, user_id)
|
||||
|
||||
|
||||
# Рассчитываем общую сумму корзины
|
||||
total_amount = sum(item["total_price"] for item in cart_items)
|
||||
|
||||
|
||||
return {
|
||||
"items": cart_items,
|
||||
"total_amount": total_amount,
|
||||
@ -98,13 +98,13 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
# Сервисы заказов
|
||||
def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
|
||||
def create_order(db: Session, user_id: Optional[int], order: OrderCreate) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает новый заказ на основе корзины или переданных элементов.
|
||||
"""
|
||||
try:
|
||||
new_order = order_repo.create_order(db, order, user_id)
|
||||
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -115,10 +115,10 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно создан",
|
||||
@ -131,26 +131,103 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
|
||||
)
|
||||
|
||||
|
||||
def create_order_new(db: Session, user_id: Optional[int], order: OrderCreateNew) -> Dict[str, Any]:
|
||||
"""
|
||||
Создает новый заказ на основе новой структуры данных.
|
||||
Если пользователь не авторизован (user_id=None), создает нового пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user_id: ID пользователя (None, если пользователь не авторизован)
|
||||
order: Данные для создания заказа
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о созданном заказе
|
||||
|
||||
Raises:
|
||||
HTTPException: Если произошла ошибка при создании заказа
|
||||
"""
|
||||
try:
|
||||
|
||||
print(f"Метод оплаты: {order.payment_method} {type(order.payment_method)} {order.payment_method.strip().lower()}")
|
||||
# Проверяем метод оплаты
|
||||
if order.payment_method.strip().lower() not in ["sbp", "card"]:
|
||||
# Если метод оплаты не поддерживается, используем значение по умолчанию
|
||||
order.payment_method = "card"
|
||||
print(f"Неизвестный метод оплаты: {order.payment_method}. Используем значение по умолчанию: card")
|
||||
|
||||
# Создаем заказ с новой структурой данных
|
||||
new_order = order_repo.create_order_new(db, order, user_id)
|
||||
|
||||
# Получаем сообщение о создании пользователя, если оно есть
|
||||
user_created_message = getattr(new_order, "user_created_message", None)
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=new_order.user_id, # Используем ID пользователя из заказа
|
||||
event_type="order_created",
|
||||
additional_data={
|
||||
"order_id": new_order.id,
|
||||
"total_amount": new_order.total_amount,
|
||||
"delivery_method": new_order.delivery_method
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
# Формируем ответ для фронтенда
|
||||
response = {
|
||||
"success": True,
|
||||
"message": "Заказ успешно создан",
|
||||
"order": order_details,
|
||||
"order_id": new_order.id # Добавляем ID заказа для удобства
|
||||
}
|
||||
|
||||
# Если был создан новый пользователь, добавляем сообщение об этом
|
||||
if user_created_message:
|
||||
response["user_message"] = user_created_message
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
# Логируем ошибку для отладки
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
|
||||
# Проверяем, содержит ли ошибка информацию о проблеме с методом оплаты
|
||||
error_message = str(e)
|
||||
if "invalid input value for enum paymentmethod" in error_message.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный метод оплаты. Допустимые значения: 'sbp', 'card'"
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о заказе по ID.
|
||||
"""
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
if not order_details:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and order_details["user_id"] != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для просмотра этого заказа"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"order": order_details
|
||||
@ -168,27 +245,27 @@ def update_order(db: Session, user_id: int, order_id: int, order_update: OrderUp
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if not is_admin and order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для обновления этого заказа"
|
||||
)
|
||||
|
||||
|
||||
# Обычные пользователи могут только отменить заказ
|
||||
if not is_admin and (order_update.status and order_update.status != OrderStatus.CANCELLED):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Пользователи могут только отменить заказ"
|
||||
)
|
||||
|
||||
|
||||
# Обновляем заказ
|
||||
updated_order = order_repo.update_order(db, order_id, order_update, is_admin)
|
||||
|
||||
order_repo.update_order(db, order_id, order_update, is_admin)
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно обновлен",
|
||||
@ -207,21 +284,21 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
|
||||
# Проверяем права доступа
|
||||
if order.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав для отмены этого заказа"
|
||||
)
|
||||
|
||||
|
||||
# Отменяем заказ
|
||||
order_update = OrderUpdate(status=OrderStatus.CANCELLED)
|
||||
order_repo.update_order(db, order_id, order_update, False)
|
||||
|
||||
|
||||
# Получаем обновленный заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, order_id)
|
||||
|
||||
|
||||
# Логируем событие отмены заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
@ -231,9 +308,9 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Заказ успешно отменен",
|
||||
"order": order_details
|
||||
}
|
||||
}
|
||||
@ -9,31 +9,32 @@
|
||||
6. [Отзывы](#отзывы)
|
||||
7. [Контент](#контент)
|
||||
8. [Аналитика](#аналитика)
|
||||
9. [Доставка](#доставка)
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Базовый URL: `/auth`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет |
|
||||
| POST | `/login` | Вход в систему | `username` (email), `password` | Нет |
|
||||
| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет |
|
||||
| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет |
|
||||
| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет | `{"success": true, "user": {...}}` |
|
||||
| POST | `/login` | Вход в систему | `username` (email), `password` | Нет | `{"access_token": "...", "token_type": "bearer"}` |
|
||||
| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет | `{"success": true, "message": "..."}` |
|
||||
| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет | `{"success": true, "message": "..."}` |
|
||||
| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Пользователи
|
||||
|
||||
Базовый URL: `/users`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/me` | Получение профиля текущего пользователя | - | Да |
|
||||
| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да |
|
||||
| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да |
|
||||
| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/me` | Получение профиля текущего пользователя | - | Да | `{"success": true, "user": {...}}` |
|
||||
| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да | `{"success": true, "user": {...}}` |
|
||||
| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
|
||||
| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да | `{"success": true, "address": {...}}` |
|
||||
| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да | `{"success": true, "message": "..."}` |
|
||||
| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) | `{"success": true, "user": {...}}` |
|
||||
|
||||
## Каталог
|
||||
|
||||
@ -41,117 +42,127 @@
|
||||
|
||||
### Коллекции
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/collections` | Получение списка коллекций | `skip`, `limit` | Нет |
|
||||
| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) |
|
||||
| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) |
|
||||
| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/collections` | Получение списка коллекций | `skip`, `limit`, `search`, `is_active` | Нет | `{"success": true, "collections": [...], "total": number}` |
|
||||
| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
|
||||
| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) | `{"success": true, "collection": {...}}` |
|
||||
| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Категории
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/categories` | Получение дерева категорий | - | Нет |
|
||||
| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/categories` | Получение списка категорий | `skip`, `limit`, `search`, `parent_id`, `is_active` | Нет | `{"success": true, "categories": [...], "total": number}` |
|
||||
| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
|
||||
| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) | `{"success": true, "category": {...}}` |
|
||||
| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Размеры
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/sizes` | Получение списка размеров | `skip`, `limit` | Нет |
|
||||
| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет |
|
||||
| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) |
|
||||
| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) |
|
||||
| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/sizes` | Получение списка размеров | `skip`, `limit`, `search` | Нет | `{"success": true, "sizes": [...], "total": number}` |
|
||||
| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет | `{"success": true, "size": {...}}` |
|
||||
| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
|
||||
| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) | `{"success": true, "size": {...}}` |
|
||||
| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) | `{"success": true}` |
|
||||
|
||||
### Продукты
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `include_variants` | Нет |
|
||||
| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет |
|
||||
| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет |
|
||||
| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `sort_by`, `sort_order` | Нет | `{"success": true, "products": [...], "total": number, "skip": number, "limit": number}` |
|
||||
| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет | `{"success": true, "product": {...}}` |
|
||||
| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет | `{"success": true, "product": {...}}` |
|
||||
| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| POST | `/products/complete` | Создание продукта с вариантами и изображениями | `ProductCreateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) | `{"success": true, "product": {...}}` |
|
||||
| PUT | `/products/{product_id}/complete` | Обновление продукта с вариантами и изображениями | `ProductUpdateComplete` (product + variants + images) | Да (админ) | `{"success": true, "product": {...}, "variants": {...}, "images": {...}}` |
|
||||
| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Варианты продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
|
||||
| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) | `{"success": true, "variant": {...}}` |
|
||||
| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
### Изображения продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file`, `is_primary` | Да (админ) |
|
||||
| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) |
|
||||
| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file` (multipart/form-data), `is_primary` (form field) | Да (админ) | `{"success": true, "image": {...}}` |
|
||||
| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) | `{"success": true, "image": {...}}` |
|
||||
| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Корзина
|
||||
|
||||
Базовый URL: `/cart`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение корзины пользователя | - | Да |
|
||||
| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (product_variant_id, quantity) | Да |
|
||||
| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да |
|
||||
| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да |
|
||||
| DELETE | `/clear` | Очистка корзины | - | Да |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/` | Получение корзины пользователя | - | Да | `{"success": true, "cart": {"items": [...], "items_count": number, "total_amount": number}}` |
|
||||
| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (variant_id, quantity) | Да | `{"success": true, "cart_item": {...}}` |
|
||||
| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да | `{"success": true, "cart_item": {...}}` |
|
||||
| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да | `{"success": true, "message": "..."}` |
|
||||
| DELETE | `/clear` | Очистка корзины | - | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Заказы
|
||||
|
||||
Базовый URL: `/orders`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да |
|
||||
| GET | `/{order_id}` | Получение информации о заказе | - | Да |
|
||||
| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method) | Да |
|
||||
| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, tracking_number) | Да (админ) |
|
||||
| POST | `/{order_id}/cancel` | Отмена заказа | - | Да |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да | `[{"id": number, "status": string, "total_amount": number, ...}]` |
|
||||
| GET | `/{order_id}` | Получение информации о заказе | - | Да | `{"success": true, "order": {...}}` |
|
||||
| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method, notes, cart_items, items) | Да | `{"success": true, "order": {...}}` |
|
||||
| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, shipping_address_id, payment_method, payment_details, tracking_number, notes) | Да (админ) | `{"success": true, "order": {...}}` |
|
||||
| POST | `/{order_id}/cancel` | Отмена заказа | - | Да | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Отзывы
|
||||
|
||||
Базовый URL: `/reviews`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет |
|
||||
| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да |
|
||||
| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да |
|
||||
| DELETE | `/{review_id}` | Удаление отзыва | - | Да |
|
||||
| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет | `{"success": true, "reviews": [...], "total": number}` |
|
||||
| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да | `{"success": true, "review": {...}}` |
|
||||
| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да | `{"success": true, "review": {...}}` |
|
||||
| DELETE | `/{review_id}` | Удаление отзыва | - | Да | `{"success": true, "message": "..."}` |
|
||||
| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) | `{"success": true, "review": {...}}` |
|
||||
|
||||
## Контент
|
||||
|
||||
Базовый URL: `/content`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет |
|
||||
| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет |
|
||||
| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет |
|
||||
| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) |
|
||||
| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) |
|
||||
| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет | `{"success": true, "pages": [...], "total": number}` |
|
||||
| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет | `{"success": true, "page": {...}}` |
|
||||
| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет | `{"success": true, "page": {...}}` |
|
||||
| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
|
||||
| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) | `{"success": true, "page": {...}}` |
|
||||
| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) | `{"success": true, "message": "..."}` |
|
||||
|
||||
## Аналитика
|
||||
|
||||
Базовый URL: `/analytics`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет |
|
||||
| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) |
|
||||
| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) |
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет | `{"success": true, "log": {...}}` |
|
||||
| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) | `{"success": true, "logs": [...], "total": number}` |
|
||||
| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) | `{"success": true, "report": {...}}` |
|
||||
|
||||
## Доставка
|
||||
|
||||
Базовый URL: `/delivery`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация | Формат ответа |
|
||||
|-------|----------|----------|-------------------|------------------------|------------------|
|
||||
| POST | `/cdek` | Обработка запросов виджета CDEK | `action` (offices, calculate) и другие параметры в зависимости от действия | Нет | Ответ от API CDEK |
|
||||
|
||||
## Модели данных
|
||||
|
||||
@ -174,12 +185,14 @@
|
||||
- `ProductVariantUpdate`: product_id, size_id, sku, stock, is_active
|
||||
- `ProductImageCreate`: product_id, image_url, alt_text, is_primary
|
||||
- `ProductImageUpdate`: alt_text, is_primary
|
||||
- `ProductCreateComplete`: включает данные продукта, список вариантов и изображений
|
||||
- `ProductUpdateComplete`: включает данные продукта, список вариантов и изображений для обновления
|
||||
|
||||
### Корзина и заказы
|
||||
- `CartItemCreate`: product_variant_id, quantity
|
||||
- `CartItemCreate`: variant_id, quantity
|
||||
- `CartItemUpdate`: quantity
|
||||
- `OrderCreate`: shipping_address_id, payment_method
|
||||
- `OrderUpdate`: status, tracking_number
|
||||
- `OrderCreate`: shipping_address_id, payment_method, notes, cart_items, items
|
||||
- `OrderUpdate`: status, shipping_address_id, payment_method, payment_details, tracking_number, notes
|
||||
|
||||
### Отзывы
|
||||
- `ReviewCreate`: product_id, rating, text
|
||||
@ -188,4 +201,4 @@
|
||||
### Контент
|
||||
- `PageCreate`: title, slug, content, is_published
|
||||
- `PageUpdate`: title, slug, content, is_published
|
||||
- `AnalyticsLogCreate`: event_type, event_data, user_id
|
||||
- `AnalyticsLogCreate`: event_type, event_data, user_id
|
||||
169
backend/requirements-new.txt
Normal file
169
backend/requirements-new.txt
Normal file
@ -0,0 +1,169 @@
|
||||
# Основные зависимости
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.34.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
pydantic_core==2.14.6
|
||||
starlette==0.35.1
|
||||
typing_extensions==4.12.2
|
||||
|
||||
# Валидация данных
|
||||
marshmallow==3.21.1
|
||||
cerberus==1.3.5
|
||||
jsonschema==4.21.1
|
||||
|
||||
# База данных
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.30.0
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.13.1
|
||||
greenlet==3.0.3 # Требуется для SQLAlchemy с asyncpg
|
||||
|
||||
# Аутентификация и безопасность
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
bcrypt<4.0.0
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
pycryptodome==3.21.0
|
||||
pycryptodomex==3.21.0
|
||||
PyJWT==2.8.0
|
||||
|
||||
# HTTP и файлы
|
||||
httpx==0.28.1
|
||||
aiofiles==24.1.0
|
||||
anyio==4.2.0 # Требуется для FastAPI и httpx
|
||||
|
||||
# CORS
|
||||
django-cors-headers==4.3.1 # Для Django проектов
|
||||
flask-cors==4.0.0 # Для Flask проектов
|
||||
|
||||
# WebSocket
|
||||
websocket-client==1.8.0
|
||||
trio==0.28.0
|
||||
trio-websocket==0.11.1
|
||||
wsproto==1.2.0
|
||||
python-socketio==5.11.1
|
||||
websockets==12.0
|
||||
|
||||
# Поиск и индексация
|
||||
meilisearch==0.28.0
|
||||
|
||||
# Облачное хранилище
|
||||
boto3==1.34.69
|
||||
|
||||
# Кэширование
|
||||
redis==5.0.3
|
||||
aioredis==2.0.1
|
||||
|
||||
# Очереди сообщений
|
||||
amqp==5.3.1
|
||||
kombu==5.4.2
|
||||
aiokafka==0.10.0
|
||||
|
||||
# Асинхронные задачи
|
||||
taskiq==0.11.10
|
||||
taskiq-dependencies==1.5.6
|
||||
|
||||
# Многопоточность и параллелизм
|
||||
futures==3.1.1
|
||||
concurrent-log-handler==0.9.24
|
||||
|
||||
# API документация
|
||||
openapi-spec-validator==0.7.1
|
||||
swagger-ui-bundle==0.0.9
|
||||
pydantic-openapi-schema==1.0.1
|
||||
|
||||
# GraphQL
|
||||
strawberry-graphql==0.219.2
|
||||
graphene==3.3
|
||||
graphene-sqlalchemy==2.3.0
|
||||
|
||||
# Электронная почта
|
||||
fastapi-mail==1.4.1
|
||||
|
||||
# Работа с датами и временем
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2025.1
|
||||
|
||||
# Интернационализация и локализация
|
||||
babel==2.14.0
|
||||
python-gettext==5.0
|
||||
polib==1.2.0
|
||||
|
||||
# Анализ данных
|
||||
pandas==2.2.3
|
||||
numpy==2.2.2
|
||||
|
||||
# Машинное обучение
|
||||
scikit-learn==1.4.1
|
||||
joblib==1.3.2
|
||||
|
||||
# Обработка естественного языка (NLP)
|
||||
nltk==3.8.1
|
||||
spacy==3.7.4
|
||||
|
||||
# Визуализация данных
|
||||
matplotlib==3.8.4
|
||||
seaborn==0.13.2
|
||||
plotly==5.22.0
|
||||
|
||||
# Работа с Excel и другими форматами файлов
|
||||
openpyxl==3.1.5
|
||||
xlrd==1.2.0
|
||||
XlsxWriter==3.2.2
|
||||
|
||||
# Работа с PDF и документами
|
||||
pdfminer.six==20191110
|
||||
python-pptx==0.6.23
|
||||
PyPDF2==3.0.1
|
||||
reportlab==4.1.0
|
||||
python-docx==1.1.0
|
||||
docx2txt==0.8
|
||||
|
||||
# Работа с архивами
|
||||
py7zr==0.22.0
|
||||
pyzstd==0.16.2
|
||||
rarfile==4.2
|
||||
|
||||
# Работа с XML и HTML
|
||||
lxml==5.3.0
|
||||
beautifulsoup4==4.12.3
|
||||
soupsieve==2.6
|
||||
|
||||
# Веб-скрапинг
|
||||
scrapy==2.11.1
|
||||
requests-html==0.10.0
|
||||
|
||||
# Работа с форматами данными
|
||||
pyyaml==6.0.1
|
||||
toml==0.10.2
|
||||
|
||||
# Работа с геоданными
|
||||
geopy==2.4.1
|
||||
|
||||
# Интеграция с мессенджерами
|
||||
aiogram==3.17.0
|
||||
aiohttp==3.11.11
|
||||
|
||||
# Планировщик задач
|
||||
pycron==3.1.2
|
||||
|
||||
# Мониторинг и логирование
|
||||
prometheus-client==0.20.0
|
||||
sentry-sdk==2.12.1
|
||||
loguru==0.7.2
|
||||
structlog==24.1.0
|
||||
python-json-logger==2.0.7
|
||||
|
||||
# Тестирование
|
||||
pytest==8.0.2
|
||||
pytest-asyncio==0.23.5
|
||||
pytest-cov==5.0.0
|
||||
httpx==0.28.1 # Для тестирования FastAPI
|
||||
|
||||
# Утилиты
|
||||
python-dotenv==1.0.0
|
||||
setuptools==75.8.0
|
||||
jinja2==3.1.3 # Для шаблонов (если используется)
|
||||
pillow==11.1.0 # Для обработки изображений
|
||||
@ -5,7 +5,6 @@ aiohttp==3.11.11
|
||||
aiosignal==1.3.2
|
||||
amqp==5.3.1
|
||||
annotated-types==0.6.0
|
||||
anthropic==0.45.2
|
||||
anyio==4.2.0
|
||||
argcomplete==1.10.3
|
||||
asyncpg==0.30.0
|
||||
@ -27,7 +26,6 @@ extract-msg==0.28.7
|
||||
fastapi==0.109.0
|
||||
frozenlist==1.5.0
|
||||
greenlet==3.0.3
|
||||
groq==0.16.0
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
@ -43,7 +41,6 @@ multidict==6.1.0
|
||||
multivolumefile==0.2.3
|
||||
numpy==2.2.2
|
||||
olefile==0.47
|
||||
openai==1.61.0
|
||||
openpyxl==3.1.5
|
||||
outcome==1.3.0.post0
|
||||
packaging==24.2
|
||||
|
||||
@ -1,101 +1,37 @@
|
||||
version: '3.8'
|
||||
version: '3.8' # Compose Specification v3.8
|
||||
|
||||
services:
|
||||
backend:
|
||||
fastapi:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: dressed-for-success-backend
|
||||
hostname: backend
|
||||
context: ./backend # директория с Dockerfile FastAPI
|
||||
volumes:
|
||||
- ./backend:/app # монтируем код для live-reload
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # hot-reload
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/shop_db
|
||||
- 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:
|
||||
app_network:
|
||||
aliases:
|
||||
- backend
|
||||
dns_search: .
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
- app-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: dressed-for-success-frontend
|
||||
hostname: frontend
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://0.0.0.0:8000/api
|
||||
- NEXT_PUBLIC_BASE_URL=http://0.0.0.0:8000
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
php:
|
||||
image: php:8.2-apache # официальный PHP Apache образ
|
||||
volumes:
|
||||
- ./php:/var/www/html
|
||||
ports:
|
||||
- "8081:80"
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- frontend
|
||||
dns_search: .
|
||||
restart: always
|
||||
- app-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: dressed-for-success-nginx
|
||||
image: nginx:stable
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # монтируем конфиг NGINX
|
||||
ports:
|
||||
- "80:80"
|
||||
# - "443:443" # Раскомментируйте для HTTPS
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./backend/uploads:/app/uploads:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
- fastapi
|
||||
- php
|
||||
networks:
|
||||
- app_network
|
||||
restart: always
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: dressed-for-success-db
|
||||
hostname: postgres
|
||||
environment:
|
||||
POSTGRES_DB: shop_db
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
app_network:
|
||||
aliases:
|
||||
- postgres
|
||||
dns_search: .
|
||||
restart: always
|
||||
- app-network
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
@ -115,22 +51,16 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
restart: always
|
||||
networks:
|
||||
app_network:
|
||||
app-network:
|
||||
aliases:
|
||||
- meilisearch
|
||||
dns_search: .
|
||||
restart: always
|
||||
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
meilisearch_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
app-network: # общая сеть для контейнеров
|
||||
driver: bridge
|
||||
|
||||
BIN
frontend/.DS_Store
vendored
BIN
frontend/.DS_Store
vendored
Binary file not shown.
BIN
frontend/app/(main)/.DS_Store
vendored
BIN
frontend/app/(main)/.DS_Store
vendored
Binary file not shown.
@ -354,7 +354,7 @@ export default function CartPage() {
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
<Link href="/checkout" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@ -432,7 +432,7 @@ export default function CartPage() {
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
>
|
||||
<Link href="/checkout/contact" className="flex items-center justify-center">
|
||||
<Link href="/checkout" className="flex items-center justify-center">
|
||||
Оформить заказ
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
@ -12,11 +12,12 @@ import { ImageSlider } from "@/components/product/ImageSlider"
|
||||
import { ProductDetails as ProductDetailsComponent } from "@/components/product/ProductDetails"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { motion } from "framer-motion"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { toast } from "sonner"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
@ -32,18 +33,19 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
const [imageRef, imageInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [detailsRef, detailsInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const { addToCart, loading: cartLoading } = useCart()
|
||||
const { toggleItem, isInWishlist } = useWishlist();
|
||||
|
||||
// layout и хлебные крошки всегда видимы
|
||||
// основной контент fade-in после загрузки
|
||||
|
||||
// Загрузка товара при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const productData = await catalogService.getProductBySlug(params.slug)
|
||||
|
||||
if (!productData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
setProduct(productData)
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке товара:", err)
|
||||
@ -52,34 +54,13 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct()
|
||||
}, [params.slug])
|
||||
|
||||
// Если все еще загружается, показываем скелетон
|
||||
if (loading) {
|
||||
return <ProductSkeleton />
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="container mx-auto py-20 px-4 text-center">
|
||||
<div className="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h1 className="text-2xl font-medium mb-4">Товар не найден</h1>
|
||||
<p className="text-gray-600 mb-6">К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="rounded-full bg-primary hover:bg-primary/90 text-white px-8 py-6">Вернуться в каталог</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="bg-tertiary/5 min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
{/* Навигация */}
|
||||
{/* Навигация и хлебные крошки */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@ -94,7 +75,6 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
<ArrowLeft className="h-4 w-4 mr-2 group-hover:transform group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Вернуться в каталог</span>
|
||||
</Link>
|
||||
|
||||
{/* Хлебные крошки */}
|
||||
<motion.nav
|
||||
initial={{ opacity: 0 }}
|
||||
@ -102,14 +82,10 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="flex items-center text-sm text-neutral-500 overflow-x-auto whitespace-nowrap pb-1"
|
||||
>
|
||||
<Link href="/" className="hover:text-primary transition-colors">
|
||||
Главная
|
||||
</Link>
|
||||
<Link href="/" className="hover:text-primary transition-colors">Главная</Link>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">
|
||||
Каталог
|
||||
</Link>
|
||||
{product.category_name && (
|
||||
<Link href="/catalog" className="hover:text-primary transition-colors">Каталог</Link>
|
||||
{product?.category_name && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<Link
|
||||
@ -121,197 +97,198 @@ export default function ProductPage({ params }: ProductPageProps) {
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 mx-1.5 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product?.name || ''}</span>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Основной контент товара */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<motion.div
|
||||
ref={imageRef}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={imageInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-1 md:order-none relative"
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="aspect-square rounded-3xl" />}>
|
||||
<div className="relative rounded-3xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{/* Используем проверку времени создания для выявления новинок (товары, созданные в течение последних 30 дней) */}
|
||||
{new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">
|
||||
Новинка
|
||||
</Badge>
|
||||
)}
|
||||
{product.discount_price && (
|
||||
<Badge variant="destructive" className="rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - product.discount_price / product.price) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка добавления в избранное */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-10 h-10"
|
||||
onClick={() => toast.success(`${product.name} добавлен в избранное`)}
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
{/* Блок информации о товаре */}
|
||||
<motion.div
|
||||
ref={detailsRef}
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={detailsInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="order-2 md:order-none"
|
||||
>
|
||||
<div className="bg-white rounded-3xl shadow-sm p-6 md:p-8">
|
||||
{/* Категория и название */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mb-6"
|
||||
>
|
||||
{product.category_name && (
|
||||
<div className="text-sm text-primary/60 mb-2 uppercase tracking-wider font-medium">
|
||||
{product.category_name}
|
||||
{/* Блок изображений и инфо */}
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div key="skeleton" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="col-span-2">
|
||||
<ProductSkeleton />
|
||||
</motion.div>
|
||||
) : error || !product ? (
|
||||
<motion.div key="error" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }} className="col-span-2">
|
||||
<div className="container mx-auto py-20 px-4 text-center">
|
||||
<div className="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-lg">
|
||||
<h1 className="text-2xl font-medium mb-4">Товар не найден</h1>
|
||||
<p className="text-gray-600 mb-6">К сожалению, запрашиваемый товар не найден или произошла ошибка при загрузке данных.</p>
|
||||
<Link href="/catalog">
|
||||
<Button className="rounded-full bg-primary hover:bg-primary/90 text-white px-8 py-6">Вернуться в каталог</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-light mb-4 text-primary tracking-tight">{product.name}</h1>
|
||||
|
||||
{/* Цена */}
|
||||
<div className="flex items-center gap-3">
|
||||
{product.discount_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.discount_price)}
|
||||
</span>
|
||||
<span className="text-lg text-primary/50 line-through">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Компонент выбора размера и количества */}
|
||||
<ProductDetailsComponent product={product} />
|
||||
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
|
||||
{/* Описание товара */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Tabs defaultValue="description" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-tertiary/10 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Описание
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="care"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Уход
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-lg p-5 border border-tertiary/10"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
typeof product.description === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
) : (
|
||||
<p>Описание недоступно</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.care_instructions ? (
|
||||
typeof product.care_instructions === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.care_instructions }} />
|
||||
) : (
|
||||
<p>Инструкции по уходу недоступны</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Инструкции по уходу отсутствуют</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
|
||||
{/* Информация о доставке и возврате */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<Truck className="h-5 w-5" />
|
||||
) : (
|
||||
<motion.div key="content" initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 30 }} transition={{ duration: 0.5 }} className="col-span-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{/* Блок изображений */}
|
||||
<div className="order-1 md:order-none relative">
|
||||
<div className="relative rounded-3xl overflow-hidden bg-white shadow-md">
|
||||
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||
{product && new Date(product.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000 && (
|
||||
<Badge className="bg-secondary text-white rounded-full px-4 py-1 shadow-md">Новинка</Badge>
|
||||
)}
|
||||
{product?.discount_price && (
|
||||
<Badge variant="destructive" className="rounded-full px-4 py-1 shadow-md">
|
||||
-{Math.round((1 - (product.discount_price! / product.price)) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Кнопка добавления в избранное */}
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
{product && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isInWishlist(product.id) ? "default" : "secondary"}
|
||||
className={`rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-10 h-10 transition-all ${isInWishlist(product.id) ? 'bg-primary/90 text-white' : ''}`}
|
||||
aria-pressed={isInWishlist(product.id)}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleItem({
|
||||
id: product.id,
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
discount_price: product.discount_price,
|
||||
image: product.primary_image || (product.images && product.images[0]?.image_url) || '',
|
||||
slug: product.slug,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${isInWishlist(product.id) ? 'fill-primary text-white' : ''}`} />
|
||||
<span className="sr-only">{isInWishlist(product.id) ? 'Убрать из избранного' : 'Добавить в избранное'}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ImageSlider images={product?.images || []} productName={product?.name || ''} />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Доставка по всей России 1-3 рабочих дня
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
{/* Блок информации о товаре */}
|
||||
<div className="order-2 md:order-none">
|
||||
<div className="bg-white rounded-3xl shadow-sm p-6 md:p-8">
|
||||
{product?.category_name && (
|
||||
<div className="text-sm text-primary/60 mb-2 uppercase tracking-wider font-medium">
|
||||
{product.category_name}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl md:text-4xl font-light mb-4 text-primary tracking-tight">{product?.name}</h1>
|
||||
{/* Цена */}
|
||||
<div className="flex items-center gap-3">
|
||||
{product.discount_price ? (
|
||||
<>
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.discount_price)}
|
||||
</span>
|
||||
<span className="text-lg text-primary/50 line-through">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-2xl font-medium text-primary">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
{/* Компонент выбора размера и количества */}
|
||||
<ProductDetailsComponent product={product} />
|
||||
<Separator className="my-6 bg-primary/10" />
|
||||
{/* Описание товара */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<Tabs defaultValue="description" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-2 bg-tertiary/10 h-auto mb-4 rounded-lg p-1">
|
||||
<TabsTrigger
|
||||
value="description"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Описание
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="care"
|
||||
className="rounded-md data-[state=active]:bg-white data-[state=active]:shadow-sm text-sm py-2.5"
|
||||
>
|
||||
Уход
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="bg-white rounded-lg p-5 border border-tertiary/10"
|
||||
>
|
||||
<TabsContent value="description" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.description ? (
|
||||
typeof product.description === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.description }} />
|
||||
) : (
|
||||
<p>Описание недоступно</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Описание отсутствует</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="care" className="text-primary/80 text-sm leading-relaxed mt-0">
|
||||
{product.care_instructions ? (
|
||||
typeof product.care_instructions === 'string' ? (
|
||||
<div className="prose prose-neutral prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: product.care_instructions }} />
|
||||
) : (
|
||||
<p>Инструкции по уходу недоступны</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-primary/50 italic">Инструкции по уходу отсутствуют</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
{/* Информация о доставке и возврате */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<Truck className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Доставка</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Доставка по всей России 1-3 рабочих дня
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-tertiary/10 rounded-lg p-4 border border-tertiary/20 transition-all duration-300 hover:shadow-md group">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="bg-primary p-2 rounded-full mr-3 text-white group-hover:bg-primary/90 transition-all duration-300">
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Бесплатный возврат в течение 14 дней
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<h3 className="font-medium text-primary">Возврат</h3>
|
||||
</div>
|
||||
<p className="text-sm text-primary/70">
|
||||
Бесплатный возврат в течение 14 дней
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -75,6 +75,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
|
||||
const [productsError, setProductsError] = useState<string | null>(null)
|
||||
|
||||
// Сохраняем последние успешные продукты для плавного UX
|
||||
const [lastProducts, setLastProducts] = useState<ExtendedProduct[]>([])
|
||||
|
||||
// Получаем данные из хука
|
||||
const { categories, collections, sizes, loading, error } = catalogData;
|
||||
|
||||
@ -178,21 +181,17 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
try {
|
||||
setIsLoadingProducts(true);
|
||||
setProductsError(null);
|
||||
|
||||
// Получаем продукты через кэширующий сервис
|
||||
const response = await catalogData.fetchProducts(queryParams);
|
||||
|
||||
if (!response || !response.products) {
|
||||
setProductsError("Товары не найдены");
|
||||
setProducts([]);
|
||||
setTotalProducts(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Фильтрация по размерам теперь выполняется на бэкенде
|
||||
setProducts(response.products as ExtendedProduct[]);
|
||||
setTotalProducts(response.total);
|
||||
|
||||
setLastProducts(response.products as ExtendedProduct[]); // сохраняем последние успешные
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке продуктов:", err);
|
||||
setProductsError("Не удалось загрузить продукты");
|
||||
@ -200,9 +199,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
// Зависим только от параметров запроса и самой функции fetchProducts
|
||||
}, [queryParams, catalogData.fetchProducts]);
|
||||
|
||||
// Обработчик выбора категории
|
||||
@ -452,8 +449,10 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
|
||||
// Компонент сетки товаров
|
||||
const ProductGrid = ({ products, loading }: { products: ExtendedProduct[], loading: boolean }) => {
|
||||
if (loading) {
|
||||
// Отображаем скелетон для загрузки
|
||||
const showProducts = loading && lastProducts.length > 0 ? lastProducts : products;
|
||||
|
||||
// Если идет загрузка (или это первый рендер), всегда показываем только скелетон
|
||||
if (loading || (products.length === 0 && lastProducts.length === 0)) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-10">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
@ -469,10 +468,11 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
// Сообщение 'Товары не найдены' только если загрузка завершена и был хотя бы один успешный запрос
|
||||
if (!loading && products.length === 0 && lastProducts.length > 0) {
|
||||
return (
|
||||
<div className="text-center py-12 w-full">
|
||||
<div className="bg-primary/5 rounded-xl p-8 max-w-md mx-auto">
|
||||
@ -488,19 +488,27 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Отображение товаров
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-10">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence>
|
||||
{showProducts.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<ProductCard product={product} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -577,9 +585,7 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
|
||||
{/* Основной контент с товарами */}
|
||||
<div className="md:col-span-9">
|
||||
{isLoadingProducts ? (
|
||||
<ProductGrid products={products} loading={isLoadingProducts} />
|
||||
) : productsError ? (
|
||||
{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">{productsError}</p>
|
||||
@ -587,20 +593,9 @@ export default function CatalogPage({ searchParams }: { searchParams?: { [key: s
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<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">
|
||||
Попробуйте изменить параметры фильтрации или поискать что-то другое.
|
||||
</p>
|
||||
<Button onClick={clearAllFilters} className="rounded-xl">
|
||||
Сбросить все фильтры
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ProductGrid products={products} loading={isLoadingProducts} />
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalProducts > 12 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
|
||||
@ -13,6 +13,7 @@ import OrderSummary from "@/components/checkout/order-summary"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import OrderComment from "@/components/checkout/order-comment"
|
||||
import { orderService } from "@/lib/order-service"
|
||||
import { OrderCreate, Address } from "@/types/order"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
@ -24,10 +25,10 @@ export default function CheckoutPage() {
|
||||
const { cart, loading, clearCart } = useCart()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
// Состояния для обработки заказа
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
|
||||
// Состояния для форм и выбранных опций
|
||||
const [userInfo, setUserInfo] = useState({
|
||||
firstName: "",
|
||||
@ -35,47 +36,82 @@ export default function CheckoutPage() {
|
||||
email: "",
|
||||
phone: ""
|
||||
})
|
||||
|
||||
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("cdek")
|
||||
|
||||
const [address, setAddress] = useState({
|
||||
|
||||
const [address, setAddress] = useState<Address>({
|
||||
city: "Новокузнецк", // По умолчанию
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: ""
|
||||
})
|
||||
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("sbp")
|
||||
|
||||
|
||||
const [orderComment, setOrderComment] = useState("")
|
||||
|
||||
|
||||
// Собираем массив goods для СДЭК (TODO: заменить на реальные размеры/вес из товара)
|
||||
const goods = cart.items.map(item => ({
|
||||
length: 20, // TODO: заменить на реальные значения
|
||||
width: 20,
|
||||
height: 20,
|
||||
weight: 2, // кг
|
||||
}));
|
||||
|
||||
// Проверка валидности заполнения формы
|
||||
const isFormValid = () => {
|
||||
const isUserInfoValid =
|
||||
userInfo.firstName &&
|
||||
userInfo.email &&
|
||||
// Проверка информации о пользователе
|
||||
const isUserInfoValid =
|
||||
userInfo.firstName &&
|
||||
userInfo.email &&
|
||||
userInfo.phone
|
||||
|
||||
const isAddressValid =
|
||||
address.city &&
|
||||
address.street &&
|
||||
address.house
|
||||
|
||||
return isUserInfoValid && isAddressValid
|
||||
|
||||
// Проверка адреса в зависимости от способа доставки
|
||||
let isAddressValid = false;
|
||||
|
||||
if (deliveryMethod === "cdek") {
|
||||
// Для CDEK нужен выбранный пункт выдачи
|
||||
isAddressValid = !!address.city && !!address.cdekPvz;
|
||||
|
||||
// Добавляем логирование для отладки
|
||||
console.log("Проверка валидности формы для CDEK:", {
|
||||
city: address.city,
|
||||
cdekPvz: !!address.cdekPvz,
|
||||
isAddressValid
|
||||
});
|
||||
} else {
|
||||
// Для курьерской доставки нужен адрес
|
||||
isAddressValid =
|
||||
!!address.city &&
|
||||
!!address.street &&
|
||||
!!address.house;
|
||||
}
|
||||
|
||||
return isUserInfoValid && isAddressValid;
|
||||
}
|
||||
|
||||
|
||||
// Обработчик оформления заказа
|
||||
const handleSubmitOrder = async () => {
|
||||
// Проверка валидности формы
|
||||
if (!isFormValid()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Форма не заполнена",
|
||||
description: "Пожалуйста, заполните все обязательные поля"
|
||||
})
|
||||
// Проверяем, что именно не заполнено
|
||||
if (deliveryMethod === "cdek" && !address.cdekPvz) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Не выбран пункт выдачи СДЭК",
|
||||
description: "Пожалуйста, нажмите кнопку 'Выбрать пункт выдачи' и выберите пункт выдачи СДЭК"
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Форма не заполнена",
|
||||
description: "Пожалуйста, заполните все обязательные поля"
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@ -84,12 +120,12 @@ export default function CheckoutPage() {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
|
||||
// Подготавливаем данные для заказа
|
||||
const orderData = {
|
||||
const orderData: OrderCreate = {
|
||||
userInfo,
|
||||
items: cart.items,
|
||||
address,
|
||||
@ -97,7 +133,10 @@ export default function CheckoutPage() {
|
||||
paymentMethod,
|
||||
comment: orderComment
|
||||
}
|
||||
|
||||
|
||||
// Вызываем отладочную функцию для анализа структуры заказа
|
||||
orderService.debugOrderStructure(orderData)
|
||||
|
||||
// Сохраняем состояние корзины для показа на странице успешного оформления
|
||||
try {
|
||||
// Создаем копию корзины
|
||||
@ -106,13 +145,13 @@ export default function CheckoutPage() {
|
||||
} catch (error) {
|
||||
console.error("Ошибка при сохранении состояния корзины:", error);
|
||||
}
|
||||
|
||||
|
||||
// Создаем заказ
|
||||
const order = await orderService.createOrder(orderData)
|
||||
|
||||
|
||||
// Очищаем корзину
|
||||
await clearCart()
|
||||
|
||||
|
||||
// Перенаправляем на страницу успешного оформления
|
||||
router.push(`/checkout/success?order_id=${order.orderId}&total=${order.total}&email=${encodeURIComponent(userInfo.email)}`)
|
||||
} catch (error) {
|
||||
@ -123,7 +162,7 @@ export default function CheckoutPage() {
|
||||
} catch (error) {
|
||||
console.error("Ошибка при очистке данных заказа:", error);
|
||||
}
|
||||
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Ошибка",
|
||||
@ -133,7 +172,7 @@ export default function CheckoutPage() {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Проверка наличия товаров в корзине
|
||||
if (!loading && (!cart.items || cart.items.length === 0)) {
|
||||
return (
|
||||
@ -141,7 +180,7 @@ export default function CheckoutPage() {
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
<div className="bg-white p-8 rounded-lg shadow-sm text-center">
|
||||
<p className="text-lg mb-6">Ваша корзина пуста</p>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => router.push("/catalog")}
|
||||
className="bg-black hover:bg-neutral-800"
|
||||
>
|
||||
@ -155,7 +194,7 @@ export default function CheckoutPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-12 px-4">
|
||||
<h1 className="text-2xl font-bold mb-8">Оформление заказа</h1>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-12 gap-8">
|
||||
{/* Левая колонка - формы и выбор опций */}
|
||||
<div className="md:col-span-8 space-y-6">
|
||||
@ -164,50 +203,51 @@ export default function CheckoutPage() {
|
||||
<h2 className="text-lg font-semibold mb-4">Информация о получателе</h2>
|
||||
<UserInfoForm userInfo={userInfo} setUserInfo={setUserInfo} />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Способ доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ доставки</h2>
|
||||
<DeliveryMethodSelector
|
||||
selected={deliveryMethod}
|
||||
onSelect={setDeliveryMethod}
|
||||
<DeliveryMethodSelector
|
||||
selected={deliveryMethod}
|
||||
onSelect={setDeliveryMethod}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Адрес доставки */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Адрес доставки</h2>
|
||||
<AddressForm
|
||||
address={address}
|
||||
setAddress={setAddress}
|
||||
deliveryMethod={deliveryMethod}
|
||||
<AddressForm
|
||||
address={address}
|
||||
setAddress={setAddress}
|
||||
deliveryMethod={deliveryMethod}
|
||||
goods={goods} // goods для СДЭК
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Комментарий к заказу */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Комментарий к заказу</h2>
|
||||
<OrderComment comment={orderComment} setComment={setOrderComment} />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Способ оплаты */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Способ оплаты</h2>
|
||||
<PaymentMethodSelector
|
||||
selected={paymentMethod}
|
||||
onSelect={setPaymentMethod}
|
||||
<PaymentMethodSelector
|
||||
selected={paymentMethod}
|
||||
onSelect={setPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Правая колонка - сводка заказа */}
|
||||
<div className="md:col-span-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm sticky top-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Ваш заказ</h2>
|
||||
<OrderSummary cart={cart} />
|
||||
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
|
||||
<Button
|
||||
className="w-full bg-black hover:bg-neutral-800 mt-4"
|
||||
onClick={handleSubmitOrder}
|
||||
@ -222,7 +262,7 @@ export default function CheckoutPage() {
|
||||
"Оформить заказ"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<p className="text-sm text-gray-500 mt-4 text-center">
|
||||
Нажимая на кнопку, вы соглашаетесь с условиями обработки персональных данных и правилами магазина
|
||||
</p>
|
||||
|
||||
@ -1,258 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { motion } from "framer-motion"
|
||||
import { Heart, ShoppingBag, Trash2, AlertCircle } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Heart, Trash2 } from "lucide-react"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
import catalogService, { ProductDetails } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
const recommended = [
|
||||
{
|
||||
id: 5,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 3490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "skirt-midi",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 12990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "coat-wool",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
|
||||
price: 7990,
|
||||
oldPrice: 9990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "sweater-cashmere",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "РУБАШКА ОВЕРСАЙЗ",
|
||||
price: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "shirt-oversize",
|
||||
},
|
||||
]
|
||||
|
||||
export default function WishlistPage() {
|
||||
// Mock wishlist items
|
||||
const [wishlistItems, setWishlistItems] = useState([
|
||||
{
|
||||
id: 1,
|
||||
name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
|
||||
price: 5990,
|
||||
oldPrice: 7990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
|
||||
price: 4990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ",
|
||||
price: 3990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ЖАКЕТ В КЛЕТКУ",
|
||||
price: 6990,
|
||||
oldPrice: 8990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
inStock: true,
|
||||
},
|
||||
])
|
||||
const { items, removeItem, clearWishlist } = useWishlist()
|
||||
const [products, setProducts] = useState<ProductDetails[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const removeFromWishlist = (id: number) => {
|
||||
setWishlistItems(wishlistItems.filter((item) => item.id !== id))
|
||||
}
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
async function fetchProducts() {
|
||||
setLoading(true)
|
||||
const results: ProductDetails[] = []
|
||||
for (const item of items) {
|
||||
try {
|
||||
const product = await catalogService.getProductById(item.product_id)
|
||||
if (isMounted && product) results.push(product)
|
||||
} catch (e) {
|
||||
// ignore not found
|
||||
}
|
||||
}
|
||||
if (isMounted) setProducts(results)
|
||||
setLoading(false)
|
||||
}
|
||||
if (items.length > 0) fetchProducts()
|
||||
else {
|
||||
setProducts([])
|
||||
setLoading(false)
|
||||
}
|
||||
return () => { isMounted = false }
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[40vh] min-h-[300px] bg-gray-900">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/placeholder.svg?height=800&width=1920')" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70"></div>
|
||||
<div className="bg-white min-h-screen">
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="flex items-center justify-between mb-8 md:mb-12">
|
||||
<Link href="/catalog" className="text-primary hover:text-primary/80 flex items-center text-sm md:text-base transition-colors duration-200">
|
||||
<Heart className="h-5 w-5 mr-2" />
|
||||
Вернуться к покупкам
|
||||
</Link>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-light text-primary tracking-tight">Избранное</h1>
|
||||
<div className="w-[120px] md:hidden"></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-arimo font-bold mb-4 uppercase tracking-tight">ИЗБРАННОЕ</h1>
|
||||
<p className="text-xl opacity-90 font-arimo">Ваша персональная коллекция любимых товаров</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
{wishlistItems.length === 0 ? (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-100 mb-6">
|
||||
<Heart className="h-12 w-12 text-black" />
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.5 }} className="py-16 md:py-24 max-w-2xl mx-auto">
|
||||
<div className="flex justify-center gap-4">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-72 w-56 rounded-3xl" />)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-arimo font-medium text-black mb-4 uppercase">Ваш список избранного пуст</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto font-arimo">
|
||||
</motion.div>
|
||||
) : products.length === 0 ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center py-16 md:py-24 max-w-2xl mx-auto"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 md:w-32 md:h-32 bg-tertiary/20 rounded-full mb-8">
|
||||
<Heart className="h-12 w-12 md:h-16 md:w-16 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-medium text-primary mb-4">Ваш список избранного пуст</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Добавляйте понравившиеся товары в избранное, чтобы не потерять их и быстро вернуться к ним позже.
|
||||
</p>
|
||||
<Button asChild className="bg-black hover:bg-gray-800 text-white px-8 font-arimo">
|
||||
<Link href="/catalog">ПЕРЕЙТИ В КАТАЛОГ</Link>
|
||||
<Button asChild className="bg-primary hover:bg-primary/90 text-white px-8">
|
||||
<Link href="/catalog">Перейти в каталог</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div>
|
||||
<motion.div
|
||||
key="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-arimo font-bold text-black uppercase tracking-tight">
|
||||
ТОВАРЫ В ИЗБРАННОМ ({wishlistItems.length})
|
||||
<h2 className="text-xl md:text-2xl font-semibold text-primary uppercase tracking-tight">
|
||||
Товары в избранном ({products.length})
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black text-black hover:bg-black hover:text-white font-arimo"
|
||||
onClick={() => setWishlistItems([])}
|
||||
className="border-primary text-primary hover:bg-primary hover:text-white"
|
||||
onClick={clearWishlist}
|
||||
>
|
||||
ОЧИСТИТЬ СПИСОК
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Очистить список
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{wishlistItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="bg-white border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<Image
|
||||
src={item.image || "/placeholder.svg"}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 bg-white p-2 shadow-md hover:bg-gray-100 transition-colors"
|
||||
onClick={() => removeFromWishlist(item.id)}
|
||||
>
|
||||
<Trash2 className="h-5 w-5 text-black" />
|
||||
<span className="sr-only">Удалить из избранного</span>
|
||||
</button>
|
||||
|
||||
{!item.inStock && (
|
||||
<div className="absolute inset-0 bg-white/80 flex flex-col items-center justify-center p-4">
|
||||
<AlertCircle className="h-8 w-8 text-gray-500 mb-2" />
|
||||
<p className="font-arimo font-medium text-black text-center">НЕТ В НАЛИЧИИ</p>
|
||||
<p className="text-sm text-gray-600 text-center mt-1 font-arimo">Сообщить, когда появится</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link href={`/product/${item.id}`} className="block">
|
||||
<h3 className="text-lg font-arimo font-medium text-black mb-2 hover:underline uppercase">
|
||||
{item.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="font-arimo font-bold text-black">{item.price.toLocaleString()} ₽</span>
|
||||
{item.oldPrice && (
|
||||
<span className="text-gray-500 line-through text-sm font-arimo">
|
||||
{item.oldPrice.toLocaleString()} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{item.inStock ? (
|
||||
<>
|
||||
<Button asChild className="flex-1 bg-black hover:bg-gray-800 text-white font-arimo">
|
||||
<Link href={`/product/${item.id}`}>ПОДРОБНЕЕ</Link>
|
||||
</Button>
|
||||
<Button className="flex-1 bg-gray-100 text-black hover:bg-gray-200 font-arimo">
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />В КОРЗИНУ
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-gray-200 text-gray-700 hover:bg-gray-300 cursor-default font-arimo"
|
||||
>
|
||||
<Link href={`/product/${item.id}`}>ПОДРОБНЕЕ</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="relative">
|
||||
<ProductCard
|
||||
product={product}
|
||||
showWishlistButton={false}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-20 opacity-100"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
removeItem(product.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-primary" />
|
||||
<span className="sr-only">Удалить из избранного</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recommended Products */}
|
||||
<section className="py-12 bg-gray-100">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-2xl font-arimo font-bold text-black mb-8 uppercase tracking-tight">РЕКОМЕНДУЕМ ВАМ</h2>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Рекомендации */}
|
||||
<section className="py-12 md:py-20">
|
||||
<h2 className="text-xl md:text-2xl font-semibold text-primary mb-8 uppercase tracking-tight">Рекомендуем вам</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[
|
||||
{
|
||||
id: 5,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 3490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 12990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
|
||||
price: 7990,
|
||||
oldPrice: 9990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "РУБАШКА ОВЕРСАЙЗ",
|
||||
price: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.id} className="bg-white border border-gray-200 overflow-hidden group">
|
||||
<div className="relative aspect-[3/4] overflow-hidden">
|
||||
<Image
|
||||
src={item.image || "/placeholder.svg"}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<button className="absolute top-4 right-4 bg-white p-2 shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Heart className="h-5 w-5 text-black" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link href={`/product/${item.id}`} className="block">
|
||||
<h3 className="text-lg font-arimo font-medium text-black mb-2 group-hover:underline uppercase">
|
||||
{item.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="font-arimo font-bold text-black">{item.price.toLocaleString()} ₽</span>
|
||||
{item.oldPrice && (
|
||||
<span className="text-gray-500 line-through text-sm font-arimo">
|
||||
{item.oldPrice.toLocaleString()} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full bg-gray-100 text-black hover:bg-gray-200 font-arimo">
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />В КОРЗИНУ
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{recommended.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
price={item.price}
|
||||
image={item.image}
|
||||
isOnSale={!!item.oldPrice}
|
||||
salePrice={item.oldPrice ? item.price : undefined}
|
||||
slug={item.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/app/.DS_Store
vendored
BIN
frontend/app/.DS_Store
vendored
Binary file not shown.
@ -1,21 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Trash, Edit, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -23,489 +12,207 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { categoryService } from '@/lib/catalog';
|
||||
import { Category, CategoryCreate, CategoryUpdate } from '@/lib/catalog';
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
// Схема валидации для формы категории
|
||||
const categoryFormSchema = z.object({
|
||||
name: z.string().min(2, { message: 'Название должно содержать минимум 2 символа' }),
|
||||
slug: z.string().min(2, { message: 'Slug должен содержать минимум 2 символа' }).optional(),
|
||||
description: z.string().optional(),
|
||||
parent_id: z.number().nullable().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
|
||||
import CategoryTree from '@/components/admin/CategoryTree';
|
||||
import { CategoryForm, CategoryFormValues, categoryFormSchema } from '@/components/admin/CategoryForm';
|
||||
|
||||
type CategoryFormValues = z.infer<typeof categoryFormSchema>;
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
import { useAdminMutation } from '@/hooks/useAdminQuery';
|
||||
import { cacheKeys } from '@/lib/api-cache';
|
||||
import useCategoriesCache from '@/hooks/useCategoriesCache';
|
||||
|
||||
// Компонент для отображения категории в виде дерева
|
||||
const CategoryItem = ({
|
||||
category,
|
||||
level = 0,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddSubcategory,
|
||||
categories,
|
||||
}: {
|
||||
category: Category;
|
||||
level?: number;
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (categoryId: number) => void;
|
||||
onAddSubcategory: (parentId: number) => void;
|
||||
categories: Category[];
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasSubcategories = category.subcategories && category.subcategories.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={`flex items-center p-2 rounded hover:bg-gray-100 transition-colors ${
|
||||
!category.is_active ? 'opacity-60' : ''
|
||||
}`}
|
||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||
>
|
||||
{hasSubcategories ? (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mr-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[18px] mr-2" />
|
||||
)}
|
||||
<div className="flex-grow font-medium">{category.name}</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onAddSubcategory(category.id)}
|
||||
title="Добавить подкатегорию"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(category)}
|
||||
title="Редактировать категорию"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(category.id)}
|
||||
title="Удалить категорию"
|
||||
disabled={hasSubcategories}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && category.subcategories && category.subcategories.length > 0 && (
|
||||
<div>
|
||||
{category.subcategories.map((subcategory) => (
|
||||
<CategoryItem
|
||||
key={subcategory.id}
|
||||
category={subcategory}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddSubcategory={onAddSubcategory}
|
||||
categories={categories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент диалогового окна для редактирования категории
|
||||
const EditCategoryDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
category,
|
||||
onSave,
|
||||
categories,
|
||||
mode,
|
||||
parentId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
category: Category | null;
|
||||
onSave: (data: CategoryFormValues) => void;
|
||||
categories: Category[];
|
||||
mode: 'edit' | 'create';
|
||||
parentId?: number | null;
|
||||
}) => {
|
||||
const form = useForm<CategoryFormValues>({
|
||||
resolver: zodResolver(categoryFormSchema),
|
||||
defaultValues: category
|
||||
? {
|
||||
name: category.name,
|
||||
slug: category.slug || '',
|
||||
description: category.description || '',
|
||||
parent_id: category.parent_id,
|
||||
is_active: category.is_active,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
parent_id: parentId || null,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset(
|
||||
category
|
||||
? {
|
||||
name: category.name,
|
||||
slug: category.slug || '',
|
||||
description: category.description || '',
|
||||
parent_id: category.parent_id,
|
||||
is_active: category.is_active,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
parent_id: parentId || null,
|
||||
is_active: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [category, form, isOpen, parentId]);
|
||||
|
||||
const handleSubmit = (data: CategoryFormValues) => {
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
// Функция для получения плоского списка категорий для выбора родителя
|
||||
const getSelectableCategories = (
|
||||
categories: Category[],
|
||||
excludeId: number | null = null
|
||||
): { id: number; name: string; level: number }[] => {
|
||||
const result: { id: number; name: string; level: number }[] = [];
|
||||
|
||||
const traverse = (cats: Category[], level = 0) => {
|
||||
cats.forEach((cat) => {
|
||||
if (excludeId === null || cat.id !== excludeId) {
|
||||
result.push({ id: cat.id, name: cat.name, level });
|
||||
if (cat.subcategories && cat.subcategories.length > 0) {
|
||||
traverse(cat.subcategories, level + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(categories);
|
||||
return result;
|
||||
};
|
||||
|
||||
const selectableCategories = getSelectableCategories(
|
||||
categories,
|
||||
mode === 'edit' ? category?.id : null
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? 'Редактирование категории' : 'Создание категории'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'edit'
|
||||
? 'Отредактируйте информацию о категории'
|
||||
: 'Создайте новую категорию'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Название категории</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Название категории" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug (для URL)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="slug-category" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Используется в URL. Только латинские буквы, цифры и дефисы.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Описание</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Описание категории" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parent_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Родительская категория</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(value === "null" ? null : parseInt(value, 10))}
|
||||
value={field.value?.toString() || "null"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите родительскую категорию" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="null">Нет родительской категории</SelectItem>
|
||||
{selectableCategories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id.toString()}>
|
||||
{'—'.repeat(cat.level)} {cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Активна</FormLabel>
|
||||
<FormDescription>
|
||||
Если отключено, категория не будет отображаться на сайте.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit">Сохранить</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryToEdit, setCategoryToEdit] = useState<Category | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'edit' | 'create'>('create');
|
||||
const [parentIdForCreate, setParentIdForCreate] = useState<number | null>(null);
|
||||
// Состояние для диалогов
|
||||
const [showFormDialog, setShowFormDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||
|
||||
// Загрузка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке категорий:', error);
|
||||
toast.error('Ошибка при загрузке категорий');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Используем хук для получения категорий
|
||||
const { categories, isLoading, error } = useCategoriesCache();
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Обработчик редактирования категории
|
||||
const handleEdit = (category: Category) => {
|
||||
setCategoryToEdit(category);
|
||||
setDialogMode('edit');
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик создания подкатегории
|
||||
const handleAddSubcategory = (parentId: number) => {
|
||||
setCategoryToEdit(null);
|
||||
setDialogMode('create');
|
||||
setParentIdForCreate(parentId);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик создания корневой категории
|
||||
const handleAddRootCategory = () => {
|
||||
setCategoryToEdit(null);
|
||||
setDialogMode('create');
|
||||
setParentIdForCreate(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик удаления категории
|
||||
const handleDelete = async (categoryId: number) => {
|
||||
if (!confirm('Вы действительно хотите удалить эту категорию?')) {
|
||||
return;
|
||||
// Мутации для работы с категориями
|
||||
const createMutation = useAdminMutation<Category, Error, CategoryFormValues>(
|
||||
'post',
|
||||
'/catalog/categories',
|
||||
{
|
||||
onSuccessMessage: 'Категория успешно создана',
|
||||
onErrorMessage: 'Не удалось создать категорию',
|
||||
invalidateQueries: [cacheKeys.categories]
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await categoryService.deleteCategory(categoryId);
|
||||
if (result) {
|
||||
// Обновляем список категорий после удаления
|
||||
setCategories(categories.filter(cat => cat.id !== categoryId));
|
||||
toast.success('Категория успешно удалена');
|
||||
} else {
|
||||
toast.error('Не удалось удалить категорию');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении категории:', error);
|
||||
toast.error('Ошибка при удалении категории');
|
||||
const updateMutation = useAdminMutation<Category, Error, CategoryFormValues & { id: number }>(
|
||||
'put',
|
||||
(variables) => `/catalog/categories/${variables.id}`,
|
||||
{
|
||||
onSuccessMessage: 'Категория успешно обновлена',
|
||||
onErrorMessage: 'Не удалось обновить категорию',
|
||||
invalidateQueries: [cacheKeys.categories]
|
||||
}
|
||||
);
|
||||
|
||||
const deleteMutation = useAdminMutation<any, Error, number>(
|
||||
'delete',
|
||||
(id: number) => `/catalog/categories/${id}`,
|
||||
{
|
||||
onSuccessMessage: 'Категория успешно удалена',
|
||||
onErrorMessage: 'Не удалось удалить категорию',
|
||||
invalidateQueries: [cacheKeys.categories]
|
||||
}
|
||||
);
|
||||
|
||||
// Обработчики событий
|
||||
const handleAddCategory = (parentId: number | null) => {
|
||||
setEditingCategory(null);
|
||||
setParentCategoryId(parentId);
|
||||
setShowFormDialog(true);
|
||||
};
|
||||
|
||||
// Обработчик сохранения категории
|
||||
const handleSave = async (values: CategoryFormValues) => {
|
||||
const handleEditCategory = (category: Category) => {
|
||||
setEditingCategory(category);
|
||||
setParentCategoryId(category.parent_id);
|
||||
setShowFormDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (category: Category) => {
|
||||
setCategoryToDelete(category);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: CategoryFormValues) => {
|
||||
try {
|
||||
if (dialogMode === 'edit' && categoryToEdit) {
|
||||
const result = await categoryService.updateCategory(categoryToEdit.id, values);
|
||||
if (result) {
|
||||
// Обновляем категорию в списке
|
||||
setCategories(categories.map(cat =>
|
||||
cat.id === categoryToEdit.id ? { ...cat, ...values } : cat
|
||||
));
|
||||
toast.success('Категория успешно обновлена');
|
||||
} else {
|
||||
toast.error('Не удалось обновить категорию');
|
||||
}
|
||||
} else {
|
||||
const result = await categoryService.createCategory({
|
||||
if (editingCategory) {
|
||||
// Обновление существующей категории
|
||||
await updateMutation.mutateAsync({
|
||||
...values,
|
||||
parent_id: parentIdForCreate || undefined
|
||||
id: editingCategory.id
|
||||
});
|
||||
} else {
|
||||
// Создание новой категории
|
||||
await createMutation.mutateAsync({
|
||||
...values,
|
||||
parent_id: parentCategoryId
|
||||
});
|
||||
if (result) {
|
||||
// Добавляем новую категорию в список
|
||||
setCategories([...categories, result]);
|
||||
toast.success('Категория успешно создана');
|
||||
} else {
|
||||
toast.error('Не удалось создать категорию');
|
||||
}
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
setShowFormDialog(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при сохранении категории:', error);
|
||||
toast.error('Ошибка при сохранении категории');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Управление категориями</CardTitle>
|
||||
<CardDescription>
|
||||
Создавайте, редактируйте и удаляйте категории товаров
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleAddRootCategory}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Добавить категорию
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Категории не найдены. Создайте первую категорию, нажав на кнопку "Добавить категорию".
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
{categories.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAddSubcategory={handleAddSubcategory}
|
||||
categories={categories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
const handleConfirmDelete = async () => {
|
||||
if (categoryToDelete) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(categoryToDelete.id);
|
||||
setShowDeleteDialog(false);
|
||||
setCategoryToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении категории:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{/* Диалоговое окно для редактирования/создания категории */}
|
||||
<EditCategoryDialog
|
||||
isOpen={isDialogOpen}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
category={categoryToEdit}
|
||||
onSave={handleSave}
|
||||
categories={categories}
|
||||
mode={dialogMode}
|
||||
parentId={parentIdForCreate}
|
||||
// Значения формы по умолчанию
|
||||
const defaultFormValues: CategoryFormValues = {
|
||||
name: editingCategory?.name || '',
|
||||
slug: editingCategory?.slug || '',
|
||||
description: editingCategory?.description || '',
|
||||
parent_id: editingCategory?.parent_id ? String(editingCategory.parent_id) : null,
|
||||
is_active: editingCategory?.is_active ?? true
|
||||
};
|
||||
|
||||
// Состояние загрузки
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const isDeleting = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<AdminPageContainer>
|
||||
<AdminPageHeader
|
||||
title="Управление категориями"
|
||||
description="Создание, редактирование и удаление категорий товаров"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryTree
|
||||
onSelectCategory={(category) => console.log('Selected category:', category)}
|
||||
onEditCategory={handleEditCategory}
|
||||
onDeleteCategory={handleDeleteCategory}
|
||||
onAddCategory={handleAddCategory}
|
||||
showActions={true}
|
||||
showCounts={true}
|
||||
/>
|
||||
|
||||
{/* Диалог формы категории */}
|
||||
<Dialog open={showFormDialog} onOpenChange={setShowFormDialog}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCategory ? 'Редактирование категории' : 'Создание новой категории'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingCategory
|
||||
? 'Измените данные категории и нажмите "Сохранить"'
|
||||
: 'Заполните форму для создания новой категории'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<CategoryForm
|
||||
defaultValues={defaultFormValues}
|
||||
categories={categories}
|
||||
onSubmit={handleFormSubmit}
|
||||
isSaving={isSaving}
|
||||
onCancel={() => setShowFormDialog(false)}
|
||||
isEditing={!!editingCategory}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Подтверждение удаления</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Вы уверены, что хотите удалить категорию "{categoryToDelete?.name}"?
|
||||
{categoryToDelete?.products_count && categoryToDelete.products_count > 0 && (
|
||||
<p className="mt-2 text-red-500">
|
||||
Внимание! В этой категории есть товары ({categoryToDelete.products_count} шт.).
|
||||
При удалении категории товары останутся без категории.
|
||||
</p>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Удаление...' : 'Удалить'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AdminPageContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { Order, Address } from '@/lib/orders';
|
||||
import { Product, ProductDetails } from '@/lib/catalog';
|
||||
import { useDashboardStats, useRecentOrders, usePopularProducts } from '@/hooks/useAdminQuery';
|
||||
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;
|
||||
total_orders: number;
|
||||
total_users: number;
|
||||
total_revenue: number;
|
||||
total_products: number;
|
||||
}
|
||||
|
||||
// Компонент статистической карточки
|
||||
@ -63,6 +60,14 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const getUserName = (order: Order) => {
|
||||
const address = order.shipping_address;
|
||||
if (address) {
|
||||
return `${address.first_name} ${address.last_name}`;
|
||||
}
|
||||
return `Пользователь #${order.user_id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
@ -84,7 +89,7 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.user_name || `Пользователь #${order.user_id}`}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{getUserName(order)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
@ -97,14 +102,13 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
{order.status === 'delivered' ? 'Доставлен' :
|
||||
order.status === 'processing' ? 'В обработке' :
|
||||
order.status === 'shipped' ? 'Отправлен' :
|
||||
order.status === 'paid' ? 'Оплачен' :
|
||||
order.status === 'pending' ? 'Ожидает' :
|
||||
order.status === 'cancelled' ? 'Отменен' :
|
||||
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_amount.toLocaleString('ru-RU')} ₽`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
@ -134,7 +138,7 @@ const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
|
||||
// Компонент популярных товаров
|
||||
interface PopularProductsProps {
|
||||
products: Product[];
|
||||
products: ProductDetails[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@ -174,17 +178,17 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{product.name || 'Без названия'}</td>
|
||||
<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 text-sm text-gray-500">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' :
|
||||
${product.stock && product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
product.stock && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{typeof product.stock === 'number' ? product.stock : 'Н/Д'}
|
||||
{product.stock || 'Н/Д'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/catalog/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Редактировать
|
||||
</Link>
|
||||
</td>
|
||||
@ -200,212 +204,90 @@ const PopularProducts = ({ products, loading, error }: PopularProductsProps) =>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Link href="/admin/catalog/products" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Посмотреть все товары →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Основной компонент дашборда
|
||||
export default function AdminDashboard() {
|
||||
// Используем кэш для статистики
|
||||
const statsCache = useAdminCache<DashboardStats>({
|
||||
key: 'dashboard-stats',
|
||||
ttl: 5 * 60 * 1000, // 5 минут
|
||||
});
|
||||
// Получаем данные через React Query хуки
|
||||
const { data: stats, isLoading: statsLoading, error: statsError } = useDashboardStats();
|
||||
const { data: recentOrders = [], isLoading: ordersLoading, error: ordersError } = useRecentOrders(5);
|
||||
const { data: popularProducts = [], isLoading: productsLoading, error: productsError } = usePopularProducts(5);
|
||||
|
||||
// Используем кэш для заказов
|
||||
const ordersCache = useAdminCache<Order[]>({
|
||||
key: 'recent-orders',
|
||||
ttl: 2 * 60 * 1000, // 2 минуты
|
||||
});
|
||||
|
||||
// Используем кэш для товаров
|
||||
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 () => {
|
||||
// Загружаем статистику
|
||||
await statsCache.loadData(async () => {
|
||||
const response = await statsApi.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return response || {
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Загружаем последние заказы
|
||||
await ordersCache.loadData(async () => {
|
||||
const response = await ordersApi.get<Order[]>('/admin/orders/recent', {
|
||||
limit: 4,
|
||||
sort_by: 'created_at',
|
||||
sort_dir: 'desc'
|
||||
});
|
||||
return response || [];
|
||||
});
|
||||
|
||||
// Загружаем популярные товары
|
||||
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 formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const recentOrders = ordersCache.data || [];
|
||||
const popularProducts = productsCache.data || [];
|
||||
// Если загружаются все данные, показываем общий индикатор загрузки
|
||||
if (statsLoading && ordersLoading && productsLoading) {
|
||||
return <AdminLoadingState message="Загрузка данных..." />;
|
||||
}
|
||||
|
||||
// Состояние загрузки
|
||||
const loading = {
|
||||
stats: statsApi.isLoading || statsCache.isLoading,
|
||||
orders: ordersApi.isLoading || ordersCache.isLoading,
|
||||
products: productsApi.isLoading || productsCache.isLoading
|
||||
};
|
||||
// Если есть ошибка в получении статистики, показываем ошибку
|
||||
if (statsError) {
|
||||
return (
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки данных"
|
||||
message={(statsError as Error)?.message || 'Не удалось загрузить статистику дашборда'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Состояние ошибок
|
||||
const error = {
|
||||
stats: statsApi.error,
|
||||
orders: ordersApi.error,
|
||||
products: productsApi.error
|
||||
const dashboardStats = stats as DashboardStats || {
|
||||
total_orders: 0,
|
||||
total_revenue: 0,
|
||||
total_users: 0,
|
||||
total_products: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
||||
|
||||
{/* Статистические карточки */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Статистика */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? '...' : stats.ordersCount.toLocaleString('ru-RU')}
|
||||
icon={<ShoppingBag size={24} className="text-white" />}
|
||||
color="bg-blue-500"
|
||||
value={dashboardStats.total_orders}
|
||||
icon={<Package size={24} className="text-purple-600" />}
|
||||
color="bg-purple-100"
|
||||
/>
|
||||
<StatCard
|
||||
title="Общие продажи"
|
||||
value={loading.stats ? '...' : `${stats.totalSales.toLocaleString('ru-RU')} ₽`}
|
||||
icon={<BarChart3 size={24} className="text-white" />}
|
||||
color="bg-green-500"
|
||||
title="Общая выручка"
|
||||
value={formatCurrency(dashboardStats.total_revenue)}
|
||||
icon={<BarChart3 size={24} className="text-green-600" />}
|
||||
color="bg-green-100"
|
||||
/>
|
||||
<StatCard
|
||||
title="Клиенты"
|
||||
value={loading.stats ? '...' : stats.customersCount.toLocaleString('ru-RU')}
|
||||
icon={<Users size={24} className="text-white" />}
|
||||
color="bg-purple-500"
|
||||
title="Клиентов"
|
||||
value={dashboardStats.total_users}
|
||||
icon={<Users size={24} className="text-blue-600" />}
|
||||
color="bg-blue-100"
|
||||
/>
|
||||
<StatCard
|
||||
title="Товары"
|
||||
value={loading.stats ? '...' : stats.productsCount.toLocaleString('ru-RU')}
|
||||
icon={<Package size={24} className="text-white" />}
|
||||
color="bg-orange-500"
|
||||
title="Товаров"
|
||||
value={dashboardStats.total_products}
|
||||
icon={<ShoppingBag size={24} className="text-orange-600" />}
|
||||
color="bg-orange-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Последние заказы и популярные товары */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
orders={recentOrders as Order[]}
|
||||
loading={ordersLoading}
|
||||
error={(ordersError as Error)?.message || null}
|
||||
/>
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
products={popularProducts as ProductDetails[]}
|
||||
loading={productsLoading}
|
||||
error={(productsError as Error)?.message || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import { ReactNode, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings, Home, Layers, FolderTree, LogOut } from 'lucide-react';
|
||||
import { authApi } from '@/lib/auth-api';
|
||||
import AdminQueryProvider from '@/providers/AdminQueryProvider';
|
||||
|
||||
// Интерфейс для компонента MenuItem
|
||||
interface MenuItemProps {
|
||||
@ -26,11 +27,11 @@ interface User {
|
||||
// Компонент элемента меню
|
||||
const MenuItem = ({ href, icon, label, active = false }: MenuItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center px-4 py-2 rounded-md mb-1 ${
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
@ -54,40 +55,40 @@ const links = [
|
||||
// Проверка является ли пользователь администратором
|
||||
function isUserAdmin(userProfile: any): boolean {
|
||||
console.log('Проверка прав администратора для:', userProfile);
|
||||
|
||||
|
||||
if (!userProfile) {
|
||||
console.log('Профиль пользователя пустой или не определен');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Детальная проверка различных способов указания прав администратора
|
||||
if (userProfile.is_admin === true) {
|
||||
console.log('Пользователь админ по свойству is_admin');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (userProfile.role === 'admin') {
|
||||
console.log('Пользователь админ по роли admin');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (userProfile.role === 'ADMIN') {
|
||||
console.log('Пользователь админ по роли ADMIN');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Проверка прав в массиве
|
||||
if (Array.isArray(userProfile.roles) && userProfile.roles.includes('admin')) {
|
||||
console.log('Пользователь админ по массиву ролей');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Проверка поля admin_access если оно есть
|
||||
if (userProfile.admin_access === true) {
|
||||
console.log('Пользователь админ по свойству admin_access');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
console.log('Пользователь НЕ админ, не найдены признаки админских прав');
|
||||
return false;
|
||||
}
|
||||
@ -125,33 +126,33 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const token = getToken();
|
||||
console.log('Проверка токена в layout:', token ? token.substring(0, 20) + '...' : 'нет токена');
|
||||
|
||||
|
||||
if (!token) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setIsLoading(false);
|
||||
|
||||
|
||||
// Перенаправляем на страницу входа, если мы не там
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Проверяем токен и получаем профиль пользователя
|
||||
const userProfile = await authApi.getProfile();
|
||||
console.log('Профиль пользователя в layout:', userProfile);
|
||||
|
||||
|
||||
if (userProfile) {
|
||||
setIsAuthenticated(true);
|
||||
setCurrentUser(userProfile);
|
||||
|
||||
|
||||
// Проверяем права администратора
|
||||
const userAdminStatus = isUserAdmin(userProfile);
|
||||
console.log('Результат проверки админских прав:', userAdminStatus);
|
||||
setIsAdmin(userAdminStatus);
|
||||
|
||||
|
||||
// Если пользователь авторизован, но не админ - показываем ошибку и предлагаем выйти
|
||||
if (!userAdminStatus && window.location.pathname !== '/admin/login') {
|
||||
setAuthError('У вас нет прав доступа к админ-панели');
|
||||
@ -166,9 +167,9 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
// Не удаляем токен при перезагрузке страницы
|
||||
// localStorage.removeItem(TOKEN_KEY);
|
||||
console.log('Профиль не получен, но токен сохраняем');
|
||||
|
||||
|
||||
// Перенаправляем на страницу входа
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
if (window.location.pathname.startsWith('/admin') &&
|
||||
window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
@ -184,7 +185,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
@ -210,7 +211,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
<h2 className="text-xl font-bold mb-2">Доступ запрещен</h2>
|
||||
<p className="mb-4">{authError || 'У вас нет прав доступа к админ-панели.'}</p>
|
||||
<p>Войдите с аккаунтом администратора.</p>
|
||||
|
||||
|
||||
{currentUser && (
|
||||
<div className="mt-4 text-sm text-gray-700 p-3 bg-gray-100 rounded-lg">
|
||||
<p>Текущий пользователь: <strong>{currentUser.email}</strong></p>
|
||||
@ -222,7 +223,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
@ -243,55 +244,57 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
// Для авторизованных пользователей с правами админа показываем полный интерфейс
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Боковое меню */}
|
||||
<div className="w-64 bg-white shadow-md">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">Админ-панель</h1>
|
||||
{currentUser && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{currentUser.email} {currentUser.is_admin ? '(Админ)' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<nav>
|
||||
{links.map((link) => (
|
||||
<MenuItem
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
icon={link.icon}
|
||||
label={link.name}
|
||||
/>
|
||||
))}
|
||||
<MenuItem
|
||||
href="/"
|
||||
icon={<Tag size={20} />}
|
||||
label="На сайт"
|
||||
/>
|
||||
{/* Кнопка выхода */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center px-4 py-2 rounded-md mb-1 w-full text-left text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="ml-3">Выйти</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Админ-панель</h2>
|
||||
<AdminQueryProvider>
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Боковое меню */}
|
||||
<div className="w-64 bg-white shadow-md">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">Админ-панель</h1>
|
||||
{currentUser && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{currentUser.email} {currentUser.is_admin ? '(Админ)' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
<div className="p-4">
|
||||
<nav>
|
||||
{links.map((link) => (
|
||||
<MenuItem
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
icon={link.icon}
|
||||
label={link.name}
|
||||
/>
|
||||
))}
|
||||
<MenuItem
|
||||
href="/"
|
||||
icon={<Tag size={20} />}
|
||||
label="На сайт"
|
||||
/>
|
||||
{/* Кнопка выхода */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center px-4 py-2 rounded-md mb-1 w-full text-left text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="ml-3">Выйти</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Админ-панель</h2>
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminQueryProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import OrderManager from '@/components/admin/OrderManager';
|
||||
import OrderManagerOptimized from '@/components/admin/OrderManagerOptimized';
|
||||
import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Управление заказами</h1>
|
||||
<OrderManager />
|
||||
</div>
|
||||
<AdminPageContainer>
|
||||
<AdminPageHeader
|
||||
title="Управление заказами"
|
||||
description="Просмотр и управление заказами клиентов"
|
||||
/>
|
||||
<OrderManagerOptimized />
|
||||
</AdminPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import AdminDashboard from './dashboard/page';
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect('/admin/dashboard');
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
@ -5,7 +5,17 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import catalogService, { ProductUpdateComplete, ProductDetails, Category, Size, Collection } from "@/lib/catalog";
|
||||
import catalogService, {
|
||||
ProductUpdateComplete,
|
||||
ProductDetails,
|
||||
Category,
|
||||
Size,
|
||||
Collection,
|
||||
ProductImage,
|
||||
getImageUrl,
|
||||
normalizeProductImage
|
||||
} from "@/lib/catalog";
|
||||
import api from "@/lib/api";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -41,6 +51,8 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Загрузка данных для продукта ID:', productId);
|
||||
|
||||
const [productResponse, categoriesResponse, sizesResponse, collectionsResponse] = await Promise.all([
|
||||
catalogService.getProductById(productId),
|
||||
@ -49,14 +61,46 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
catalogService.getCollections()
|
||||
]);
|
||||
|
||||
console.log('Полученные данные продукта:', productResponse);
|
||||
console.log('Полученные категории:', categoriesResponse);
|
||||
console.log('Полученные размеры:', sizesResponse);
|
||||
console.log('Полученные коллекции:', collectionsResponse);
|
||||
|
||||
if (!productResponse) {
|
||||
throw new Error("Товар не найден");
|
||||
}
|
||||
|
||||
setProduct(productResponse);
|
||||
setCategories(Array.isArray(categoriesResponse) ? categoriesResponse : []);
|
||||
setSizes(Array.isArray(sizesResponse) ? sizesResponse : []);
|
||||
setCollections(Array.isArray(collectionsResponse?.collections) ? collectionsResponse.collections : []);
|
||||
|
||||
// Преобразуем категории если не массив
|
||||
if (categoriesResponse) {
|
||||
const categories = Array.isArray(categoriesResponse)
|
||||
? categoriesResponse
|
||||
: ('categories' in categoriesResponse as any ? (categoriesResponse as any).categories : []);
|
||||
setCategories(categories);
|
||||
} else {
|
||||
setCategories([]);
|
||||
}
|
||||
|
||||
// Преобразуем размеры если не массив
|
||||
if (sizesResponse) {
|
||||
const sizes = Array.isArray(sizesResponse)
|
||||
? sizesResponse
|
||||
: ('sizes' in sizesResponse as any ? (sizesResponse as any).sizes : []);
|
||||
setSizes(sizes);
|
||||
} else {
|
||||
setSizes([]);
|
||||
}
|
||||
|
||||
// Преобразуем коллекции если не массив
|
||||
if (collectionsResponse) {
|
||||
const collections = Array.isArray(collectionsResponse)
|
||||
? collectionsResponse
|
||||
: ('collections' in collectionsResponse as any ? (collectionsResponse as any).collections : []);
|
||||
setCollections(collections);
|
||||
} else {
|
||||
setCollections([]);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, "Не удалось загрузить данные");
|
||||
// Устанавливаем пустые массивы в случае ошибки
|
||||
@ -85,7 +129,7 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
description: formData.description,
|
||||
price: parseFloat(formData.price),
|
||||
discount_price: formData.discount_price ? parseFloat(formData.discount_price) : undefined,
|
||||
care_instructions: formData.care_instructions,
|
||||
care_instructions: formData.care_instructions || {},
|
||||
is_active: formData.is_active,
|
||||
category_id: formData.category_id ? parseInt(formData.category_id) : undefined,
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id) : undefined,
|
||||
@ -93,35 +137,56 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
// Обработка вариантов товара
|
||||
variants: formData.variants?.map((variant: any) => ({
|
||||
id: variant.id, // если id есть - будет обновление, если нет - создание
|
||||
size_id: variant.size_id,
|
||||
size_id: parseInt(variant.size_id),
|
||||
sku: variant.sku || '',
|
||||
stock: variant.stock || 0,
|
||||
stock: parseInt(variant.stock) || 0,
|
||||
is_active: variant.is_active !== false
|
||||
})),
|
||||
|
||||
// Добавляем список вариантов для удаления
|
||||
variants_to_remove: formData.variantsToRemove,
|
||||
// Добавляем список вариантов для удаления (преобразуем в массив чисел)
|
||||
variants_to_remove: Array.isArray(formData.variantsToRemove)
|
||||
? formData.variantsToRemove.map((id: any) => parseInt(id))
|
||||
: [],
|
||||
|
||||
// Добавляем список обновленных изображений
|
||||
images: formData.images?.filter((img: any) => img.id).map((img: any) => ({
|
||||
id: img.id,
|
||||
id: parseInt(img.id),
|
||||
image_url: img.image_url,
|
||||
alt_text: img.alt_text || '',
|
||||
is_primary: img.is_primary || false
|
||||
})),
|
||||
|
||||
// Добавляем список изображений для удаления
|
||||
images_to_remove: formData.imagesToRemove
|
||||
// Добавляем список изображений для удаления (преобразуем в массив чисел)
|
||||
images_to_remove: Array.isArray(formData.imagesToRemove)
|
||||
? formData.imagesToRemove.map((id: any) => parseInt(id))
|
||||
: []
|
||||
};
|
||||
|
||||
console.log('Отправка запроса на комплексное обновление товара:', updateData);
|
||||
|
||||
try {
|
||||
const updatedProduct = await catalogService.updateProductComplete(productId, updateData);
|
||||
// Отправляем запрос напрямую с помощью api вместо сервиса
|
||||
const response = await api.put(`/catalog/products/${productId}/complete`, updateData);
|
||||
console.log('Ответ API при обновлении товара:', response);
|
||||
|
||||
let updatedProduct: ProductDetails | null = null;
|
||||
|
||||
// Проверяем структуру ответа
|
||||
if (response && typeof response === 'object') {
|
||||
// Формат { success, product }
|
||||
if ('success' in response && response.success && 'product' in response) {
|
||||
updatedProduct = response.product as ProductDetails;
|
||||
}
|
||||
// Прямой ответ с продуктом
|
||||
else if ('id' in response && 'name' in response) {
|
||||
updatedProduct = response as ProductDetails;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedProduct) {
|
||||
throw new Error('Не удалось обновить товар');
|
||||
throw new Error('Не удалось обновить товар: неверный формат ответа от сервера');
|
||||
}
|
||||
|
||||
console.log('Товар успешно обновлен:', updatedProduct);
|
||||
|
||||
// Загрузка локальных изображений отдельно
|
||||
@ -134,7 +199,20 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
|
||||
// Делаем изображение основным, только если нет других изображений
|
||||
const isPrimary = i === 0 && (!updatedProduct.images || updatedProduct.images.length === 0);
|
||||
await catalogService.uploadProductImage(productId, file, isPrimary);
|
||||
|
||||
// Создаем FormData для загрузки файла
|
||||
const imageFormData = new FormData();
|
||||
imageFormData.append('file', file);
|
||||
imageFormData.append('is_primary', isPrimary ? 'true' : 'false');
|
||||
|
||||
// Отправляем запрос напрямую с помощью api
|
||||
const imageResponse = await api.post(`/catalog/products/${productId}/images`, imageFormData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Ответ API при загрузке изображения ${i + 1}:`, imageResponse);
|
||||
|
||||
// Добавляем небольшую задержку между загрузками
|
||||
if (i < formData.localImages.length - 1) {
|
||||
@ -150,9 +228,51 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
toast.success('Товар успешно обновлен');
|
||||
|
||||
// Перезагружаем товар, чтобы отобразить актуальные данные
|
||||
const refreshedProduct = await catalogService.getProductById(productId);
|
||||
if (refreshedProduct) {
|
||||
setProduct(refreshedProduct);
|
||||
try {
|
||||
console.log('Перезагрузка данных продукта после обновления');
|
||||
|
||||
// Запрашиваем продукт напрямую с помощью api вместо сервиса
|
||||
const refreshResponse = await api.get(`/catalog/products/${productId}`);
|
||||
console.log('Ответ API при перезагрузке товара:', refreshResponse);
|
||||
|
||||
let refreshedProduct: ProductDetails | null = null;
|
||||
|
||||
// Проверяем структуру ответа
|
||||
if (refreshResponse && typeof refreshResponse === 'object') {
|
||||
// Формат { success, product }
|
||||
if ('success' in refreshResponse && refreshResponse.success && 'product' in refreshResponse) {
|
||||
refreshedProduct = refreshResponse.product as ProductDetails;
|
||||
}
|
||||
// Прямой ответ с продуктом
|
||||
else if ('id' in refreshResponse && 'name' in refreshResponse) {
|
||||
refreshedProduct = refreshResponse as ProductDetails;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Полученные обновленные данные:', refreshedProduct);
|
||||
|
||||
if (refreshedProduct) {
|
||||
// Обрабатываем изображения перед установкой в состояние
|
||||
if (refreshedProduct.images && Array.isArray(refreshedProduct.images)) {
|
||||
refreshedProduct.images = refreshedProduct.images.map((image: ProductImage) => ({
|
||||
...image,
|
||||
image_url: normalizeProductImage(image.image_url)
|
||||
}));
|
||||
}
|
||||
|
||||
// Устанавливаем primary_image если его нет
|
||||
if (!refreshedProduct.primary_image && refreshedProduct.images && refreshedProduct.images.length > 0) {
|
||||
const primaryImage = refreshedProduct.images.find(img => img.is_primary);
|
||||
refreshedProduct.primary_image = primaryImage ? primaryImage.image_url : refreshedProduct.images[0].image_url;
|
||||
}
|
||||
|
||||
setProduct(refreshedProduct);
|
||||
} else {
|
||||
console.warn('Не удалось получить обновленные данные продукта');
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Ошибка при перезагрузке данных продукта:', refreshError);
|
||||
// Не показываем ошибку пользователю, так как обновление уже прошло успешно
|
||||
}
|
||||
} catch (apiErr: any) {
|
||||
console.error('Ошибка при API-запросе:', apiErr);
|
||||
|
||||
@ -1,12 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import ProductManager from '@/components/admin/ProductManager';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ProductManagerOptimized from '@/components/admin/ProductManagerOptimized';
|
||||
import catalogAdminService, { Category as AdminCategory } from '@/lib/catalog-admin';
|
||||
import { Category, Collection } from '@/lib/catalog';
|
||||
import { AdminPageContainer } from '@/components/admin/AdminPageContainer';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminPageHeader';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [categoriesData, collectionsData] = await Promise.all([
|
||||
catalogAdminService.getCategories(),
|
||||
catalogAdminService.getCollections(),
|
||||
]);
|
||||
|
||||
// Проверяем, что данные являются массивами
|
||||
if (!Array.isArray(categoriesData) || !Array.isArray(collectionsData)) {
|
||||
console.error('Unexpected data format:', { categoriesData, collectionsData });
|
||||
return;
|
||||
}
|
||||
|
||||
// Рекурсивная функция для преобразования категорий
|
||||
const transformCategory = (cat: AdminCategory): Category => ({
|
||||
...cat,
|
||||
description: cat.description || undefined,
|
||||
subcategories: cat.subcategories?.map(transformCategory) || []
|
||||
});
|
||||
|
||||
setCategories(categoriesData.map(transformCategory));
|
||||
setCollections(collectionsData.map(col => ({
|
||||
...col,
|
||||
description: col.description || undefined
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Управление товарами</h1>
|
||||
<ProductManager />
|
||||
</div>
|
||||
<AdminPageContainer>
|
||||
<AdminPageHeader
|
||||
title="Управление товарами"
|
||||
description="Создание, редактирование и удаление товаров"
|
||||
/>
|
||||
<ProductManagerOptimized categories={categories} collections={collections} />
|
||||
</AdminPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { Toaster } from "@/components/ui/toaster"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import { WishlistProvider } from "@/hooks/use-wishlist"
|
||||
import { AuthProvider } from "@/lib/auth"
|
||||
import { ReactNode } from 'react'
|
||||
import Providers from '@/providers/Providers'
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
@ -24,31 +26,33 @@ import { LazyBgLoader } from "./LazyBgLoader";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} ${playfair.variable} font-sans antialiased`}>
|
||||
<LazyBgLoader />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
themes={['light', 'dark']}
|
||||
value={{
|
||||
light: 'light',
|
||||
dark: 'dark'
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<WishlistProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WishlistProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Providers>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
themes={['light', 'dark']}
|
||||
value={{
|
||||
light: 'light',
|
||||
dark: 'dark'
|
||||
}}
|
||||
>
|
||||
<AuthProvider>
|
||||
<WishlistProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WishlistProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
BIN
frontend/certbot/.DS_Store
vendored
BIN
frontend/certbot/.DS_Store
vendored
Binary file not shown.
BIN
frontend/components/.DS_Store
vendored
BIN
frontend/components/.DS_Store
vendored
Binary file not shown.
268
frontend/components/admin/CategoryTree.tsx
Normal file
268
frontend/components/admin/CategoryTree.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, FolderTree, Edit, Trash, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
import useCategoriesCache from '@/hooks/useCategoriesCache';
|
||||
import AdminLoadingState from '@/components/admin/AdminLoadingState';
|
||||
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
|
||||
|
||||
interface CategoryTreeProps {
|
||||
onSelectCategory?: (category: Category) => void;
|
||||
onEditCategory?: (category: Category) => void;
|
||||
onDeleteCategory?: (category: Category) => void;
|
||||
onAddCategory?: (parentId: number | null) => void;
|
||||
showActions?: boolean;
|
||||
showCounts?: boolean;
|
||||
expandAll?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryNodeProps {
|
||||
category: Category & { children?: Category[] };
|
||||
level: number;
|
||||
onSelect?: (category: Category) => void;
|
||||
onEdit?: (category: Category) => void;
|
||||
onDelete?: (category: Category) => void;
|
||||
onAdd?: (parentId: number) => void;
|
||||
showActions?: boolean;
|
||||
showCounts?: boolean;
|
||||
expandedNodes: Set<number>;
|
||||
toggleNode: (id: number) => void;
|
||||
}
|
||||
|
||||
// Компонент для отображения узла дерева категорий
|
||||
const CategoryNode = ({
|
||||
category,
|
||||
level,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAdd,
|
||||
showActions = true,
|
||||
showCounts = true,
|
||||
expandedNodes,
|
||||
toggleNode
|
||||
}: CategoryNodeProps) => {
|
||||
const hasChildren = category.children && category.children.length > 0;
|
||||
const isExpanded = expandedNodes.has(category.id);
|
||||
|
||||
// Обработчики событий
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleNode(category.id);
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
if (onSelect) onSelect(category);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEdit) onEdit(category);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onDelete) onDelete(category);
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onAdd) onAdd(category.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="category-node">
|
||||
<div
|
||||
className={`flex items-center py-2 px-2 rounded-md hover:bg-gray-100 cursor-pointer ${level === 0 ? 'font-medium' : ''}`}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button onClick={handleToggle} className="mr-1 focus:outline-none">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 h-4 mr-1"></span>
|
||||
)}
|
||||
|
||||
<FolderTree size={16} className="mr-2 text-gray-500" />
|
||||
|
||||
<span className="flex-1">{category.name}</span>
|
||||
|
||||
{showCounts && category.products_count !== undefined && (
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{category.products_count}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" onClick={handleAdd} className="h-7 w-7">
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleEdit} className="h-7 w-7">
|
||||
<Edit size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleDelete} className="h-7 w-7 text-red-500">
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="category-children">
|
||||
{category.children!.map(child => (
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
category={child}
|
||||
level={level + 1}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAdd={onAdd}
|
||||
showActions={showActions}
|
||||
showCounts={showCounts}
|
||||
expandedNodes={expandedNodes}
|
||||
toggleNode={toggleNode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Основной компонент дерева категорий
|
||||
export function CategoryTree({
|
||||
onSelectCategory,
|
||||
onEditCategory,
|
||||
onDeleteCategory,
|
||||
onAddCategory,
|
||||
showActions = true,
|
||||
showCounts = true,
|
||||
expandAll = false
|
||||
}: CategoryTreeProps) {
|
||||
// Получаем категории с использованием хука
|
||||
const {
|
||||
categories,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
getCategoryTree
|
||||
} = useCategoriesCache();
|
||||
|
||||
// Состояние для отслеживания развернутых узлов
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
|
||||
// Функция для переключения состояния узла
|
||||
const toggleNode = useCallback((id: number) => {
|
||||
setExpandedNodes(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Функция для разворачивания всех узлов
|
||||
const expandAllNodes = useCallback(() => {
|
||||
const allIds = new Set<number>();
|
||||
const addAllIds = (cats: Category[]) => {
|
||||
cats.forEach(cat => {
|
||||
allIds.add(cat.id);
|
||||
if (cat.children && cat.children.length > 0) {
|
||||
addAllIds(cat.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
addAllIds(getCategoryTree());
|
||||
setExpandedNodes(allIds);
|
||||
}, [getCategoryTree]);
|
||||
|
||||
// Функция для сворачивания всех узлов
|
||||
const collapseAllNodes = useCallback(() => {
|
||||
setExpandedNodes(new Set());
|
||||
}, []);
|
||||
|
||||
// Разворачиваем все узлы при первой загрузке, если нужно
|
||||
useState(() => {
|
||||
if (expandAll && categories.length > 0) {
|
||||
expandAllNodes();
|
||||
}
|
||||
});
|
||||
|
||||
// Если данные загружаются, показываем индикатор загрузки
|
||||
if (isLoading) {
|
||||
return <AdminLoadingState message="Загрузка категорий..." />;
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение об ошибке
|
||||
if (error) {
|
||||
return (
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки категорий"
|
||||
message={String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Получаем дерево категорий
|
||||
const categoryTree = getCategoryTree();
|
||||
|
||||
return (
|
||||
<div className="category-tree">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Категории</h3>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={expandAllNodes}>
|
||||
Развернуть все
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={collapseAllNodes}>
|
||||
Свернуть все
|
||||
</Button>
|
||||
{onAddCategory && (
|
||||
<Button size="sm" onClick={() => onAddCategory(null)}>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Добавить корневую
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
{categoryTree.length > 0 ? (
|
||||
categoryTree.map(category => (
|
||||
<CategoryNode
|
||||
key={category.id}
|
||||
category={category}
|
||||
level={0}
|
||||
onSelect={onSelectCategory}
|
||||
onEdit={onEditCategory}
|
||||
onDelete={onDeleteCategory}
|
||||
onAdd={onAddCategory}
|
||||
showActions={showActions}
|
||||
showCounts={showCounts}
|
||||
expandedNodes={expandedNodes}
|
||||
toggleNode={toggleNode}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Категории не найдены
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryTree;
|
||||
316
frontend/components/admin/OrderManagerOptimized.tsx
Normal file
316
frontend/components/admin/OrderManagerOptimized.tsx
Normal file
@ -0,0 +1,316 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { Calendar as CalendarIcon, Search, Filter, RefreshCw, Eye, Edit } 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 { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Order } from '@/lib/orders';
|
||||
import useOrdersCache from '@/hooks/useOrdersCache';
|
||||
import AdminErrorAlert from '@/components/admin/AdminErrorAlert';
|
||||
import AdminLoadingState from '@/components/admin/AdminLoadingState';
|
||||
|
||||
interface OrderManagerProps {
|
||||
pageSize?: number;
|
||||
showFilters?: boolean;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export default function OrderManagerOptimized({
|
||||
pageSize = 10,
|
||||
showFilters = true,
|
||||
showSearch = true
|
||||
}: OrderManagerProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Состояние компонента
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([null, null]);
|
||||
|
||||
// Используем хук для получения заказов
|
||||
const {
|
||||
orders,
|
||||
totalOrders,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
getStatusLabel,
|
||||
getStatusClass,
|
||||
formatDate,
|
||||
formatAmount
|
||||
} = useOrdersCache({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
status: statusFilter,
|
||||
search: searchQuery,
|
||||
dateRange
|
||||
});
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = useCallback(() => {
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// Обработчик изменения фильтра статуса
|
||||
const handleStatusFilterChange = useCallback((value: string) => {
|
||||
setStatusFilter(value === 'all' ? '' : value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// Обработчик изменения страницы
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setCurrentPage(page);
|
||||
}, []);
|
||||
|
||||
// Обработчик перехода на страницу просмотра заказа
|
||||
const handleViewOrder = useCallback((id: number) => {
|
||||
router.push(`/admin/orders/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// Обработчик перехода на страницу редактирования заказа
|
||||
const handleEditOrder = useCallback((id: number) => {
|
||||
router.push(`/admin/orders/${id}/edit`);
|
||||
}, [router]);
|
||||
|
||||
// Статусы заказов для фильтра
|
||||
const orderStatuses = useMemo(() => [
|
||||
{ value: 'pending', label: 'Ожидает оплаты' },
|
||||
{ value: 'paid', label: 'Оплачен' },
|
||||
{ value: 'processing', label: 'В обработке' },
|
||||
{ value: 'shipped', label: 'Отправлен' },
|
||||
{ value: 'delivered', label: 'Доставлен' },
|
||||
{ value: 'cancelled', label: 'Отменен' }
|
||||
], []);
|
||||
|
||||
// Если данные загружаются, показываем индикатор загрузки
|
||||
if (isLoading && !orders.length) {
|
||||
return <AdminLoadingState message="Загрузка заказов..." />;
|
||||
}
|
||||
|
||||
// Если произошла ошибка, показываем сообщение об ошибке
|
||||
if (error && !orders.length) {
|
||||
return (
|
||||
<AdminErrorAlert
|
||||
title="Ошибка загрузки заказов"
|
||||
message={String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Фильтры и поиск */}
|
||||
{(showFilters || showSearch) && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{showSearch && (
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<Input
|
||||
placeholder="Поиск заказов..."
|
||||
className="pl-10"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<>
|
||||
<div className="w-full md:w-48">
|
||||
<Select
|
||||
value={statusFilter || 'all'}
|
||||
onValueChange={handleStatusFilterChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все статусы</SelectItem>
|
||||
{orderStatuses.map((status) => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left font-normal"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange[0] && dateRange[1] ? (
|
||||
<>
|
||||
{format(dateRange[0], 'dd.MM.yyyy', { locale: ru })} - {format(dateRange[1], 'dd.MM.yyyy', { locale: ru })}
|
||||
</>
|
||||
) : (
|
||||
<span>Выберите период</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={dateRange as [Date, Date]}
|
||||
onSelect={(range) => setDateRange(range)}
|
||||
initialFocus
|
||||
locale={ru}
|
||||
/>
|
||||
<div className="p-3 border-t border-border flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDateRange([null, null])}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSearch()}
|
||||
>
|
||||
Применить
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSearch} className="md:w-auto">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Применить
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={() => refetch()} className="md:w-auto">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Таблица заказов */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Заказы</CardTitle>
|
||||
<CardDescription>
|
||||
Всего заказов: {totalOrders}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-3 px-4 text-left font-medium">ID</th>
|
||||
<th className="py-3 px-4 text-left font-medium">Клиент</th>
|
||||
<th className="py-3 px-4 text-left font-medium">Дата</th>
|
||||
<th className="py-3 px-4 text-left font-medium">Статус</th>
|
||||
<th className="py-3 px-4 text-left font-medium">Сумма</th>
|
||||
<th className="py-3 px-4 text-right font-medium">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
#{order.id}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{order.user_name || `Пользователь #${order.user_id}`}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{formatDate(order.created_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge className={cn(getStatusClass(order.status))}>
|
||||
{getStatusLabel(order.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{order.total !== undefined && order.total !== null
|
||||
? formatAmount(order.total)
|
||||
: 'Н/Д'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleViewOrder(order.id)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleEditOrder(order.id)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-6 text-center text-gray-500">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Вперед
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
frontend/components/admin/ProductManagerOptimized.tsx
Normal file
308
frontend/components/admin/ProductManagerOptimized.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Plus, Trash, Edit, Eye, MoreHorizontal } from 'lucide-react';
|
||||
import { useProducts } from '@/hooks/useProducts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { Category, Collection, Product, getImageUrl } from '@/lib/catalog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ProductManagerProps {
|
||||
categories?: Category[];
|
||||
collections?: Collection[];
|
||||
}
|
||||
|
||||
export default function ProductManagerOptimized({ categories = [], collections = [] }: ProductManagerProps) {
|
||||
const {
|
||||
products,
|
||||
loading,
|
||||
error,
|
||||
selectedProducts,
|
||||
pagination,
|
||||
filters,
|
||||
deleteProducts,
|
||||
setFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
selectProducts
|
||||
} = useProducts();
|
||||
|
||||
// Функция для удаления одного продукта
|
||||
const handleDeleteSingle = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот продукт?')) {
|
||||
await deleteProducts([id]);
|
||||
}
|
||||
};
|
||||
|
||||
// Мемоизированные колонки таблицы
|
||||
const columns = useMemo<ColumnDef<Product>[]>(() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Выбрать все"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Выбрать строку"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.id}</span>,
|
||||
size: 70
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
header: 'Фото',
|
||||
cell: ({ row }) => (
|
||||
<div className="relative w-12 h-12 overflow-hidden rounded-md border">
|
||||
<Image
|
||||
src={getImageUrl(row.original.primary_image) || '/placeholder.jpg'}
|
||||
alt={row.original.name}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 80
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Название',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/admin/products/${row.original.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.getValue('name')}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Цена',
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">
|
||||
{new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB'
|
||||
}).format(row.getValue('price'))}
|
||||
{row.original.discount_price && (
|
||||
<div className="text-xs text-gray-500 line-through">
|
||||
{new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB'
|
||||
}).format(row.original.discount_price)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: 'category_name',
|
||||
header: 'Категория',
|
||||
cell: ({ row }) => {
|
||||
const categoryId = row.original.category_id;
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
return category?.name || `Категория #${categoryId}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'collection_name',
|
||||
header: 'Коллекция',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.collection_id) return <span className="text-gray-400">—</span>;
|
||||
const collection = collections.find(c => c.id === row.original.collection_id);
|
||||
return collection?.name || `Коллекция #${row.original.collection_id}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Активен',
|
||||
cell: ({ row }) => (
|
||||
<span className={cn(
|
||||
'px-2 py-1 rounded-full text-xs font-medium',
|
||||
row.getValue('is_active')
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
)}>
|
||||
{row.getValue('is_active') ? 'Да' : 'Нет'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Действия',
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Открыть меню</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Действия</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/products/${row.original.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Редактировать
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/catalog/${row.original.slug}`} target="_blank">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Просмотреть на сайте
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteSingle(row.original.id)}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
], [categories, collections, handleDeleteSingle]);
|
||||
|
||||
// Обработчик удаления выбранных продуктов
|
||||
const handleDelete = async () => {
|
||||
if (selectedProducts.length === 0) return;
|
||||
|
||||
if (window.confirm('Вы уверены, что хотите удалить выбранные продукты?')) {
|
||||
await deleteProducts(selectedProducts);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-red-500">
|
||||
Произошла ошибка при загрузке продуктов: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Управление продуктами</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedProducts.length === 0 || loading.delete}
|
||||
>
|
||||
<Trash className="w-4 h-4 mr-2" />
|
||||
Удалить выбранные
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/admin/products/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Добавить продукт
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Input
|
||||
placeholder="Поиск по названию..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilter('search', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={filters.category || "all"}
|
||||
onValueChange={(value) => setFilter('category', value === "all" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Все категории" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все категории</SelectItem>
|
||||
{categories?.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.collection || "all"}
|
||||
onValueChange={(value) => setFilter('collection', value === "all" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Все коллекции" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все коллекции</SelectItem>
|
||||
{collections?.map((collection) => (
|
||||
<SelectItem key={collection.id} value={collection.id.toString()}>
|
||||
{collection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.active?.toString() || "all"}
|
||||
onValueChange={(value) => setFilter('active', value === "all" ? null : value === "true")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все</SelectItem>
|
||||
<SelectItem value="true">Активные</SelectItem>
|
||||
<SelectItem value="false">Неактивные</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable<Product>
|
||||
columns={columns}
|
||||
data={products}
|
||||
loading={loading.fetch}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: Math.ceil(pagination.total / pagination.pageSize),
|
||||
onPageChange: (pageIndex) => setPage(pageIndex + 1),
|
||||
onPageSizeChange: setPageSize
|
||||
}}
|
||||
selection={{
|
||||
selectedRowIds: selectedProducts,
|
||||
onSelectionChange: selectProducts
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShippingAddress } from '@/types/order';
|
||||
|
||||
const addressSchema = z.object({
|
||||
address_line1: z.string().min(5, 'Адрес должен быть не менее 5 символов'),
|
||||
address_line2: z.string().optional(),
|
||||
city: z.string().min(2, 'Укажите город'),
|
||||
state: z.string().min(2, 'Укажите область/регион'),
|
||||
postal_code: z.string().min(5, 'Укажите почтовый индекс'),
|
||||
country: z.string().min(2, 'Укажите страну'),
|
||||
is_default: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type AddressFormData = z.infer<typeof addressSchema>;
|
||||
|
||||
interface AddressFormProps {
|
||||
onSubmit: (data: AddressFormData) => void;
|
||||
initialData?: ShippingAddress;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function AddressForm({ onSubmit, initialData, isSubmitting = false }: AddressFormProps) {
|
||||
const form = useForm<AddressFormData>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
defaultValues: initialData || {
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address_line1"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Адрес</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ул. Пушкина, д. 10" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address_line2"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Дополнительная информация</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Квартира, подъезд, этаж и т.д. (необязательно)" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Город</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Москва" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Область/Регион</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Московская область" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postal_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Индекс</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Страна</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Россия" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Сохранение...' : 'Сохранить адрес'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
251
frontend/components/checkout/address-autocomplete-input.tsx
Normal file
251
frontend/components/checkout/address-autocomplete-input.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, MapPin, X } from "lucide-react";
|
||||
|
||||
// Тип адреса (минимально необходимый)
|
||||
interface Address {
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment: string;
|
||||
postalCode: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AddressAutocompleteInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onAddressSelect?: (addr: Address) => void; // если нужно получить полный объект
|
||||
resetKey?: string | number;
|
||||
isSelected?: boolean; // Флаг, указывающий, что адрес уже выбран из подсказок
|
||||
}
|
||||
|
||||
const DADATA_TOKEN = "f727bf3d85b96d1b43a5e0d113d6fe016c83c5ff";
|
||||
const DADATA_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address";
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default function AddressAutocompleteInput({ value, onChange, onAddressSelect, resetKey, isSelected = false }: AddressAutocompleteInputProps) {
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalSelected, setInternalSelected] = useState(isSelected);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const debouncedQuery = useDebounce(value, 1000);
|
||||
|
||||
// Обновление внутреннего состояния выбора при изменении пропса
|
||||
useEffect(() => {
|
||||
setInternalSelected(isSelected);
|
||||
}, [isSelected]);
|
||||
|
||||
// Очистка подсказок при изменении ключа сброса
|
||||
useEffect(() => {
|
||||
setSuggestions([]);
|
||||
setError(null);
|
||||
setShowSuggestions(false);
|
||||
setInternalSelected(false);
|
||||
}, [resetKey]);
|
||||
|
||||
// Загрузка подсказок при изменении поискового запроса
|
||||
useEffect(() => {
|
||||
// Если адрес уже выбран или запрос короткий - не запрашиваем подсказки
|
||||
if (internalSelected || !debouncedQuery || debouncedQuery.length <= 2) {
|
||||
setSuggestions([]);
|
||||
setLoading(false);
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchSuggestions(debouncedQuery);
|
||||
}, [debouncedQuery, internalSelected]);
|
||||
|
||||
// Обработчик клика вне компонента для закрытия подсказок
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Запрос подсказок с сервера DaData
|
||||
const fetchSuggestions = async (q: string) => {
|
||||
try {
|
||||
const body = {
|
||||
query: q,
|
||||
count: 8,
|
||||
locations: [{ city: "Новокузнецк" }],
|
||||
language: "ru"
|
||||
};
|
||||
|
||||
const res = await fetch(DADATA_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Token ${DADATA_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ошибка DaData: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
setSuggestions(data.suggestions || []);
|
||||
setLoading(false);
|
||||
setShowSuggestions(true);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
setError("Ошибка загрузки подсказок");
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Выбор адреса из списка подсказок
|
||||
const handleSuggestionSelect = (suggestion: any) => {
|
||||
onChange(suggestion.value);
|
||||
setShowSuggestions(false);
|
||||
setInternalSelected(true); // Пометить адрес как выбранный
|
||||
|
||||
if (onAddressSelect) {
|
||||
const addr: Address = {
|
||||
city: suggestion.data.city || "Новокузнецк",
|
||||
street: suggestion.data.street_with_type || "",
|
||||
house: suggestion.data.house || "",
|
||||
apartment: suggestion.data.flat || "",
|
||||
postalCode: suggestion.data.postal_code || "",
|
||||
full: suggestion.value,
|
||||
fias_id: suggestion.data.fias_id,
|
||||
kladr_id: suggestion.data.kladr_id,
|
||||
geo_lat: suggestion.data.geo_lat,
|
||||
geo_lon: suggestion.data.geo_lon,
|
||||
};
|
||||
onAddressSelect(addr);
|
||||
}
|
||||
};
|
||||
|
||||
// Очистка поля ввода
|
||||
const handleClearInput = () => {
|
||||
onChange("");
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
setInternalSelected(false); // Сбросить флаг выбора
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Обработчик изменения ввода
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
|
||||
// Если пользователь изменяет текст после выбора, сбрасываем флаг выбора
|
||||
if (internalSelected) {
|
||||
setInternalSelected(false);
|
||||
}
|
||||
|
||||
if (newValue.length > 2) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
{/* Поле ввода */}
|
||||
<div className="relative w-full">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</span>
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="address-autocomplete"
|
||||
placeholder="Начните вводить адрес…"
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => {
|
||||
// Показываем подсказки при фокусе только если адрес не был выбран и есть текст
|
||||
if (!internalSelected && value && value.length > 2 && suggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
className={`pl-10 pr-10 py-2 w-full rounded-xl border ${
|
||||
error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-300"
|
||||
: "border-gray-300 focus:border-primary focus:ring-2 focus:ring-primary"
|
||||
} ${loading ? "animate-pulse" : ""}`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearInput}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Очистить поле"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Выпадающий список подсказок */}
|
||||
{showSuggestions && !internalSelected && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute z-50 mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-xl max-h-[280px] overflow-y-auto"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="animate-spin w-5 h-5 mr-2" />
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="text-red-500 text-sm px-4 py-3">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && suggestions.length === 0 && (
|
||||
<div className="py-3 text-center text-sm text-gray-500">Нет подсказок</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.value}
|
||||
className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => handleSuggestionSelect(suggestion)}
|
||||
>
|
||||
<div className="font-medium text-sm">{suggestion.value}</div>
|
||||
{suggestion.data.postal_code && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Индекс: {suggestion.data.postal_code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DeliveryMethod } from "@/app/(main)/checkout/page";
|
||||
import dynamic from "next/dynamic";
|
||||
import AddressAutocompleteInput from "./address-autocomplete-input";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
// Глобальный объект CDEKWidget добавляется скриптом
|
||||
declare global {
|
||||
interface Window {
|
||||
CDEKWidget?: any;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Типы CDEK (можно вынести в отдельный файл types/cdek.d.ts) ---
|
||||
type LngLat = [number, number];
|
||||
type DeliveryMode = "door" | "office";
|
||||
interface iOffice {
|
||||
city_code: number;
|
||||
city: string;
|
||||
region: string;
|
||||
type: string;
|
||||
country_code: string;
|
||||
postal_code: string;
|
||||
have_cashless: boolean;
|
||||
have_cash: boolean;
|
||||
allowed_cod: boolean;
|
||||
is_dressing_room: boolean;
|
||||
code: string;
|
||||
name: string;
|
||||
address: string;
|
||||
work_time: string;
|
||||
location: LngLat;
|
||||
dimensions: Array<{ depth: number; width: number; height: number }> | null;
|
||||
weight_min: number;
|
||||
weight_max: number;
|
||||
}
|
||||
interface iGeocoderMember {
|
||||
name: string;
|
||||
position: number[];
|
||||
kind: string;
|
||||
precision: string;
|
||||
formatted: string;
|
||||
country_code: string;
|
||||
postal_code: string | null;
|
||||
components: any[];
|
||||
bounds: { lower: number[]; upper: number[] };
|
||||
}
|
||||
interface iTariff {
|
||||
tariff_code: number;
|
||||
tariff_name: string;
|
||||
tariff_description: string;
|
||||
delivery_mode: number;
|
||||
period_min: number;
|
||||
period_max: number;
|
||||
delivery_sum: number;
|
||||
}
|
||||
type tChooseFunction = (type: DeliveryMode, tariff: iTariff | null, target: iOffice | iGeocoderMember) => void;
|
||||
type tCalculateFunction = (prices: { office: iTariff[]; door: iTariff[]; pickup: iTariff[] }, address: { code?: number; address?: string }) => void;
|
||||
type CdekPvz = iOffice | iGeocoderMember | null;
|
||||
// --- END Типы CDEK ---
|
||||
|
||||
interface Address {
|
||||
city: string;
|
||||
@ -11,132 +70,469 @@ interface Address {
|
||||
house: string;
|
||||
apartment: string;
|
||||
postalCode: string;
|
||||
cdekPvz?: CdekPvz;
|
||||
cdekTariff?: iTariff | null;
|
||||
cdekDeliveryType?: DeliveryMode;
|
||||
formattedAddress?: string; // Полный форматированный адрес для автозаполнения
|
||||
geo_lat?: string; // Координаты для геолокации
|
||||
geo_lon?: string;
|
||||
fias_id?: string; // Идентификаторы ФИАС/КЛАДР
|
||||
kladr_id?: string;
|
||||
full?: string; // Полное представление адреса из DaData
|
||||
}
|
||||
|
||||
interface AddressFormProps {
|
||||
address: Address;
|
||||
setAddress: (address: Address) => void;
|
||||
deliveryMethod: DeliveryMethod;
|
||||
goods: { length: number; width: number; height: number; weight: number }[];
|
||||
}
|
||||
|
||||
export default function AddressForm({ address, setAddress, deliveryMethod }: AddressFormProps) {
|
||||
// Создаем локальное состояние для обработки формы
|
||||
const [localAddress, setLocalAddress] = useState(address);
|
||||
|
||||
// Обновляем родительский компонент при изменении локального состояния
|
||||
// --- CDEK Widget popup-обёртка (динамический импорт, ssr: false) ---
|
||||
// Используем глобальную переменную для хранения компонента между рендерами
|
||||
// @ts-ignore
|
||||
const CdekWidgetPopup = dynamic(() => import("./cdek-widget-popup"), {
|
||||
ssr: false,
|
||||
// Важно: используем loading для предотвращения мерцания при загрузке
|
||||
loading: () => <div className="text-sm text-gray-500">Загрузка виджета СДЭК...</div>
|
||||
});
|
||||
|
||||
const YANDEX_API_KEY = process.env.NEXT_PUBLIC_YANDEX_MAPS_KEY || "46c9d016-fad5-489c-86d5-56ce393e0344";
|
||||
const SERVICE_PATH = "http://localhost:8081/service.php";
|
||||
|
||||
// Задержка debounce для поля ввода города (в миллисекундах)
|
||||
const CITY_DEBOUNCE_DELAY = 500;
|
||||
|
||||
export default function AddressForm({ address, setAddress, deliveryMethod, goods }: AddressFormProps) {
|
||||
const [localAddress, setLocalAddress] = useState<Address>({ ...address, cdekPvz: address.cdekPvz || null });
|
||||
const [deliveryInfo, setDeliveryInfo] = useState<{ price?: number; time?: number; tariff?: iTariff | null }>({});
|
||||
const [resetKey, setResetKey] = useState<string>(uuidv4());
|
||||
const [autocompleteValue, setAutocompleteValue] = useState<string>(localAddress.formattedAddress || "");
|
||||
const [isAddressSelected, setIsAddressSelected] = useState<boolean>(!!localAddress.formattedAddress);
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
const prevCity = useRef(localAddress.city);
|
||||
const lastCdekServiceCall = useRef<number>(0);
|
||||
const cdekServiceThrottleMs = 2000; // Минимальный интервал между вызовами сервиса (2 секунды)
|
||||
|
||||
// Состояние для поля ввода города (обновляется мгновенно)
|
||||
const [cityInputValue, setCityInputValue] = useState<string>(localAddress.city);
|
||||
|
||||
// Передача данных в родительский компонент
|
||||
useEffect(() => {
|
||||
console.log('[AddressForm] Передача данных в родительский компонент:', localAddress);
|
||||
setAddress(localAddress);
|
||||
}, [localAddress, setAddress]);
|
||||
|
||||
// Обработчик изменения полей формы
|
||||
|
||||
// Проверяем наличие пункта выдачи CDEK
|
||||
if (deliveryMethod === 'cdek') {
|
||||
console.log('[AddressForm] Проверка наличия пункта выдачи CDEK:', {
|
||||
hasCdekPvz: !!localAddress.cdekPvz,
|
||||
cdekDeliveryType: localAddress.cdekDeliveryType
|
||||
});
|
||||
}
|
||||
}, [localAddress, setAddress, deliveryMethod]);
|
||||
|
||||
// Сброс ПВЗ при смене города
|
||||
useEffect(() => {
|
||||
if (prevCity.current !== localAddress.city) {
|
||||
// При смене города сбрасываем ПВЗ
|
||||
setLocalAddress(prev => ({ ...prev, cdekPvz: null, cdekTariff: null, cdekDeliveryType: undefined }));
|
||||
prevCity.current = localAddress.city;
|
||||
}
|
||||
}, [localAddress.city]);
|
||||
|
||||
// Сброс полей при смене способа доставки
|
||||
useEffect(() => {
|
||||
// Не сбрасываем resetKey при каждой смене способа доставки,
|
||||
// чтобы избежать повторной инициализации компонентов
|
||||
|
||||
// При смене способа доставки сбрасываем только некоторые поля, сохраняя город
|
||||
if (deliveryMethod === "cdek") {
|
||||
// Синхронизируем значение инпута с текущим городом в адресе
|
||||
setCityInputValue(localAddress.city);
|
||||
|
||||
// Сбрасываем поля адреса, но только если они заполнены
|
||||
if (localAddress.street || localAddress.house || localAddress.apartment || localAddress.postalCode || localAddress.formattedAddress) {
|
||||
setLocalAddress(prev => ({
|
||||
...prev,
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: "",
|
||||
formattedAddress: ""
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// При переходе на курьерскую доставку сбрасываем ПВЗ, но только если он выбран
|
||||
if (localAddress.cdekPvz) {
|
||||
setLocalAddress(prev => ({
|
||||
...prev,
|
||||
cdekPvz: null,
|
||||
cdekTariff: null,
|
||||
cdekDeliveryType: undefined
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Сбрасываем поля автозаполнения только при переходе на курьерскую доставку
|
||||
if (deliveryMethod === "courier") {
|
||||
setAutocompleteValue("");
|
||||
setIsAddressSelected(false);
|
||||
// Генерируем новый ключ только для компонента автозаполнения
|
||||
setResetKey(uuidv4());
|
||||
}
|
||||
|
||||
setAddressError(null);
|
||||
}, [deliveryMethod, localAddress.city]);
|
||||
|
||||
// Сохранение данных в localStorage удалено, так как кеширование реализовано на бэкенде
|
||||
|
||||
// Debounce функция для обновления localAddress.city
|
||||
const debouncedSetCity = useCallback(
|
||||
debounce((value: string) => {
|
||||
setLocalAddress(prev => ({ ...prev, city: value }));
|
||||
}, CITY_DEBOUNCE_DELAY),
|
||||
[setLocalAddress] // Зависимость только от setLocalAddress
|
||||
);
|
||||
|
||||
// --- логирование ---
|
||||
useEffect(() => {
|
||||
// Выключаем логирование для уменьшения шума в консоли
|
||||
// console.log("[AddressForm] address updated:", localAddress);
|
||||
}, [localAddress]);
|
||||
|
||||
// Проверка адреса на корректность
|
||||
const validateAddress = (addr: Address): string | null => {
|
||||
if (!addr.city || addr.city.toLowerCase() !== "новокузнецк") {
|
||||
return "Доставка возможна только по городу Новокузнецк";
|
||||
}
|
||||
|
||||
if (!addr.street || addr.street.trim() === "") {
|
||||
return "В адресе не указана улица";
|
||||
}
|
||||
|
||||
if (!addr.house || addr.house.trim() === "") {
|
||||
return "В адресе не указан номер дома";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setLocalAddress(prev => ({ ...prev, [name]: value }));
|
||||
if (name === 'city') {
|
||||
setCityInputValue(value); // Обновляем локальное состояние инпута мгновенно
|
||||
debouncedSetCity(value); // Вызываем debounce для обновления основного состояния
|
||||
} else {
|
||||
setLocalAddress(prev => ({ ...prev, [name]: value })); // Обновляем остальные поля напрямую
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutocompleteChange = (value: string) => {
|
||||
setAutocompleteValue(value);
|
||||
|
||||
// Если пользователь очистил поле, также очищаем сохраненный адрес
|
||||
if (!value) {
|
||||
setLocalAddress(prev => ({
|
||||
...prev,
|
||||
formattedAddress: "",
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: "",
|
||||
geo_lat: undefined,
|
||||
geo_lon: undefined
|
||||
}));
|
||||
setIsAddressSelected(false);
|
||||
setAddressError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressSelect = (selectedAddress: Address) => {
|
||||
// Проверка адреса на корректность
|
||||
const error = validateAddress(selectedAddress);
|
||||
setAddressError(error);
|
||||
|
||||
// Если есть ошибка, не обновляем адрес
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработка выбора адреса из подсказок
|
||||
setLocalAddress(prev => ({
|
||||
...prev,
|
||||
city: selectedAddress.city || "Новокузнецк",
|
||||
street: selectedAddress.street || "",
|
||||
house: selectedAddress.house || "",
|
||||
apartment: selectedAddress.apartment || "",
|
||||
postalCode: selectedAddress.postalCode || "",
|
||||
formattedAddress: selectedAddress.full || "",
|
||||
geo_lat: selectedAddress.geo_lat,
|
||||
geo_lon: selectedAddress.geo_lon,
|
||||
fias_id: selectedAddress.fias_id,
|
||||
kladr_id: selectedAddress.kladr_id
|
||||
}));
|
||||
setIsAddressSelected(true);
|
||||
// Обновляем значение инпута города, если адрес выбран из автокомплита
|
||||
if (selectedAddress.city) {
|
||||
setCityInputValue(selectedAddress.city);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCdekChoose: tChooseFunction = (type, tariff, target) => {
|
||||
console.log('[CDEK] Выбран ПВЗ:', { type, tariff, target });
|
||||
|
||||
if (!target) {
|
||||
console.error('[CDEK] Выбран пустой target');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем выбранный ПВЗ
|
||||
setLocalAddress(prev => {
|
||||
const newAddress = {
|
||||
...prev,
|
||||
cdekPvz: target,
|
||||
cdekTariff: tariff,
|
||||
cdekDeliveryType: type,
|
||||
};
|
||||
|
||||
// Логируем обновленный адрес
|
||||
console.log('[CDEK] Обновлен адрес:', newAddress);
|
||||
|
||||
return newAddress;
|
||||
});
|
||||
|
||||
// Сбрасываем ошибки, если они были
|
||||
setAddressError(null);
|
||||
|
||||
// Обновляем время последнего вызова сервиса
|
||||
lastCdekServiceCall.current = Date.now();
|
||||
|
||||
// Дополнительное логирование для отладки
|
||||
setTimeout(() => {
|
||||
console.log('[CDEK] Текущее состояние localAddress после выбора ПВЗ:', localAddress);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCdekCalculate: tCalculateFunction = (prices, addressInfo) => {
|
||||
// Проверяем, не слишком ли часто вызывается сервис
|
||||
const now = Date.now();
|
||||
if (now - lastCdekServiceCall.current < cdekServiceThrottleMs) {
|
||||
// console.log(`[CDEK] Пропускаем обработку расчета (throttle ${cdekServiceThrottleMs}ms)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем время последнего вызова
|
||||
lastCdekServiceCall.current = now;
|
||||
|
||||
// Если есть код города в адресе, можно использовать его для дополнительной логики
|
||||
const cityCode = addressInfo?.code;
|
||||
|
||||
if (prices.office && prices.office.length > 0) {
|
||||
setDeliveryInfo({
|
||||
price: prices.office[0].delivery_sum,
|
||||
time: prices.office[0].period_max,
|
||||
tariff: prices.office[0],
|
||||
});
|
||||
} else {
|
||||
console.warn('[CDEK] Нет тарифов для office' + (cityCode ? ` (код города: ${cityCode})` : ''));
|
||||
}
|
||||
};
|
||||
|
||||
const resetCdekSelection = () => {
|
||||
setLocalAddress(prev => ({ ...prev, cdekPvz: null, cdekTariff: null, cdekDeliveryType: undefined }));
|
||||
setDeliveryInfo({});
|
||||
|
||||
// Обновляем время последнего вызова для предотвращения мгновенного повтора
|
||||
lastCdekServiceCall.current = Date.now();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Город */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-sm font-medium">
|
||||
Город <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
value={localAddress.city}
|
||||
onChange={handleChange}
|
||||
placeholder="Название города"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary"
|
||||
defaultValue="Новокузнецк"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Улица */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="street" className="text-sm font-medium">
|
||||
Улица <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="street"
|
||||
name="street"
|
||||
value={localAddress.street}
|
||||
onChange={handleChange}
|
||||
placeholder="Название улицы"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Дом */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="house" className="text-sm font-medium">
|
||||
Дом <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="house"
|
||||
name="house"
|
||||
value={localAddress.house}
|
||||
onChange={handleChange}
|
||||
placeholder="Номер дома"
|
||||
required
|
||||
className="border-gray-300 focus:border-primary"
|
||||
/>
|
||||
{deliveryMethod === "courier" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address-autocomplete" className="text-sm font-medium">
|
||||
Адрес доставки <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Начните вводить адрес (например: "ул. Кирова 10"), выберите вариант из списка. Доставка только по Новокузнецку.
|
||||
</p>
|
||||
<AddressAutocompleteInput
|
||||
value={autocompleteValue}
|
||||
onChange={handleAutocompleteChange}
|
||||
onAddressSelect={handleAddressSelect}
|
||||
resetKey={resetKey}
|
||||
isSelected={isAddressSelected}
|
||||
/>
|
||||
|
||||
{/* Ошибка адреса */}
|
||||
{addressError && (
|
||||
<div className="mt-2 text-sm text-red-500">
|
||||
{addressError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localAddress.formattedAddress && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-sm font-medium">Информация о доставке:</p>
|
||||
<div className="text-sm mt-2">
|
||||
<p>Адрес: {localAddress.formattedAddress}</p>
|
||||
{localAddress.postalCode && <p>Индекс: {localAddress.postalCode}</p>}
|
||||
</div>
|
||||
|
||||
{/* Информационное сообщение о квартире */}
|
||||
{!localAddress.apartment && (
|
||||
<div className="mt-3 text-xs text-amber-600 bg-amber-50 p-2 rounded-md">
|
||||
<p>⚠️ В выбранном адресе не указан номер квартиры/офиса. Пожалуйста, сообщите номер квартиры курьеру при подтверждении заказа по телефону.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 text-xs text-red-500 underline"
|
||||
onClick={() => {
|
||||
setLocalAddress(prev => ({
|
||||
...prev,
|
||||
formattedAddress: "",
|
||||
street: "",
|
||||
house: "",
|
||||
apartment: "",
|
||||
postalCode: "",
|
||||
geo_lat: undefined,
|
||||
geo_lon: undefined
|
||||
}));
|
||||
setAutocompleteValue("");
|
||||
setIsAddressSelected(false);
|
||||
setAddressError(null);
|
||||
setResetKey(uuidv4()); // Сброс компонента автозаполнения
|
||||
}}
|
||||
>
|
||||
Изменить адрес
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
Курьер доставит заказ по указанному адресу в Новокузнецке. Время доставки будет согласовано по телефону.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Квартира/Офис */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apartment" className="text-sm font-medium">
|
||||
Квартира/Офис
|
||||
</Label>
|
||||
<Input
|
||||
id="apartment"
|
||||
name="apartment"
|
||||
value={localAddress.apartment}
|
||||
onChange={handleChange}
|
||||
placeholder="Номер квартиры/офиса"
|
||||
className="border-gray-300 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индекс - только для СДЭК */}
|
||||
{deliveryMethod === "cdek" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postalCode" className="text-sm font-medium">
|
||||
Почтовый индекс
|
||||
</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
value={localAddress.postalCode}
|
||||
onChange={handleChange}
|
||||
placeholder="Почтовый индекс"
|
||||
className="border-gray-300 focus:border-primary w-full md:w-1/2"
|
||||
maxLength={6}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Почтовый индекс поможет быстрее доставить заказ
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дополнительная информация для выбранного способа доставки */}
|
||||
{deliveryMethod === "cdek" ? (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
После оформления заказа вы сможете выбрать пункт выдачи СДЭК из списка доступных в вашем городе.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-700">
|
||||
Курьер доставит заказ по указанному адресу в Новокузнецке. Время доставки будет согласовано по телефону.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : deliveryMethod === "cdek" ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-md">
|
||||
<h3 className="text-sm font-medium mb-2">Доставка СДЭК</h3>
|
||||
<p className="text-xs text-gray-600 mb-4">
|
||||
Выберите пункт выдачи заказов СДЭК в вашем городе.
|
||||
Доставка возможна по всей России.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cdek_city" className="text-sm font-medium">Город</Label>
|
||||
<Input
|
||||
id="cdek_city"
|
||||
name="city"
|
||||
value={cityInputValue}
|
||||
onChange={handleChange}
|
||||
className="w-full"
|
||||
placeholder="Введите название города"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{CdekWidgetPopup && deliveryMethod === "cdek" && (
|
||||
<div className="space-y-4">
|
||||
<CdekWidgetPopup
|
||||
key="cdek-widget-popup" // Используем фиксированный ключ, чтобы компонент не пересоздавался
|
||||
yandexApiKey={YANDEX_API_KEY}
|
||||
servicePath={SERVICE_PATH}
|
||||
from={localAddress.city}
|
||||
defaultLocation={localAddress.city}
|
||||
goods={goods}
|
||||
onSelect={handleCdekChoose}
|
||||
onCalculate={handleCdekCalculate}
|
||||
debugMode={false} // Отключаем режим отладки в продакшене
|
||||
/>
|
||||
|
||||
{/* Информация о выбранном ПВЗ */}
|
||||
{localAddress.cdekPvz && (
|
||||
<div className="mt-4 p-4 bg-white border border-green-200 rounded-md">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-green-700 mb-1">Пункт выдачи выбран</h4>
|
||||
{(() => {
|
||||
if (!localAddress.cdekPvz) return null;
|
||||
|
||||
if ('code' in localAddress.cdekPvz && localAddress.cdekPvz.code) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm font-medium">{localAddress.cdekPvz.name || `ПВЗ ${localAddress.cdekPvz.code}`}</p>
|
||||
<p className="text-sm">{localAddress.cdekPvz.address}</p>
|
||||
{localAddress.cdekPvz.work_time && <p className="text-xs text-gray-600">Режим работы: {localAddress.cdekPvz.work_time}</p>}
|
||||
</>);
|
||||
} else if ('formatted' in localAddress.cdekPvz && localAddress.cdekPvz.formatted) {
|
||||
return (
|
||||
<p className="text-sm">{localAddress.cdekPvz.formatted}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <p className="text-sm">Адрес: {(localAddress.cdekPvz as any).address || 'Информация недоступна'}</p>;
|
||||
})()}
|
||||
|
||||
{deliveryInfo.price && (
|
||||
<p className="text-sm mt-2">Стоимость доставки: <span className="font-medium">{deliveryInfo.price} ₽</span></p>
|
||||
)}
|
||||
{deliveryInfo.time && (
|
||||
<p className="text-sm">Срок доставки: <span className="font-medium">{deliveryInfo.time} дн.</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600 hover:text-red-800 underline"
|
||||
onClick={resetCdekSelection}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!localAddress.cdekPvz && (
|
||||
<div className="mt-2 text-sm text-amber-600">
|
||||
<p className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938-9L12 3.5 19.225 9M5 20h14a2 2 0 002-2V9a2 2 0 00-.6-1.4L12 1 3.6 7.6A2 2 0 003 9v9a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Пожалуйста, выберите пункт выдачи СДЭК
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка для отладки - показывает текущее состояние адреса */}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 text-xs text-blue-600 underline"
|
||||
onClick={() => {
|
||||
console.log('[DEBUG] Текущее состояние адреса:', {
|
||||
localAddress,
|
||||
hasCdekPvz: !!localAddress.cdekPvz,
|
||||
cdekDeliveryType: localAddress.cdekDeliveryType
|
||||
});
|
||||
}}
|
||||
>
|
||||
Проверить состояние (отладка)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
698
frontend/components/checkout/cdek-widget-popup.tsx
Normal file
698
frontend/components/checkout/cdek-widget-popup.tsx
Normal file
@ -0,0 +1,698 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
// --- Типы CDEK (дублируем для изоляции, либо импортируй из types/cdek.d.ts если появится) ---
|
||||
type DeliveryMode = "door" | "office";
|
||||
interface iOffice {
|
||||
city_code: number;
|
||||
city: string;
|
||||
region: string;
|
||||
type: string;
|
||||
country_code: string;
|
||||
postal_code: string;
|
||||
have_cashless: boolean;
|
||||
have_cash: boolean;
|
||||
allowed_cod: boolean;
|
||||
is_dressing_room: boolean;
|
||||
code: string;
|
||||
name: string;
|
||||
address: string;
|
||||
work_time: string;
|
||||
location: [number, number];
|
||||
dimensions: Array<{ depth: number; width: number; height: number }> | null;
|
||||
weight_min: number;
|
||||
weight_max: number;
|
||||
}
|
||||
interface iGeocoderMember {
|
||||
name: string;
|
||||
position: number[];
|
||||
kind: string;
|
||||
precision: string;
|
||||
formatted: string;
|
||||
country_code: string;
|
||||
postal_code: string | null;
|
||||
components: any[];
|
||||
bounds: { lower: number[]; upper: number[] };
|
||||
}
|
||||
interface iTariff {
|
||||
tariff_code: number;
|
||||
tariff_name: string;
|
||||
tariff_description: string;
|
||||
delivery_mode: number;
|
||||
period_min: number;
|
||||
period_max: number;
|
||||
delivery_sum: number;
|
||||
}
|
||||
type tChooseFunction = (type: DeliveryMode, tariff: iTariff | null, target: iOffice | iGeocoderMember) => void;
|
||||
type tCalculateFunction = (prices: { office: iTariff[]; door: iTariff[]; pickup: iTariff[] }, address: { code?: number; address?: string }) => void;
|
||||
// --- END Типы CDEK ---
|
||||
|
||||
interface CdekWidgetPopupProps {
|
||||
yandexApiKey: string;
|
||||
servicePath: string;
|
||||
from: string;
|
||||
defaultLocation: string;
|
||||
goods: { length: number; width: number; height: number; weight: number }[];
|
||||
onSelect: tChooseFunction;
|
||||
onCalculate: tCalculateFunction;
|
||||
debugMode?: boolean; // Добавляем параметр для включения/выключения режима отладки
|
||||
}
|
||||
|
||||
// Удалены функции кеширования и связанные константы, так как кеширование реализовано на бэкенде
|
||||
|
||||
// Глобальная переменная для хранения экземпляра виджета между рендерами
|
||||
let globalWidgetInstance: any = null;
|
||||
// Глобальная переменная для отслеживания загрузки скрипта
|
||||
let isScriptLoaded = false;
|
||||
|
||||
export default function CdekWidgetPopup({
|
||||
yandexApiKey,
|
||||
servicePath,
|
||||
from,
|
||||
defaultLocation,
|
||||
goods,
|
||||
onSelect,
|
||||
onCalculate,
|
||||
debugMode = true, // По умолчанию режим отладки выключен
|
||||
}: CdekWidgetPopupProps) {
|
||||
const widgetInstance = useRef<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const isComponentMounted = useRef(true);
|
||||
const prevFromRef = useRef<string>(from);
|
||||
|
||||
// Добавляем состояние для хранения выбранных данных
|
||||
const [selectedDeliveryData, setSelectedDeliveryData] = useState<{
|
||||
type: DeliveryMode | null;
|
||||
tariff: iTariff | null;
|
||||
target: iOffice | iGeocoderMember | null;
|
||||
}>({
|
||||
type: null,
|
||||
tariff: null,
|
||||
target: null
|
||||
});
|
||||
|
||||
// Добавляем состояние для хранения рассчитанных цен
|
||||
const [calculatedPrices, setCalculatedPrices] = useState<{
|
||||
office: iTariff[];
|
||||
door: iTariff[];
|
||||
pickup: iTariff[];
|
||||
} | null>(null);
|
||||
|
||||
// Безопасная обертка для вызова пользовательского коллбэка onSelect
|
||||
const safeCallOnSelect = useCallback((type: DeliveryMode, tariff: iTariff | null, target: iOffice | iGeocoderMember) => {
|
||||
if (!isComponentMounted.current) return;
|
||||
|
||||
try {
|
||||
// Сохраняем выбранные данные в состоянии для отображения
|
||||
setSelectedDeliveryData({
|
||||
type,
|
||||
tariff,
|
||||
target
|
||||
});
|
||||
|
||||
// Всегда логируем выбранные данные в консоль для отладки
|
||||
console.log('CDEK выбранный пункт выдачи:', {
|
||||
type,
|
||||
tariff,
|
||||
target
|
||||
});
|
||||
|
||||
// Проверяем, что target не пустой
|
||||
if (!target) {
|
||||
console.error('CDEK: Выбран пустой target!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Вызываем коллбэк
|
||||
onSelect(type, tariff, target);
|
||||
|
||||
// Дополнительная проверка после вызова коллбэка
|
||||
setTimeout(() => {
|
||||
console.log('CDEK: Проверка состояния после выбора ПВЗ:', {
|
||||
selectedDeliveryData: {
|
||||
type,
|
||||
tariff,
|
||||
target
|
||||
},
|
||||
isComponentMounted: isComponentMounted.current
|
||||
});
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
console.error("Ошибка при вызове onSelect:", e);
|
||||
}
|
||||
}, [onSelect, debugMode]);
|
||||
|
||||
// Безопасная обертка для вызова пользовательского коллбэка onCalculate
|
||||
const safeCallOnCalculate = useCallback((prices: { office: iTariff[]; door: iTariff[]; pickup: iTariff[] }, address: { code?: number; address?: string }) => {
|
||||
if (!isComponentMounted.current) return;
|
||||
|
||||
try {
|
||||
// Сохраняем рассчитанные цены в состоянии для отображения
|
||||
setCalculatedPrices(prices);
|
||||
|
||||
// Логируем рассчитанные цены в консоль
|
||||
if (debugMode) {
|
||||
console.log('CDEK рассчитанные тарифы:', {
|
||||
prices,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
onCalculate(prices, address);
|
||||
} catch (e) {
|
||||
console.error("Ошибка при вызове onCalculate:", e);
|
||||
}
|
||||
}, [onCalculate, debugMode]);
|
||||
|
||||
// Создаем throttled версию обработчика расчета с интервалом 500 мс
|
||||
const throttledCalculate = useCallback(
|
||||
throttle((prices: { office: iTariff[]; door: iTariff[]; pickup: iTariff[] }, address: { code?: number; address?: string }) => {
|
||||
safeCallOnCalculate(prices, address);
|
||||
}, 500),
|
||||
[safeCallOnCalculate]
|
||||
);
|
||||
|
||||
// Создаем throttled версию обработчика выбора с интервалом 300 мс
|
||||
const throttledSelect = useCallback(
|
||||
throttle((type: DeliveryMode, tariff: iTariff | null, target: iOffice | iGeocoderMember) => {
|
||||
safeCallOnSelect(type, tariff, target);
|
||||
}, 300),
|
||||
[safeCallOnSelect]
|
||||
);
|
||||
|
||||
// Упрощенный прокси для service.php без кеширования
|
||||
const customServiceProxy = useCallback(async (url: string, data: any, method?: string) => {
|
||||
// Определяем метод запроса
|
||||
const requestMethod = (method || data?.method || (Object.keys(data || {}).length === 0 ? 'GET' : 'POST')).toUpperCase();
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`→ Запрос ${requestMethod} к CDEK. URL: ${url}${requestMethod !== 'GET' ? ', Data: ' + JSON.stringify(data) : ''}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Выполняем запрос
|
||||
const response = await fetch(url, {
|
||||
method: requestMethod,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: requestMethod !== 'GET' ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} для ${url}`);
|
||||
}
|
||||
|
||||
// Пытаемся получить JSON
|
||||
try {
|
||||
const jsonData = await response.json();
|
||||
return { result: jsonData };
|
||||
} catch (jsonError) {
|
||||
// Если не удалось распарсить как JSON, пробуем получить текст
|
||||
console.warn(`Не удалось распарсить ответ как JSON для ${url}, пробуем текст`);
|
||||
const textResponse = await response.text();
|
||||
|
||||
// Пробуем распарсить текст как JSON
|
||||
try {
|
||||
return { result: JSON.parse(textResponse) };
|
||||
} catch (textParseError) {
|
||||
// Если и это не удалось, возвращаем как текст
|
||||
return { result: textResponse };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Ошибка запроса ${requestMethod} для ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}, [debugMode]);
|
||||
|
||||
// Функция для создания виджета
|
||||
const createWidget = useCallback(() => {
|
||||
if (!window.CDEKWidget) {
|
||||
console.error("CDEK Widget is not loaded yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем глобальный экземпляр виджета
|
||||
if (globalWidgetInstance) {
|
||||
if (debugMode) {
|
||||
console.log(`→ Используем глобальный экземпляр виджета, обновляем город на: ${from}`);
|
||||
}
|
||||
|
||||
// Сохраняем ссылку на глобальный экземпляр
|
||||
widgetInstance.current = globalWidgetInstance;
|
||||
|
||||
try {
|
||||
// Пробуем обновить город в существующем виджете
|
||||
if (typeof globalWidgetInstance.updateLocation === 'function') {
|
||||
globalWidgetInstance.updateLocation(from);
|
||||
prevFromRef.current = from;
|
||||
|
||||
// Устанавливаем состояние инициализации
|
||||
if (!isInitialized) {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (typeof globalWidgetInstance.setCity === 'function') {
|
||||
globalWidgetInstance.setCity(from);
|
||||
prevFromRef.current = from;
|
||||
|
||||
// Устанавливаем состояние инициализации
|
||||
if (!isInitialized) {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.warn("Не удалось обновить город в глобальном виджете:", updateError);
|
||||
// Продолжаем и создаем новый виджет
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет глобального экземпляра или не удалось обновить город, создаем новый
|
||||
if (debugMode) {
|
||||
console.log(`→ Создаем новый экземпляр виджета с городом: ${from}`);
|
||||
}
|
||||
|
||||
// Создаем новый экземпляр виджета с официально поддерживаемыми параметрами
|
||||
const newWidgetInstance = new window.CDEKWidget({
|
||||
apiKey: yandexApiKey, // ключ API Яндекс.Карт
|
||||
servicePath: servicePath, // URL для запросов к API CDEK
|
||||
serviceCustom: customServiceProxy, // кастомный прокси без кеширования
|
||||
from: from, // текущий город
|
||||
defaultLocation: defaultLocation, // центр карты по умолчанию
|
||||
popup: true, // режим модального окна
|
||||
goods: goods, // данные о товарах для расчета
|
||||
onChoose: throttledSelect, // обработчик выбора ПВЗ/адреса
|
||||
onCalculate: throttledCalculate, // обработчик расчета стоимости
|
||||
hideDeliveryOptions: { door: true }, // не скрывать опцию доставки до двери
|
||||
tariffs: { office: [136], door: [] }, // ограничение тарифов
|
||||
onReady: () => {
|
||||
if (isComponentMounted.current) {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
|
||||
if (debugMode) {
|
||||
console.log(`→ Виджет инициализирован с городом: ${from}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure: (error: any) => {
|
||||
console.error("CDEK Widget init failure:", error);
|
||||
if (isComponentMounted.current) {
|
||||
setError(`Ошибка инициализации: ${error?.message || 'неизвестная ошибка'}`);
|
||||
setIsLoading(false);
|
||||
setIsInitialized(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Сохраняем экземпляр в локальной и глобальной переменной
|
||||
widgetInstance.current = newWidgetInstance;
|
||||
globalWidgetInstance = newWidgetInstance;
|
||||
|
||||
// Записываем текущий город
|
||||
prevFromRef.current = from;
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Error creating CDEK Widget:", e);
|
||||
if (isComponentMounted.current) {
|
||||
setError("Ошибка инициализации виджета СДЭК");
|
||||
setIsLoading(false);
|
||||
setIsInitialized(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, [yandexApiKey, servicePath, from, goods, throttledSelect, throttledCalculate, customServiceProxy, debugMode, isInitialized]);
|
||||
|
||||
// Загрузка скрипта CDEK и инициализация виджета
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Устанавливаем флаг монтирования
|
||||
isComponentMounted.current = true;
|
||||
|
||||
// Функция очистки ресурсов
|
||||
const cleanup = () => {
|
||||
try {
|
||||
// Устанавливаем флаг размонтирования
|
||||
isComponentMounted.current = false;
|
||||
|
||||
// НЕ уничтожаем виджет при размонтировании и не очищаем ссылку
|
||||
// Это позволяет сохранить экземпляр между рендерами
|
||||
} catch (e) {
|
||||
console.warn("Error during cleanup:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Загружаем скрипт если ещё не загружен
|
||||
const loadScriptAndCreateWidget = () => {
|
||||
// Если глобальный экземпляр виджета уже существует, используем его
|
||||
if (globalWidgetInstance) {
|
||||
if (debugMode) console.log("→ Глобальный экземпляр виджета уже существует");
|
||||
widgetInstance.current = globalWidgetInstance;
|
||||
// isScriptLoaded уже установлен в true
|
||||
setIsInitialized(true);
|
||||
setIsLoading(false);
|
||||
|
||||
// Обновляем город, если он изменился
|
||||
if (prevFromRef.current !== from) {
|
||||
try {
|
||||
if (typeof globalWidgetInstance.updateLocation === 'function') {
|
||||
globalWidgetInstance.updateLocation(from);
|
||||
prevFromRef.current = from;
|
||||
} else if (typeof globalWidgetInstance.setCity === 'function') {
|
||||
globalWidgetInstance.setCity(from);
|
||||
prevFromRef.current = from;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Ошибка при обновлении города:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Если скрипт уже загружен глобально
|
||||
if (window.CDEKWidget && isScriptLoaded) {
|
||||
if (debugMode) console.log("→ Скрипт CDEK уже загружен глобально");
|
||||
// isScriptLoaded уже установлен в true
|
||||
createWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже скрипт на странице
|
||||
const existingScript = document.querySelector('script[src="https://cdn.jsdelivr.net/npm/@cdek-it/widget@3"]');
|
||||
|
||||
if (existingScript) {
|
||||
if (debugMode) console.log("→ Скрипт CDEK уже добавлен на страницу");
|
||||
|
||||
// Если скрипт уже добавлен, но виджет еще не инициализирован
|
||||
const checkWidget = setInterval(() => {
|
||||
if (window.CDEKWidget) {
|
||||
clearInterval(checkWidget);
|
||||
isScriptLoaded = true;
|
||||
// isScriptLoaded уже установлен в true
|
||||
createWidget();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Таймаут на случай, если что-то пойдет не так
|
||||
setTimeout(() => clearInterval(checkWidget), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе загружаем скрипт
|
||||
if (debugMode) console.log("→ Загружаем скрипт CDEK");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.jsdelivr.net/npm/@cdek-it/widget@3";
|
||||
script.async = true;
|
||||
script.id = "cdek-widget-script"; // Добавляем ID для легкой идентификации
|
||||
script.onload = () => {
|
||||
if (debugMode) console.log("→ Скрипт CDEK загружен");
|
||||
isScriptLoaded = true;
|
||||
// isScriptLoaded уже установлен в true
|
||||
createWidget();
|
||||
};
|
||||
script.onerror = () => {
|
||||
setError("Ошибка загрузки скрипта СДЭК");
|
||||
setIsLoading(false);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
};
|
||||
|
||||
// Запускаем загрузку и инициализацию
|
||||
loadScriptAndCreateWidget();
|
||||
|
||||
// Очистка при размонтировании
|
||||
return cleanup;
|
||||
|
||||
}, [from, createWidget, debugMode]); // Добавляем зависимости для обновления при изменении города
|
||||
|
||||
// Эффект для обновления центра карты удален, так как эта логика теперь включена в функции createWidget и openWidget
|
||||
|
||||
// Открытие виджета с проверкой состояния
|
||||
const openWidget = () => {
|
||||
try {
|
||||
// Проверяем глобальный экземпляр виджета
|
||||
if (globalWidgetInstance) {
|
||||
if (debugMode) console.log("→ Открываем глобальный экземпляр виджета");
|
||||
|
||||
// Обновляем ссылку на глобальный экземпляр
|
||||
widgetInstance.current = globalWidgetInstance;
|
||||
|
||||
// Обновляем город, если он изменился
|
||||
if (prevFromRef.current !== from) {
|
||||
try {
|
||||
if (typeof globalWidgetInstance.updateLocation === 'function') {
|
||||
globalWidgetInstance.updateLocation(from);
|
||||
prevFromRef.current = from;
|
||||
} else if (typeof globalWidgetInstance.setCity === 'function') {
|
||||
globalWidgetInstance.setCity(from);
|
||||
prevFromRef.current = from;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Ошибка при обновлении города:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Открываем виджет
|
||||
if (typeof globalWidgetInstance.open === 'function') {
|
||||
globalWidgetInstance.open();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если глобальный экземпляр не существует или метод open отсутствует
|
||||
if (window.CDEKWidget) {
|
||||
if (debugMode) console.log("→ Глобальный экземпляр не существует или метод open отсутствует, создаем новый");
|
||||
|
||||
// Пробуем создать виджет
|
||||
if (createWidget()) {
|
||||
// Даем небольшую задержку для инициализации
|
||||
setTimeout(() => {
|
||||
if (globalWidgetInstance && typeof globalWidgetInstance.open === 'function') {
|
||||
globalWidgetInstance.open();
|
||||
} else if (widgetInstance.current && typeof widgetInstance.current.open === 'function') {
|
||||
widgetInstance.current.open();
|
||||
} else {
|
||||
setError("Не удалось открыть виджет СДЭК после инициализации");
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
setError("Не удалось инициализировать виджет СДЭК для открытия");
|
||||
}
|
||||
} else {
|
||||
// Если библиотека не загружена, пробуем загрузить скрипт
|
||||
if (debugMode) console.log("→ Библиотека CDEK не загружена, загружаем скрипт");
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.jsdelivr.net/npm/@cdek-it/widget@3";
|
||||
script.async = true;
|
||||
script.id = "cdek-widget-script";
|
||||
script.onload = () => {
|
||||
if (debugMode) console.log("→ Скрипт CDEK загружен");
|
||||
isScriptLoaded = true;
|
||||
// isScriptLoaded уже установлен в true
|
||||
|
||||
if (createWidget()) {
|
||||
setTimeout(() => {
|
||||
if (globalWidgetInstance && typeof globalWidgetInstance.open === 'function') {
|
||||
globalWidgetInstance.open();
|
||||
} else if (widgetInstance.current && typeof widgetInstance.current.open === 'function') {
|
||||
widgetInstance.current.open();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
setError("Ошибка загрузки скрипта СДЭК");
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Ошибка при открытии виджета:", e);
|
||||
setError("Ошибка при открытии виджета");
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для отображения данных пункта выдачи в удобочитаемом формате
|
||||
const renderDeliveryPoint = () => {
|
||||
if (!selectedDeliveryData.target) return null;
|
||||
|
||||
// Если выбран пункт выдачи (ПВЗ)
|
||||
if (selectedDeliveryData.type === 'office') {
|
||||
const office = selectedDeliveryData.target as iOffice;
|
||||
return (
|
||||
<div className="mt-3 p-2 border rounded text-sm">
|
||||
<h4 className="font-medium">Данные выбранного пункта выдачи (СДЭК)</h4>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div><span className="font-medium">Код ПВЗ:</span> {office.code}</div>
|
||||
<div><span className="font-medium">Адрес:</span> {office.address}</div>
|
||||
<div><span className="font-medium">Город:</span> {office.city}</div>
|
||||
<div><span className="font-medium">Регион:</span> {office.region}</div>
|
||||
<div><span className="font-medium">Индекс:</span> {office.postal_code}</div>
|
||||
<div><span className="font-medium">Режим работы:</span> {office.work_time}</div>
|
||||
<div><span className="font-medium">Координаты:</span> {office.location.join(', ')}</div>
|
||||
<div><span className="font-medium">Есть примерочная:</span> {office.is_dressing_room ? 'Да' : 'Нет'}</div>
|
||||
{selectedDeliveryData.tariff && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<div><span className="font-medium">Тариф:</span> {selectedDeliveryData.tariff.tariff_name}</div>
|
||||
<div><span className="font-medium">Стоимость:</span> {selectedDeliveryData.tariff.delivery_sum} ₽</div>
|
||||
<div><span className="font-medium">Срок доставки:</span> {selectedDeliveryData.tariff.period_min}-{selectedDeliveryData.tariff.period_max} дн.</div>
|
||||
</div>
|
||||
)}
|
||||
{debugMode && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-blue-500">Полные данные (для отладки)</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 overflow-auto text-xs max-h-60">
|
||||
{JSON.stringify(office, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если выбрана доставка до двери
|
||||
if (selectedDeliveryData.type === 'door') {
|
||||
const address = selectedDeliveryData.target as iGeocoderMember;
|
||||
return (
|
||||
<div className="mt-3 p-2 border rounded text-sm">
|
||||
<h4 className="font-medium">Данные адреса доставки (СДЭК)</h4>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div><span className="font-medium">Адрес:</span> {address.formatted}</div>
|
||||
<div><span className="font-medium">Индекс:</span> {address.postal_code}</div>
|
||||
<div><span className="font-medium">Координаты:</span> {address.position.join(', ')}</div>
|
||||
{selectedDeliveryData.tariff && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<div><span className="font-medium">Тариф:</span> {selectedDeliveryData.tariff.tariff_name}</div>
|
||||
<div><span className="font-medium">Стоимость:</span> {selectedDeliveryData.tariff.delivery_sum} ₽</div>
|
||||
<div><span className="font-medium">Срок доставки:</span> {selectedDeliveryData.tariff.period_min}-{selectedDeliveryData.tariff.period_max} дн.</div>
|
||||
</div>
|
||||
)}
|
||||
{debugMode && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-blue-500">Полные данные (для отладки)</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 overflow-auto text-xs max-h-60">
|
||||
{JSON.stringify(address, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Функция для отображения рассчитанных тарифов
|
||||
const renderCalculatedPrices = () => {
|
||||
if (!calculatedPrices) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-2 border rounded text-sm">
|
||||
<h4 className="font-medium">Доступные тарифы СДЭК</h4>
|
||||
{calculatedPrices.office.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<h5 className="font-medium">Пункт выдачи:</h5>
|
||||
<ul className="mt-1 list-disc pl-5">
|
||||
{calculatedPrices.office.map((tariff) => (
|
||||
<li key={tariff.tariff_code}>
|
||||
{tariff.tariff_name} - {tariff.delivery_sum} ₽ ({tariff.period_min}-{tariff.period_max} дн.)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{calculatedPrices.door.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<h5 className="font-medium">Курьером до двери:</h5>
|
||||
<ul className="mt-1 list-disc pl-5">
|
||||
{calculatedPrices.door.map((tariff) => (
|
||||
<li key={tariff.tariff_code}>
|
||||
{tariff.tariff_name} - {tariff.delivery_sum} ₽ ({tariff.period_min}-{tariff.period_max} дн.)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{calculatedPrices.pickup.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<h5 className="font-medium">Самовывоз:</h5>
|
||||
<ul className="mt-1 list-disc pl-5">
|
||||
{calculatedPrices.pickup.map((tariff) => (
|
||||
<li key={tariff.tariff_code}>
|
||||
{tariff.tariff_name} - {tariff.delivery_sum} ₽ ({tariff.period_min}-{tariff.period_max} дн.)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{debugMode && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-blue-500">Полные данные (для отладки)</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 overflow-auto text-xs max-h-60">
|
||||
{JSON.stringify(calculatedPrices, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-red-500 text-sm">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 border rounded bg-primary text-white"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
setIsInitialized(false);
|
||||
// Попытка пересоздания виджета при ошибке
|
||||
createWidget();
|
||||
}}
|
||||
>
|
||||
Повторить загрузку виджета
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isLoading && <div className="mb-2 text-sm">Загрузка виджета...</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 border rounded bg-primary text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={openWidget}
|
||||
disabled={isLoading || !isInitialized} // Блокируем кнопку пока виджет не инициализирован
|
||||
>
|
||||
Выбрать пункт выдачи СДЭК
|
||||
</button>
|
||||
|
||||
{/* Отображаем данные о выбранном пункте выдачи, если они есть */}
|
||||
{selectedDeliveryData.target && renderDeliveryPoint()}
|
||||
|
||||
{/* Отображаем рассчитанные тарифы, если они есть */}
|
||||
{calculatedPrices && renderCalculatedPrices()}
|
||||
|
||||
{/* Кнопка очистки кеша удалена, так как кеширование больше не используется */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { normalizeProductImage } from "@/lib/catalog";
|
||||
import { Cart } from "@/types/cart";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ShoppingBag, Truck, Package } from "lucide-react";
|
||||
@ -41,7 +42,7 @@ export default function OrderSummary({ cart }: OrderSummaryProps) {
|
||||
<div className="relative h-16 w-16 flex-shrink-0 rounded overflow-hidden border border-gray-200">
|
||||
{item.image || item.product_image ? (
|
||||
<Image
|
||||
src={item.image || item.product_image || ""}
|
||||
src={normalizeProductImage(item.image || item.product_image || "")}
|
||||
alt={item.name || item.product_name || "Товар"}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@ -5,7 +5,7 @@ import Image from "next/image"
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery"
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
import { createPortal } from 'react-dom'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { toast } from "sonner"
|
||||
import { motion } from "framer-motion"
|
||||
import { Product, ProductDetails } from "@/lib/catalog"
|
||||
import { CardImageSlider } from "@/components/product/CardImageSlider"
|
||||
import { useWishlist } from "@/hooks/use-wishlist"
|
||||
|
||||
// Расширенный интерфейс для поддержки как простых, так и расширенных карточек товаров
|
||||
interface ProductCardProps {
|
||||
@ -31,6 +32,7 @@ interface ProductCardProps {
|
||||
slug?: string;
|
||||
viewMode?: 'grid' | 'list';
|
||||
href?: string;
|
||||
showWishlistButton?: boolean;
|
||||
}
|
||||
|
||||
// Функция для корректного отображения URL изображения
|
||||
@ -70,6 +72,7 @@ export function getProperImageUrl(image: string | { id: number; image_url: strin
|
||||
|
||||
export function ProductCard(props: ProductCardProps) {
|
||||
const { addToCart, loading } = useCart();
|
||||
const { toggleItem, isInWishlist } = useWishlist();
|
||||
|
||||
// Определение: в каком формате переданы данные (полный объект или отдельные поля)
|
||||
const isProductObject = 'product' in props && !!props.product;
|
||||
@ -163,6 +166,20 @@ export function ProductCard(props: ProductCardProps) {
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
// Формируем объект для избранного
|
||||
const wishlistItem = {
|
||||
id: id!,
|
||||
product_id: id!,
|
||||
name,
|
||||
price,
|
||||
discount_price: salePrice,
|
||||
image: props.image || (isProductObject && props.product && props.product.primary_image) || '',
|
||||
slug: slug || '',
|
||||
};
|
||||
const inWishlist = isInWishlist(id!);
|
||||
|
||||
const showWishlistButton = props.showWishlistButton !== false;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -186,22 +203,25 @@ export function ProductCard(props: ProductCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка избранного - прозрачная, как стекло */}
|
||||
<div className="absolute top-3 right-3 z-10 flex flex-col gap-2 transition-transform duration-300">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-8 h-8 transition-all"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toast.success(`${name} добавлен в избранное`);
|
||||
}}
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
<span className="sr-only">Добавить в избранное</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Кнопка избранного */}
|
||||
{showWishlistButton && (
|
||||
<div className="absolute top-3 right-3 z-10 flex flex-col gap-2 transition-transform duration-300">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={inWishlist ? "default" : "secondary"}
|
||||
className={`rounded-full bg-white/40 backdrop-blur-sm text-primary/90 hover:bg-white/60 shadow-sm w-8 h-8 transition-all ${inWishlist ? 'bg-primary/90 text-white' : ''}`}
|
||||
aria-pressed={inWishlist}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleItem(wishlistItem);
|
||||
}}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${inWishlist ? 'fill-primary text-white' : ''}`} />
|
||||
<span className="sr-only">{inWishlist ? 'Убрать из избранного' : 'Добавить в избранное'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Используем CardImageSlider для отображения изображений */}
|
||||
<Link href={productUrl} className="block aspect-[3/4] relative overflow-hidden">
|
||||
|
||||
204
frontend/components/ui/data-table.tsx
Normal file
204
frontend/components/ui/data-table.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
Table as TableInstance,
|
||||
Row,
|
||||
Header,
|
||||
Cell,
|
||||
TableOptions,
|
||||
RowSelectionState,
|
||||
HeaderGroup
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData>[];
|
||||
data: TData[];
|
||||
loading?: boolean;
|
||||
pagination?: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
};
|
||||
selection?: {
|
||||
selectedRowIds: number[];
|
||||
onSelectionChange: (selectedIds: number[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<TData extends { id: number }>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
pagination,
|
||||
selection
|
||||
}: DataTableProps<TData>) {
|
||||
const tableOptions: TableOptions<TData> = {
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
pageCount: pagination?.pageCount,
|
||||
manualPagination: !!pagination,
|
||||
enableRowSelection: !!selection,
|
||||
enableMultiRowSelection: !!selection,
|
||||
state: {
|
||||
pagination: pagination ? {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize
|
||||
} : undefined,
|
||||
rowSelection: selection ? data.reduce<RowSelectionState>((acc, row) => {
|
||||
acc[row.id] = selection.selectedRowIds.includes(row.id);
|
||||
return acc;
|
||||
}, {}) : {}
|
||||
},
|
||||
onPaginationChange: pagination ? (updater: ((state: PaginationState) => PaginationState) | PaginationState) => {
|
||||
if (typeof updater === 'function') {
|
||||
const newState = updater(table.getState().pagination);
|
||||
pagination.onPageChange(newState.pageIndex);
|
||||
}
|
||||
} : undefined,
|
||||
onRowSelectionChange: selection ? () => {
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
const selectedIds = selectedRows.map(row => row.original.id);
|
||||
selection.onSelectionChange(selectedIds);
|
||||
} : undefined
|
||||
};
|
||||
|
||||
const table = useReactTable(tableOptions);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
|
||||
headerGroup.headers.map((header: Header<TData, unknown>) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">Загрузка...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Нет данных
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Строк на странице</p>
|
||||
<Select
|
||||
value={`${pagination.pageSize}`}
|
||||
onValueChange={(value) => pagination.onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
«
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(table.getState().pagination.pageIndex - 1)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Страница {table.getState().pagination.pageIndex + 1} из{' '}
|
||||
{pagination.pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(table.getState().pagination.pageIndex + 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(pagination.pageCount - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
»
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -42,10 +42,7 @@ const TableFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
className={cn("bg-primary font-medium text-primary-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { AuthContext } from '../lib/auth';
|
||||
|
||||
// Создаем собственную реализацию хука useAuth
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth должен использоваться внутри AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@ -1,210 +1,185 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '@/lib/api';
|
||||
import { cacheKeys } from '@/lib/api-cache';
|
||||
|
||||
/**
|
||||
* Типы состояний запроса
|
||||
*/
|
||||
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;
|
||||
export function useAdminQuery<TData = unknown, TError = unknown>(
|
||||
key: string | string[],
|
||||
url: string,
|
||||
params: Record<string, any> = {},
|
||||
options: Omit<UseQueryOptions<TData, TError, TData>, 'queryKey' | 'queryFn'> = {}
|
||||
) {
|
||||
const queryKey = Array.isArray(key) ? key : [key];
|
||||
return useQuery<TData, TError>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<TData>(url, { params });
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data from ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для работы с 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);
|
||||
|
||||
export function useAdminMutation<TData = unknown, TError = unknown, TVariables = unknown>(
|
||||
method: 'post' | 'put' | 'delete',
|
||||
url: string,
|
||||
options: {
|
||||
onSuccessMessage?: string;
|
||||
onErrorMessage?: string;
|
||||
invalidateQueries?: (string | string[])[];
|
||||
mutationOptions?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>;
|
||||
} = {}
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccess,
|
||||
onError,
|
||||
showSuccessToast = false,
|
||||
showErrorToast = true,
|
||||
successMessage = 'Операция выполнена успешно',
|
||||
errorMessage = 'Произошла ошибка при выполнении операции'
|
||||
onSuccessMessage = 'Операция выполнена успешно',
|
||||
onErrorMessage = 'Произошла ошибка при выполнении операции',
|
||||
invalidateQueries = [],
|
||||
mutationOptions = {}
|
||||
} = 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);
|
||||
return useMutation<TData, TError, TVariables>({
|
||||
mutationFn: async (variables) => {
|
||||
try {
|
||||
if (method === 'delete') {
|
||||
return await api.delete<TData>(url);
|
||||
} else {
|
||||
return await api[method]<TData>(url, variables);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${method.toUpperCase()} ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
if (onSuccessMessage) toast.success(onSuccessMessage);
|
||||
if (invalidateQueries.length > 0) {
|
||||
invalidateQueries.forEach(key => {
|
||||
if (Array.isArray(key)) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 (mutationOptions.onSuccess) {
|
||||
mutationOptions.onSuccess(data, variables, context);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (onErrorMessage) toast.error(onErrorMessage);
|
||||
if (mutationOptions.onError) {
|
||||
mutationOptions.onError(error, variables, context);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
},
|
||||
...mutationOptions
|
||||
});
|
||||
}
|
||||
|
||||
export default useAdminApi;
|
||||
export function useProducts(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.products, JSON.stringify(params)],
|
||||
'/catalog/products',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useProduct(id: number | string, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.product(id),
|
||||
`/catalog/products/${id}`,
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useCategories(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.categories, JSON.stringify(params)],
|
||||
'/catalog/categories',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useCollections(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.collections, JSON.stringify(params)],
|
||||
'/catalog/collections',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useSizes(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.sizes, JSON.stringify(params)],
|
||||
'/catalog/sizes',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrders(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.orders, JSON.stringify(params)],
|
||||
'/admin/orders',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrder(id: number | string, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.order(id),
|
||||
`/admin/orders/${id}`,
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboardStats(options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.dashboardStats,
|
||||
'/admin/dashboard/stats',
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecentOrders(limit = 5, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.recentOrders,
|
||||
'/admin/orders/recent',
|
||||
{ limit },
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function usePopularProducts(limit = 5, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.popularProducts,
|
||||
'/admin/products/popular',
|
||||
{ limit },
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
useAdminQuery,
|
||||
useAdminMutation,
|
||||
useProducts,
|
||||
useProduct,
|
||||
useCategories,
|
||||
useCollections,
|
||||
useSizes,
|
||||
useOrders,
|
||||
useOrder,
|
||||
useDashboardStats,
|
||||
useRecentOrders,
|
||||
usePopularProducts
|
||||
};
|
||||
@ -1,191 +0,0 @@
|
||||
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;
|
||||
244
frontend/hooks/useAdminQuery.ts
Normal file
244
frontend/hooks/useAdminQuery.ts
Normal file
@ -0,0 +1,244 @@
|
||||
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '@/lib/api';
|
||||
import { cacheKeys } from '@/lib/api-cache';
|
||||
|
||||
/**
|
||||
* Хук для выполнения GET-запросов с использованием react-query
|
||||
* @param key Ключ кэша
|
||||
* @param url URL для запроса
|
||||
* @param params Параметры запроса
|
||||
* @param options Дополнительные опции для useQuery
|
||||
* @returns Результат выполнения useQuery
|
||||
*/
|
||||
export function useAdminQuery<TData = unknown, TError = unknown>(
|
||||
key: string | string[],
|
||||
url: string,
|
||||
params: Record<string, any> = {},
|
||||
options: Omit<UseQueryOptions<TData, TError, TData>, 'queryKey' | 'queryFn'> = {}
|
||||
) {
|
||||
const queryKey = Array.isArray(key) ? key : [key];
|
||||
|
||||
return useQuery<TData, TError>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<TData>(url, { params });
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data from ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для выполнения мутаций (POST, PUT, DELETE) с использованием react-query
|
||||
* @param method Метод запроса (post, put, delete)
|
||||
* @param url URL для запроса
|
||||
* @param options Дополнительные опции для useMutation
|
||||
* @returns Результат выполнения useMutation
|
||||
*/
|
||||
export function useAdminMutation<TData = unknown, TError = unknown, TVariables = unknown>(
|
||||
method: 'post' | 'put' | 'delete',
|
||||
url: string,
|
||||
options: {
|
||||
onSuccessMessage?: string;
|
||||
onErrorMessage?: string;
|
||||
invalidateQueries?: (string | string[])[];
|
||||
mutationOptions?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>;
|
||||
} = {}
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
onSuccessMessage = 'Операция выполнена успешно',
|
||||
onErrorMessage = 'Произошла ошибка при выполнении операции',
|
||||
invalidateQueries = [],
|
||||
mutationOptions = {}
|
||||
} = options;
|
||||
|
||||
return useMutation<TData, TError, TVariables>({
|
||||
mutationFn: async (variables) => {
|
||||
try {
|
||||
if (method === 'delete') {
|
||||
return await api.delete<TData>(url);
|
||||
} else {
|
||||
return await api[method]<TData>(url, variables);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${method.toUpperCase()} ${url}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
// Показываем сообщение об успехе
|
||||
if (onSuccessMessage) {
|
||||
toast.success(onSuccessMessage);
|
||||
}
|
||||
|
||||
// Инвалидируем кэш для указанных запросов
|
||||
if (invalidateQueries.length > 0) {
|
||||
invalidateQueries.forEach(key => {
|
||||
if (Array.isArray(key)) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Вызываем пользовательский обработчик успеха
|
||||
if (mutationOptions.onSuccess) {
|
||||
mutationOptions.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Показываем сообщение об ошибке
|
||||
if (onErrorMessage) {
|
||||
toast.error(onErrorMessage);
|
||||
}
|
||||
|
||||
// Вызываем пользовательский обработчик ошибки
|
||||
if (mutationOptions.onError) {
|
||||
mutationOptions.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
...mutationOptions
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения списка продуктов
|
||||
*/
|
||||
export function useProducts(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.products, JSON.stringify(params)],
|
||||
'/catalog/products',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения продукта по ID
|
||||
*/
|
||||
export function useProduct(id: number | string, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.product(id),
|
||||
`/catalog/products/${id}`,
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения списка категорий
|
||||
*/
|
||||
export function useCategories(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.categories, JSON.stringify(params)],
|
||||
'/catalog/categories',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения списка коллекций
|
||||
*/
|
||||
export function useCollections(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.collections, JSON.stringify(params)],
|
||||
'/catalog/collections',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения списка размеров
|
||||
*/
|
||||
export function useSizes(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.sizes, JSON.stringify(params)],
|
||||
'/catalog/sizes',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения списка заказов
|
||||
*/
|
||||
export function useOrders(params: Record<string, any> = {}, options = {}) {
|
||||
return useAdminQuery(
|
||||
[cacheKeys.orders, JSON.stringify(params)],
|
||||
'/admin/orders',
|
||||
params,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения заказа по ID
|
||||
*/
|
||||
export function useOrder(id: number | string, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.order(id),
|
||||
`/admin/orders/${id}`,
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения статистики дашборда
|
||||
*/
|
||||
export function useDashboardStats(options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.dashboardStats,
|
||||
'/admin/dashboard/stats',
|
||||
{},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения последних заказов
|
||||
*/
|
||||
export function useRecentOrders(limit = 5, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.recentOrders,
|
||||
'/admin/orders/recent',
|
||||
{ limit },
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения популярных продуктов
|
||||
*/
|
||||
export function usePopularProducts(limit = 5, options = {}) {
|
||||
return useAdminQuery(
|
||||
cacheKeys.popularProducts,
|
||||
'/admin/products/popular',
|
||||
{ limit },
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
useAdminQuery,
|
||||
useAdminMutation,
|
||||
useProducts,
|
||||
useProduct,
|
||||
useCategories,
|
||||
useCollections,
|
||||
useSizes,
|
||||
useOrders,
|
||||
useOrder,
|
||||
useDashboardStats,
|
||||
useRecentOrders,
|
||||
usePopularProducts
|
||||
};
|
||||
@ -1,271 +0,0 @@
|
||||
"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
|
||||
};
|
||||
}
|
||||
@ -7,9 +7,7 @@ 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());
|
||||
@ -28,27 +26,20 @@ export function useCart() {
|
||||
|
||||
// Проверка аутентификации и синхронизация корзины
|
||||
useEffect(() => {
|
||||
if (apiStatus.isAuthenticated) {
|
||||
// Проверяем, не происходит ли уже синхронизация и не слишком ли рано для новой
|
||||
const now = Date.now();
|
||||
if (!syncInProgressRef.current && now - lastSyncTimestamp > SYNC_THROTTLE_MS) {
|
||||
synchronizeCart();
|
||||
}
|
||||
if (apiStatus.isAuthenticated && !syncInProgressRef.current) {
|
||||
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('Не удалось синхронизировать корзину');
|
||||
@ -69,7 +60,7 @@ export function useCart() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.addToCart(item);
|
||||
|
||||
|
||||
if (result) {
|
||||
toast({
|
||||
title: 'Товар добавлен',
|
||||
@ -103,7 +94,7 @@ export function useCart() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.updateCartItem(id, quantity);
|
||||
|
||||
|
||||
if (!result) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@ -131,7 +122,7 @@ export function useCart() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.removeFromCart(id);
|
||||
|
||||
|
||||
if (result) {
|
||||
toast({
|
||||
title: 'Товар удален',
|
||||
@ -165,7 +156,7 @@ export function useCart() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.clearCart();
|
||||
|
||||
|
||||
if (result) {
|
||||
toast({
|
||||
title: 'Корзина очищена',
|
||||
|
||||
33
frontend/hooks/useCategoriesCache.ts
Normal file
33
frontend/hooks/useCategoriesCache.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCategories } from './useAdminApi';
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
|
||||
export default function useCategoriesCache(params: Record<string, any> = {}) {
|
||||
const { data, isLoading, error, refetch } = useCategories(params) as { data?: Category[], isLoading: boolean, error: any, refetch: () => void };
|
||||
const categories: Category[] = data || [];
|
||||
|
||||
// Строим дерево категорий из flat-списка
|
||||
const getCategoryTree = () => {
|
||||
const map = new Map<number, Category & { children?: Category[] }>();
|
||||
categories.forEach(cat => {
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
const tree: (Category & { children?: Category[] })[] = [];
|
||||
map.forEach(cat => {
|
||||
if (cat.parent_id && map.has(cat.parent_id)) {
|
||||
map.get(cat.parent_id)!.children!.push(cat);
|
||||
} else {
|
||||
tree.push(cat);
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
|
||||
return {
|
||||
categories,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
getCategoryTree,
|
||||
};
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Хук для отслеживания медиа-запросов (например, для определения мобильного устройства)
|
||||
* @param query Медиа-запрос, например: '(max-width: 768px)'
|
||||
* @returns Булево значение, соответствующее запросу
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, поддерживается ли matchMedia
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
const media = window.matchMedia(query)
|
||||
// Установка начального значения
|
||||
setMatches(media.matches)
|
||||
|
||||
// Обработчик изменений
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
// Добавляем слушатель
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener('change', listener)
|
||||
return () => media.removeEventListener('change', listener)
|
||||
} else {
|
||||
// Для старых браузеров
|
||||
media.addListener(listener)
|
||||
return () => media.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Если matchMedia не поддерживается, возвращаем дефолтное значение
|
||||
return () => {}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
80
frontend/hooks/useOrdersCache.ts
Normal file
80
frontend/hooks/useOrdersCache.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useOrders } from './useAdminApi';
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { Order } from '@/lib/orders';
|
||||
|
||||
interface UseOrdersCacheParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
dateRange?: [Date | null, Date | null];
|
||||
}
|
||||
|
||||
export default function useOrdersCache({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
status = '',
|
||||
search = '',
|
||||
dateRange = [null, null],
|
||||
}: UseOrdersCacheParams = {}) {
|
||||
const params: Record<string, any> = {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: search || undefined,
|
||||
status: status || undefined,
|
||||
};
|
||||
if (dateRange[0] && dateRange[1]) {
|
||||
params.date_from = format(dateRange[0], 'yyyy-MM-dd');
|
||||
params.date_to = format(dateRange[1], 'yyyy-MM-dd');
|
||||
}
|
||||
const { data, isLoading, error, refetch } = useOrders(params) as { data?: { orders: Order[]; total: number }, isLoading: boolean, error: any, refetch: () => void };
|
||||
const orders: Order[] = data?.orders || [];
|
||||
const totalOrders: number = data?.total || 0;
|
||||
const totalPages: number = pageSize > 0 ? Math.ceil(totalOrders / pageSize) : 1;
|
||||
|
||||
// Вспомогательные функции для UI
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'Ожидает оплаты';
|
||||
case 'paid': return 'Оплачен';
|
||||
case 'processing': return 'В обработке';
|
||||
case 'shipped': return 'Отправлен';
|
||||
case 'delivered': return 'Доставлен';
|
||||
case 'cancelled': return 'Отменен';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'secondary';
|
||||
case 'paid': return 'default';
|
||||
case 'processing': return 'outline';
|
||||
case 'shipped': return 'default';
|
||||
case 'delivered': return 'default';
|
||||
case 'cancelled': return 'destructive';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
const formatDate = (date: string | Date) => {
|
||||
if (!date) return '';
|
||||
return format(new Date(date), 'dd.MM.yyyy', { locale: ru });
|
||||
};
|
||||
const formatAmount = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(amount);
|
||||
};
|
||||
|
||||
return {
|
||||
orders,
|
||||
totalOrders,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
getStatusLabel,
|
||||
getStatusClass,
|
||||
formatDate,
|
||||
formatAmount,
|
||||
};
|
||||
}
|
||||
59
frontend/hooks/useProducts.ts
Normal file
59
frontend/hooks/useProducts.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useProducts as useProductsApi } from './useAdminApi';
|
||||
import { Product } from '@/lib/catalog';
|
||||
|
||||
interface Filters {
|
||||
search?: string;
|
||||
category?: string;
|
||||
is_active?: boolean | '';
|
||||
}
|
||||
|
||||
export function useProducts() {
|
||||
const [filters, setFilters] = useState<Filters>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
|
||||
const params: Record<string, any> = {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
search: filters.search || undefined,
|
||||
category_id: filters.category || undefined,
|
||||
is_active: filters.is_active === '' ? undefined : filters.is_active,
|
||||
};
|
||||
|
||||
const { data, isLoading, error, refetch } = useProductsApi(params) as { data?: { products: Product[]; total: number }, isLoading: boolean, error: any, refetch: () => void };
|
||||
const products: Product[] = data?.products || [];
|
||||
const total: number = data?.total || 0;
|
||||
|
||||
// Удаление продуктов (заглушка, реальный API — через useAdminMutation)
|
||||
const deleteProducts = useCallback(async (ids: number[]) => {
|
||||
// Здесь должен быть вызов useAdminMutation, но для совместимости оставим заглушку
|
||||
setSelectedProducts((prev) => prev.filter(id => !ids.includes(id)));
|
||||
// Можно добавить toast.success('Удалено')
|
||||
}, []);
|
||||
|
||||
// Управление фильтрами
|
||||
const setFilter = (key: keyof Filters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// Управление выбором
|
||||
const selectProducts = (ids: number[]) => setSelectedProducts(ids);
|
||||
|
||||
return {
|
||||
products,
|
||||
loading: isLoading,
|
||||
error,
|
||||
selectedProducts,
|
||||
pagination: { page, pageSize, total },
|
||||
filters,
|
||||
deleteProducts,
|
||||
setFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
selectProducts,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@ -1,19 +1,5 @@
|
||||
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, // Обновлять данные при монтировании компонента
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ключи кэша для различных типов данных
|
||||
*/
|
||||
@ -43,7 +29,7 @@ export const cacheKeys = {
|
||||
* Функция для инвалидации кэша при изменении данных
|
||||
* @param keys Массив ключей кэша для инвалидации
|
||||
*/
|
||||
export function invalidateCache(keys: string | string[] | (string | string[])[]): void {
|
||||
export function invalidateCache(queryClient: QueryClient, keys: string | string[] | (string | string[])[]): void {
|
||||
const keysArray = Array.isArray(keys) ? keys : [keys];
|
||||
|
||||
keysArray.forEach(key => {
|
||||
@ -58,8 +44,8 @@ export function invalidateCache(keys: string | string[] | (string | string[])[])
|
||||
/**
|
||||
* Функция для инвалидации кэша продуктов
|
||||
*/
|
||||
export function invalidateProductsCache(): void {
|
||||
invalidateCache([
|
||||
export function invalidateProductsCache(queryClient: QueryClient): void {
|
||||
invalidateCache(queryClient, [
|
||||
cacheKeys.products,
|
||||
cacheKeys.popularProducts,
|
||||
// Также инвалидируем связанные данные
|
||||
@ -70,8 +56,8 @@ export function invalidateProductsCache(): void {
|
||||
/**
|
||||
* Функция для инвалидации кэша заказов
|
||||
*/
|
||||
export function invalidateOrdersCache(): void {
|
||||
invalidateCache([
|
||||
export function invalidateOrdersCache(queryClient: QueryClient): void {
|
||||
invalidateCache(queryClient, [
|
||||
cacheKeys.orders,
|
||||
cacheKeys.recentOrders,
|
||||
// Также инвалидируем связанные данные
|
||||
@ -82,8 +68,8 @@ export function invalidateOrdersCache(): void {
|
||||
/**
|
||||
* Функция для инвалидации кэша категорий
|
||||
*/
|
||||
export function invalidateCategoriesCache(): void {
|
||||
invalidateCache([
|
||||
export function invalidateCategoriesCache(queryClient: QueryClient): void {
|
||||
invalidateCache(queryClient, [
|
||||
cacheKeys.categories,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.products
|
||||
@ -93,8 +79,8 @@ export function invalidateCategoriesCache(): void {
|
||||
/**
|
||||
* Функция для инвалидации кэша коллекций
|
||||
*/
|
||||
export function invalidateCollectionsCache(): void {
|
||||
invalidateCache([
|
||||
export function invalidateCollectionsCache(queryClient: QueryClient): void {
|
||||
invalidateCache(queryClient, [
|
||||
cacheKeys.collections,
|
||||
// Также инвалидируем связанные данные
|
||||
cacheKeys.products
|
||||
@ -104,14 +90,13 @@ export function invalidateCollectionsCache(): void {
|
||||
/**
|
||||
* Функция для инвалидации кэша размеров
|
||||
*/
|
||||
export function invalidateSizesCache(): void {
|
||||
invalidateCache([
|
||||
export function invalidateSizesCache(queryClient: QueryClient): void {
|
||||
invalidateCache(queryClient, [
|
||||
cacheKeys.sizes
|
||||
]);
|
||||
}
|
||||
|
||||
export default {
|
||||
queryClient,
|
||||
cacheKeys,
|
||||
invalidateCache,
|
||||
invalidateProductsCache,
|
||||
|
||||
@ -14,24 +14,9 @@ export const apiStatus = {
|
||||
connectionError: false,
|
||||
lastConnectionError: null as Error | null,
|
||||
isAuthenticated: false, // Флаг аутентификации пользователя
|
||||
|
||||
// Метод для проверки статуса соединения с API
|
||||
checkConnection: async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_API_URL}/health`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
apiStatus.connectionError = !response.ok;
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
apiStatus.connectionError = true;
|
||||
apiStatus.lastConnectionError = error as Error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error'
|
||||
} as const;
|
||||
|
||||
// Для отладки выводим URL API в консоль
|
||||
if (apiStatus.debugMode) {
|
||||
@ -133,11 +118,15 @@ instance.interceptors.response.use(
|
||||
);
|
||||
|
||||
// Общий интерфейс для ответа API
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// API клиент
|
||||
@ -219,8 +208,7 @@ export async function fetchApi<T>(
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.detail || 'Ошибка при запросе к API',
|
||||
message: data.message || 'Произошла ошибка при выполнении запроса',
|
||||
data: data as T,
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,8 +219,7 @@ export async function fetchApi<T>(
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Неизвестная ошибка',
|
||||
message: 'Произошла ошибка при выполнении запроса',
|
||||
data: {} as T,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,19 +5,7 @@ import { apiStatus } from './api';
|
||||
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 мс между запросами
|
||||
@ -29,39 +17,27 @@ 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> => {
|
||||
// Функция для получения данных о продукте без кеширования
|
||||
const getProductDetails = 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);
|
||||
@ -185,7 +161,7 @@ class CartStore {
|
||||
this.syncRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (apiStatus.isAuthenticated) {
|
||||
// Синхронизация с сервером отключена, используем только локальную корзину
|
||||
return;
|
||||
@ -198,17 +174,17 @@ class CartStore {
|
||||
private recalculateCart(): void {
|
||||
// Правильно подсчитываем количество товаров
|
||||
this.cart.items_count = this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
|
||||
// Пересчитываем общую стоимость
|
||||
this.cart.total_amount = this.cart.items.reduce(
|
||||
(sum, item) => sum + (item.price || 0) * item.quantity,
|
||||
(sum, item) => sum + (item.price || 0) * item.quantity,
|
||||
0
|
||||
);
|
||||
|
||||
|
||||
// НЕ сохраняем и НЕ уведомляем здесь
|
||||
// this.saveToStorage();
|
||||
// this.notify();
|
||||
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log(`Корзина обновлена: ${this.cart.items_count} товаров на сумму ${this.cart.total_amount}`);
|
||||
}
|
||||
@ -225,10 +201,10 @@ class CartStore {
|
||||
// Создаем копию корзины
|
||||
const newCart = { ...this.cart, items: [...this.cart.items] };
|
||||
|
||||
// Загружаем данные о продукте из кэша или API
|
||||
// Загружаем данные о продукте из API
|
||||
let productData: ProductDetails | null = null;
|
||||
try {
|
||||
productData = await getCachedProductDetails(item.product_id);
|
||||
productData = await getProductDetails(item.product_id);
|
||||
} catch (error) {
|
||||
console.error(`Не удалось загрузить данные о продукте ${item.product_id}:`, error);
|
||||
}
|
||||
@ -240,7 +216,7 @@ class CartStore {
|
||||
if (index === existingItemIndex) {
|
||||
// Только обновляем количество и общую цену, остальные данные не трогаем
|
||||
const newQuantity = cartItem.quantity + item.quantity;
|
||||
|
||||
|
||||
return {
|
||||
...cartItem, // Сохраняем старые ссылки на product_name, product_image и т.д.
|
||||
quantity: newQuantity,
|
||||
@ -250,13 +226,13 @@ class CartStore {
|
||||
return cartItem; // Возвращаем старый объект для неизмененных элементов
|
||||
});
|
||||
newCart.items = updatedItems; // Заменяем массив items на обновленный
|
||||
|
||||
|
||||
} else {
|
||||
// Если товара нет, добавляем новый с правильными данными
|
||||
const tempId = Date.now(); // Используем временный ID для нового элемента
|
||||
const variant = productData?.variants?.find(v => v.id === item.variant_id);
|
||||
const variantName = variant?.size?.name || 'Вариант';
|
||||
|
||||
|
||||
newCart.items.push({
|
||||
id: tempId,
|
||||
product_id: item.product_id,
|
||||
@ -267,8 +243,8 @@ class CartStore {
|
||||
price: productData?.discount_price || productData?.price || 0,
|
||||
total_price: (productData?.discount_price || productData?.price || 0) * item.quantity,
|
||||
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
|
||||
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
|
||||
: undefined)
|
||||
});
|
||||
}
|
||||
@ -307,10 +283,10 @@ class CartStore {
|
||||
const newItems = this.cart.items.map(item => {
|
||||
if (item.id === itemId) {
|
||||
// Возвращаем новый объект только для измененного элемента
|
||||
return {
|
||||
...item,
|
||||
return {
|
||||
...item,
|
||||
quantity,
|
||||
total_price: item.price * quantity
|
||||
total_price: item.price * quantity
|
||||
};
|
||||
}
|
||||
// Возвращаем старый объект для неизмененных элементов
|
||||
@ -395,13 +371,7 @@ class CartStore {
|
||||
return this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
// Очистка кэша продуктов (для тестирования)
|
||||
clearProductCache(): void {
|
||||
Object.keys(productCache).forEach(key => {
|
||||
delete productCache[parseInt(key)];
|
||||
});
|
||||
console.log('Кэш продуктов очищен');
|
||||
}
|
||||
// Метод очистки кэша продуктов удален, так как кеширование реализовано на бэкенде
|
||||
}
|
||||
|
||||
// Создаем экземпляр хранилища
|
||||
|
||||
@ -58,8 +58,8 @@ const catalogAdminService = {
|
||||
// Категории
|
||||
getCategories: 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[] }>('/catalog/categories');
|
||||
return response.categories || [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении категорий:', error);
|
||||
return [];
|
||||
@ -68,8 +68,8 @@ const catalogAdminService = {
|
||||
|
||||
getCategory: async (id: number): Promise<Category | null> => {
|
||||
try {
|
||||
const response = await api.get<Category>(`/catalog/categories/${id}`);
|
||||
return response as Category;
|
||||
const response = await api.get<{ success: boolean, category: Category }>(`/catalog/categories/${id}`);
|
||||
return response.category || null;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении категории ${id}:`, error);
|
||||
return null;
|
||||
@ -112,8 +112,8 @@ const catalogAdminService = {
|
||||
limit?: number;
|
||||
}): Promise<Collection[]> => {
|
||||
try {
|
||||
const response = await api.get<Collection[]>('/catalog/collections', { params });
|
||||
return response as Collection[];
|
||||
const response = await api.get<{ success: boolean, collections: Collection[] }>('/catalog/collections', { params });
|
||||
return response.collections || [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении коллекций:', error);
|
||||
return [];
|
||||
@ -122,8 +122,8 @@ const catalogAdminService = {
|
||||
|
||||
getCollection: async (id: number): Promise<Collection | null> => {
|
||||
try {
|
||||
const response = await api.get<Collection>(`/catalog/collections/${id}`);
|
||||
return response as Collection;
|
||||
const response = await api.get<{ success: boolean, collection: Collection }>(`/catalog/collections/${id}`);
|
||||
return response.collection || null;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении коллекции ${id}:`, error);
|
||||
return null;
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import api, { apiStatus, PUBLIC_BASE_URL, ApiResponse } from './api';
|
||||
|
||||
// Импортируем типы для коллекций и категорий
|
||||
import { CategoryUpdate, CollectionCreate, CollectionUpdate } from './catalog-admin';
|
||||
|
||||
export interface ProductReport {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -26,6 +29,8 @@ export interface Product {
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
primary_image?: string;
|
||||
category_name?: string;
|
||||
collection_name?: string;
|
||||
}
|
||||
|
||||
// Интерфейс для ответа API со списком продуктов
|
||||
@ -140,8 +145,8 @@ export interface Collection {
|
||||
export interface Size {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string; // Изменено с value на code для соответствия бэкенду
|
||||
value?: string; // Оставляем для обратной совместимости
|
||||
code: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
created_at?: string;
|
||||
@ -150,7 +155,7 @@ export interface Size {
|
||||
|
||||
export interface SizeCreate {
|
||||
name: string;
|
||||
code: string; // Изменено с value на code для соответствия бэкенду
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
@ -158,7 +163,7 @@ export interface SizeCreate {
|
||||
export interface SizeUpdate {
|
||||
id: number;
|
||||
name?: string;
|
||||
code?: string; // Изменено с value на code для соответствия бэкенду
|
||||
code?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
@ -198,27 +203,156 @@ export interface CategoryCreate {
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryUpdate {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
// Параметры для запроса списка продуктов
|
||||
export interface GetProductsParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
category_id?: number;
|
||||
collection_id?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface CollectionCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
// API функции для работы с продуктами
|
||||
export async function getProducts(params: GetProductsParams): Promise<ProductsResponse> {
|
||||
try {
|
||||
const response = await api.get<{success: boolean, products: Product[], total: number, skip: number, limit: number}>('/catalog/products', { params });
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Ответ API getProducts:', response);
|
||||
}
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'products' in response) {
|
||||
return {
|
||||
products: response.products,
|
||||
total: response.total,
|
||||
page_size: response.limit,
|
||||
current_page: Math.floor(response.skip / response.limit) + 1
|
||||
};
|
||||
}
|
||||
|
||||
// Запасной вариант для обратной совместимости
|
||||
if (response && typeof response === 'object') {
|
||||
if ('data' in response && typeof response.data === 'object') {
|
||||
const data = response.data as any;
|
||||
if ('products' in data && Array.isArray(data.products)) {
|
||||
return {
|
||||
products: data.products,
|
||||
total: typeof data.total === 'number' ? data.total : 0,
|
||||
page_size: typeof data.page_size === 'number' ? data.page_size : undefined,
|
||||
current_page: typeof data.current_page === 'number' ? data.current_page : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Если response само является ProductsResponse
|
||||
if ('products' in response && Array.isArray(response.products)) {
|
||||
const typedResponse = response as any;
|
||||
return {
|
||||
products: typedResponse.products,
|
||||
total: typeof typedResponse.total === 'number' ? typedResponse.total : 0,
|
||||
page_size: typeof typedResponse.page_size === 'number' ? typedResponse.page_size : undefined,
|
||||
current_page: typeof typedResponse.current_page === 'number' ? typedResponse.current_page : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API:', response);
|
||||
return { products: [], total: 0 };
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении продуктов:', error);
|
||||
return { products: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export interface CollectionUpdate {
|
||||
id: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
export async function getProduct(id: number): Promise<ProductDetails> {
|
||||
try {
|
||||
const response = await api.get<{success: boolean, product: ProductDetails}>(`/catalog/products/${id}`);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'product' in response) {
|
||||
return response.product;
|
||||
}
|
||||
|
||||
throw new Error('Неверный формат ответа API при получении продукта');
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении продукта ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProduct(data: ProductCreateComplete): Promise<ProductDetails> {
|
||||
try {
|
||||
const response = await api.post<{success: boolean, product: ProductDetails}>('/catalog/products', data);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'product' in response) {
|
||||
return response.product;
|
||||
}
|
||||
|
||||
throw new Error('Неверный формат ответа API при создании продукта');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании продукта:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProduct(id: number, data: ProductUpdateComplete): Promise<ProductDetails> {
|
||||
try {
|
||||
const response = await api.put<{success: boolean, product: ProductDetails}>(`/catalog/products/${id}`, data);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'product' in response) {
|
||||
return response.product;
|
||||
}
|
||||
|
||||
throw new Error('Неверный формат ответа API при обновлении продукта');
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при обновлении продукта ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: number): Promise<void> {
|
||||
await api.delete(`/catalog/products/${id}`);
|
||||
}
|
||||
|
||||
// API функции для работы с категориями
|
||||
export async function getCategories(): Promise<Category[]> {
|
||||
const response = await api.get<ApiResponse<{ categories: Category[] }>>('/catalog/categories');
|
||||
return response.data.categories;
|
||||
}
|
||||
|
||||
// API функции для работы с коллекциями
|
||||
export async function getCollections(): Promise<Collection[]> {
|
||||
const response = await api.get<ApiResponse<CollectionsResponse>>('/catalog/collections');
|
||||
return response.data.collections;
|
||||
}
|
||||
|
||||
// API функции для работы с размерами
|
||||
export async function getSizes(): Promise<Size[]> {
|
||||
const response = await api.get<ApiResponse<{ sizes: Size[] }>>('/catalog/sizes');
|
||||
return response.data.sizes;
|
||||
}
|
||||
|
||||
// Функция для загрузки изображения
|
||||
export async function uploadProductImage(file: File): Promise<string> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post<{success: boolean, url: string}>('/catalog/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'url' in response) {
|
||||
return response.url;
|
||||
}
|
||||
|
||||
throw new Error('Неверный формат ответа API при загрузке изображения');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке изображения:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -842,23 +976,34 @@ const catalogService = {
|
||||
console.log(`Обновление товара #${productId} с вариантами и изображениями:`, data);
|
||||
}
|
||||
|
||||
const response = await api.put<{success: boolean; product?: ProductDetails; error?: string}>(`/catalog/products/${productId}/complete`, data);
|
||||
const response = await api.put<{success: boolean; product?: ProductDetails; error?: string} | ProductDetails>(`/catalog/products/${productId}/complete`, data);
|
||||
|
||||
if (apiStatus.debugMode) {
|
||||
console.log('Результат обновления товара:', response);
|
||||
}
|
||||
|
||||
// Проверяем успешность и структуру ответа
|
||||
if (!response || response.success === false) {
|
||||
throw new Error(response?.error || 'Ошибка при обновлении товара');
|
||||
// Проверяем структуру ответа - может быть двух форматов
|
||||
if (response && typeof response === 'object') {
|
||||
// Формат { success, product, error }
|
||||
if ('success' in response) {
|
||||
if (response.success === false) {
|
||||
throw new Error(response.error || 'Ошибка при обновлении товара');
|
||||
}
|
||||
|
||||
if (!response.product) {
|
||||
throw new Error('Сервер не вернул данные об обновленном товаре');
|
||||
}
|
||||
|
||||
return processProductImages(response.product);
|
||||
}
|
||||
|
||||
// Формат прямого ответа ProductDetails
|
||||
if ('id' in response && 'name' in response) {
|
||||
return processProductImages(response as ProductDetails);
|
||||
}
|
||||
}
|
||||
|
||||
// Если успешно, но нет продукта в ответе
|
||||
if (!response.product) {
|
||||
throw new Error('Сервер не вернул данные об обновленном товаре');
|
||||
}
|
||||
|
||||
return processProductImages(response.product);
|
||||
throw new Error('Неизвестный формат ответа API при обновлении товара');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении товара:', error);
|
||||
throw error;
|
||||
@ -1000,10 +1145,13 @@ export const collectionService = {
|
||||
// Получение списка коллекций
|
||||
getCollections: async (): Promise<Collection[]> => {
|
||||
try {
|
||||
const response = await api.get<ApiResponse<Collection[]>>('/catalog/collections');
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const response = await api.get<{success: boolean, collections: Collection[], total: number}>('/catalog/collections');
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'collections' in response) {
|
||||
return response.collections;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API для коллекций:', response);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении коллекций:', error);
|
||||
@ -1014,10 +1162,13 @@ export const collectionService = {
|
||||
// Получение коллекции по ID
|
||||
getCollectionById: async (id: number): Promise<Collection | null> => {
|
||||
try {
|
||||
const response = await api.get<ApiResponse<Collection>>(`/catalog/collections/${id}`);
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const response = await api.get<{success: boolean, collection: Collection}>(`/catalog/collections/${id}`);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'collection' in response) {
|
||||
return response.collection;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API для коллекции:', response);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении коллекции ${id}:`, error);
|
||||
@ -1028,10 +1179,13 @@ export const collectionService = {
|
||||
// Создание коллекции
|
||||
createCollection: async (data: CollectionCreate): Promise<Collection | null> => {
|
||||
try {
|
||||
const response = await api.post<ApiResponse<Collection>>('/catalog/collections', data);
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const response = await api.post<{success: boolean, collection: Collection}>('/catalog/collections', data);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'collection' in response) {
|
||||
return response.collection;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API при создании коллекции:', response);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании коллекции:', error);
|
||||
@ -1040,15 +1194,18 @@ export const collectionService = {
|
||||
},
|
||||
|
||||
// Обновление коллекции
|
||||
updateCollection: async (data: CollectionUpdate): Promise<Collection | null> => {
|
||||
updateCollection: async (id: number, data: CollectionUpdate): Promise<Collection | null> => {
|
||||
try {
|
||||
const response = await api.put<ApiResponse<Collection>>(`/catalog/collections/${data.id}`, data);
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const response = await api.put<{success: boolean, collection: Collection}>(`/catalog/collections/${id}`, data);
|
||||
|
||||
if (response && typeof response === 'object' && 'success' in response && response.success && 'collection' in response) {
|
||||
return response.collection;
|
||||
}
|
||||
|
||||
console.error('Неизвестный формат ответа API при обновлении коллекции:', response);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при обновлении коллекции ${data.id}:`, error);
|
||||
console.error(`Ошибка при обновлении коллекции ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@ -1056,8 +1213,8 @@ export const collectionService = {
|
||||
// Удаление коллекции
|
||||
deleteCollection: async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
const response = await api.delete<ApiResponse<void>>(`/catalog/collections/${id}`);
|
||||
return response.success;
|
||||
const response = await api.delete<{success: boolean}>(`/catalog/collections/${id}`);
|
||||
return response && typeof response === 'object' && 'success' in response && response.success;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при удалении коллекции ${id}:`, error);
|
||||
return false;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { OrderCreate, Order } from "@/types/order";
|
||||
import { apiStatus } from "./api";
|
||||
import { OrderCreate, Order, OrderItem } from "@/types/order";
|
||||
import { apiStatus, PUBLIC_API_URL } from "./api";
|
||||
|
||||
/**
|
||||
* Сервис для работы с заказами
|
||||
@ -12,90 +12,337 @@ class OrderService {
|
||||
*/
|
||||
async createOrder(orderData: OrderCreate): Promise<Order> {
|
||||
try {
|
||||
// На данный момент это заглушка, позже будет реальный API вызов
|
||||
console.log("Создание заказа с данными:", orderData);
|
||||
|
||||
// Генерируем номер заказа
|
||||
const orderId = `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
|
||||
// Рассчитываем стоимость
|
||||
const subtotal = orderData.items.reduce((total, item) => {
|
||||
return total + (item.price || 0) * item.quantity;
|
||||
}, 0);
|
||||
|
||||
const shippingCost = subtotal > 5000 ? 0 : (orderData.deliveryMethod === "courier" ? 400 : 300);
|
||||
const total = subtotal + shippingCost;
|
||||
|
||||
// Имитируем задержку сервера
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// В реальном приложении здесь был бы API запрос к бэкенду
|
||||
// const response = await fetch('/api/orders', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json'
|
||||
// },
|
||||
// body: JSON.stringify(orderData)
|
||||
// });
|
||||
// const data = await response.json();
|
||||
|
||||
// Возвращаем смоделированный ответ
|
||||
// Подробное логирование исходных данных
|
||||
console.log("%c 🛒 ОФОРМЛЕНИЕ ЗАКАЗА: Исходные данные", "background: #4CAF50; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.log("Полные данные заказа:", orderData);
|
||||
console.log("Информация о пользователе:", orderData.userInfo);
|
||||
console.log("Адрес доставки:", orderData.address);
|
||||
console.log("Способ доставки:", orderData.deliveryMethod);
|
||||
console.log("Способ оплаты:", orderData.paymentMethod);
|
||||
console.log("Товары в заказе:", orderData.items);
|
||||
|
||||
// Если это CDEK, логируем информацию о пункте выдачи
|
||||
if (orderData.deliveryMethod === "cdek" && orderData.address.cdekPvz) {
|
||||
console.log("%c 📦 CDEK: Информация о пункте выдачи", "background: #2196F3; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.log("ПВЗ CDEK:", orderData.address.cdekPvz);
|
||||
console.log("Тариф CDEK:", orderData.address.cdekTariff);
|
||||
console.log("Тип доставки CDEK:", orderData.address.cdekDeliveryType);
|
||||
}
|
||||
|
||||
// Преобразуем данные в формат, ожидаемый бэкендом
|
||||
const backendOrderData = {
|
||||
// Информация о пользователе
|
||||
user_info: {
|
||||
first_name: orderData.userInfo.firstName,
|
||||
last_name: orderData.userInfo.lastName,
|
||||
email: orderData.userInfo.email,
|
||||
phone: orderData.userInfo.phone
|
||||
},
|
||||
|
||||
// Информация о доставке
|
||||
delivery: {
|
||||
method: orderData.deliveryMethod,
|
||||
address: {
|
||||
city: orderData.address.city,
|
||||
street: orderData.address.street,
|
||||
house: orderData.address.house,
|
||||
apartment: orderData.address.apartment,
|
||||
postal_code: orderData.address.postalCode,
|
||||
formatted_address: orderData.address.formattedAddress
|
||||
},
|
||||
// Если это доставка CDEK, добавляем информацию о пункте выдачи
|
||||
cdek_info: orderData.deliveryMethod === "cdek" ? {
|
||||
pvz: orderData.address.cdekPvz,
|
||||
tariff: orderData.address.cdekTariff,
|
||||
delivery_type: orderData.address.cdekDeliveryType
|
||||
} : undefined
|
||||
},
|
||||
|
||||
// Информация о товарах
|
||||
items: orderData.items.map((item: OrderItem) => ({
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity,
|
||||
price: item.price
|
||||
})),
|
||||
|
||||
// Способ оплаты
|
||||
payment_method: orderData.paymentMethod,
|
||||
|
||||
// Комментарий к заказу
|
||||
comment: orderData.comment
|
||||
};
|
||||
|
||||
// Логируем данные, которые будут отправлены на бэкенд
|
||||
console.log("%c 📤 ОТПРАВКА НА БЭКЕНД", "background: #FF9800; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.log("Данные для бэкенда:", JSON.stringify(backendOrderData, null, 2));
|
||||
|
||||
// Отправляем запрос на бэкенд
|
||||
const response = await fetch(`${PUBLIC_API_URL}/orders/new`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
// Добавляем токен авторизации, если пользователь авторизован
|
||||
...(apiStatus.isAuthenticated ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {})
|
||||
},
|
||||
body: JSON.stringify(backendOrderData)
|
||||
});
|
||||
|
||||
// Если ответ не успешный, выбрасываем ошибку
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Ошибка при создании заказа: ${response.status}`);
|
||||
}
|
||||
|
||||
// Получаем данные ответа
|
||||
const responseData = await response.json();
|
||||
|
||||
// Логируем ответ от сервера
|
||||
console.log("%c 📥 ОТВЕТ ОТ БЭКЕНДА", "background: #9C27B0; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.log("Ответ от сервера:", responseData);
|
||||
|
||||
// Преобразуем ответ в формат Order
|
||||
const order: Order = {
|
||||
orderId,
|
||||
userId: apiStatus.isAuthenticated ? 1 : undefined, // ID пользователя, если он авторизован
|
||||
orderId: responseData.order_id || responseData.id || `ORD-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||
userId: responseData.user_id || (apiStatus.isAuthenticated ? 1 : undefined),
|
||||
userInfo: orderData.userInfo,
|
||||
items: orderData.items,
|
||||
address: orderData.address,
|
||||
deliveryMethod: orderData.deliveryMethod,
|
||||
paymentMethod: orderData.paymentMethod,
|
||||
comment: orderData.comment,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
total,
|
||||
status: 'new',
|
||||
createdAt: new Date().toISOString()
|
||||
subtotal: responseData.subtotal || responseData.total_amount || 0,
|
||||
shippingCost: responseData.shipping_cost || 0,
|
||||
total: responseData.total || responseData.total_amount || 0,
|
||||
status: responseData.status || 'new',
|
||||
createdAt: responseData.created_at || new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
// Логируем преобразованный объект заказа
|
||||
console.log("%c 🎁 ИТОГОВЫЙ ОБЪЕКТ ЗАКАЗА", "background: #673AB7; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.log("Преобразованный объект заказа:", order);
|
||||
|
||||
return order;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при создании заказа:", error);
|
||||
throw new Error("Не удалось создать заказ");
|
||||
// Подробное логирование ошибок
|
||||
console.log("%c ❌ ОШИБКА ПРИ СОЗДАНИИ ЗАКАЗА", "background: #F44336; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
console.error("Детали ошибки:", error);
|
||||
|
||||
// Если это ошибка от fetch API
|
||||
if (error instanceof Response) {
|
||||
try {
|
||||
const errorData = await error.json();
|
||||
console.error("Ответ сервера с ошибкой:", errorData);
|
||||
} catch (e) {
|
||||
console.error("Не удалось распарсить ответ с ошибкой:", e);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(error instanceof Error ? error.message : "Не удалось создать заказ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Получает список заказов пользователя
|
||||
*/
|
||||
async getUserOrders(): Promise<Order[]> {
|
||||
try {
|
||||
// В реальном приложении здесь был бы API запрос к бэкенду
|
||||
// const response = await fetch('/api/orders');
|
||||
// const data = await response.json();
|
||||
|
||||
// Пока возвращаем пустой массив
|
||||
return [];
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!apiStatus.isAuthenticated) {
|
||||
console.warn("Попытка получить заказы неавторизованного пользователя");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Отправляем запрос на бэкенд
|
||||
const response = await fetch(`${PUBLIC_API_URL}/orders`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
// Если ответ не успешный, выбрасываем ошибку
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Ошибка при получении заказов: ${response.status}`);
|
||||
}
|
||||
|
||||
// Получаем данные ответа
|
||||
const responseData = await response.json();
|
||||
|
||||
// Преобразуем ответ в формат Order[]
|
||||
const orders: Order[] = responseData.orders?.map((orderData: any) => ({
|
||||
orderId: orderData.order_id || orderData.id || '',
|
||||
userId: orderData.user_id,
|
||||
userInfo: {
|
||||
firstName: orderData.user_info?.first_name || '',
|
||||
lastName: orderData.user_info?.last_name || '',
|
||||
email: orderData.user_info?.email || '',
|
||||
phone: orderData.user_info?.phone || ''
|
||||
},
|
||||
items: orderData.items?.map((item: any) => ({
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
name: item.product_name || '',
|
||||
image: item.product_image || ''
|
||||
})) || [],
|
||||
address: {
|
||||
city: orderData.delivery?.address?.city || '',
|
||||
street: orderData.delivery?.address?.street || '',
|
||||
house: orderData.delivery?.address?.house || '',
|
||||
apartment: orderData.delivery?.address?.apartment || '',
|
||||
postalCode: orderData.delivery?.address?.postal_code || '',
|
||||
formattedAddress: orderData.delivery?.address?.formatted_address || ''
|
||||
},
|
||||
deliveryMethod: orderData.delivery?.method || 'courier',
|
||||
paymentMethod: orderData.payment_method || 'card',
|
||||
comment: orderData.comment || '',
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shipping_cost || 0,
|
||||
total: orderData.total || orderData.total_amount || 0,
|
||||
status: orderData.status || 'new',
|
||||
createdAt: orderData.created_at || '',
|
||||
updatedAt: orderData.updated_at || ''
|
||||
})) || [];
|
||||
|
||||
return orders;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при получении заказов:", error);
|
||||
throw new Error("Не удалось получить список заказов");
|
||||
throw new Error(error instanceof Error ? error.message : "Не удалось получить список заказов");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Получает заказ по номеру
|
||||
* Отладочная функция для вывода структуры заказа в консоль
|
||||
* Используйте эту функцию для анализа структуры данных заказа
|
||||
*/
|
||||
debugOrderStructure(orderData: OrderCreate): void {
|
||||
console.log("%c 🔍 ОТЛАДКА СТРУКТУРЫ ЗАКАЗА", "background: #607D8B; color: white; padding: 2px 5px; border-radius: 3px;");
|
||||
|
||||
// Выводим общую структуру
|
||||
console.log("Структура заказа:", {
|
||||
userInfo: orderData.userInfo,
|
||||
address: orderData.address,
|
||||
deliveryMethod: orderData.deliveryMethod,
|
||||
paymentMethod: orderData.paymentMethod,
|
||||
items: orderData.items,
|
||||
comment: orderData.comment
|
||||
});
|
||||
|
||||
// Выводим JSON для копирования в код
|
||||
console.log("JSON для копирования:");
|
||||
console.log(JSON.stringify(orderData, null, 2));
|
||||
|
||||
// Выводим структуру для бэкенда
|
||||
const backendStructure = {
|
||||
user_info: {
|
||||
first_name: orderData.userInfo.firstName,
|
||||
last_name: orderData.userInfo.lastName,
|
||||
email: orderData.userInfo.email,
|
||||
phone: orderData.userInfo.phone
|
||||
},
|
||||
delivery: {
|
||||
method: orderData.deliveryMethod,
|
||||
address: {
|
||||
city: orderData.address.city,
|
||||
street: orderData.address.street,
|
||||
house: orderData.address.house,
|
||||
apartment: orderData.address.apartment,
|
||||
postal_code: orderData.address.postalCode,
|
||||
formatted_address: orderData.address.formattedAddress
|
||||
},
|
||||
cdek_info: orderData.deliveryMethod === "cdek" ? {
|
||||
pvz: orderData.address.cdekPvz,
|
||||
tariff: orderData.address.cdekTariff,
|
||||
delivery_type: orderData.address.cdekDeliveryType
|
||||
} : undefined
|
||||
},
|
||||
items: orderData.items.map((item: OrderItem) => ({
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity,
|
||||
price: item.price
|
||||
})),
|
||||
payment_method: orderData.paymentMethod,
|
||||
comment: orderData.comment
|
||||
};
|
||||
|
||||
console.log("Структура для бэкенда:");
|
||||
console.log(JSON.stringify(backendStructure, null, 2));
|
||||
}
|
||||
|
||||
async getOrderById(orderId: string): Promise<Order | null> {
|
||||
try {
|
||||
// В реальном приложении здесь был бы API запрос к бэкенду
|
||||
// const response = await fetch(`/api/orders/${orderId}`);
|
||||
// const data = await response.json();
|
||||
|
||||
// Пока возвращаем null
|
||||
return null;
|
||||
// Отправляем запрос на бэкенд
|
||||
const response = await fetch(`${PUBLIC_API_URL}/orders/${orderId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
// Добавляем токен авторизации, если пользователь авторизован
|
||||
...(apiStatus.isAuthenticated ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {})
|
||||
}
|
||||
});
|
||||
|
||||
// Если ответ не успешный, выбрасываем ошибку
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null; // Заказ не найден
|
||||
}
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Ошибка при получении заказа: ${response.status}`);
|
||||
}
|
||||
|
||||
// Получаем данные ответа
|
||||
const orderData = await response.json();
|
||||
|
||||
// Преобразуем ответ в формат Order
|
||||
const order: Order = {
|
||||
orderId: orderData.order_id || orderData.id || '',
|
||||
userId: orderData.user_id,
|
||||
userInfo: {
|
||||
firstName: orderData.user_info?.first_name || '',
|
||||
lastName: orderData.user_info?.last_name || '',
|
||||
email: orderData.user_info?.email || '',
|
||||
phone: orderData.user_info?.phone || ''
|
||||
},
|
||||
items: orderData.items?.map((item: any) => ({
|
||||
product_id: item.product_id,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
name: item.product_name || '',
|
||||
image: item.product_image || ''
|
||||
})) || [],
|
||||
address: {
|
||||
city: orderData.delivery?.address?.city || '',
|
||||
street: orderData.delivery?.address?.street || '',
|
||||
house: orderData.delivery?.address?.house || '',
|
||||
apartment: orderData.delivery?.address?.apartment || '',
|
||||
postalCode: orderData.delivery?.address?.postal_code || '',
|
||||
formattedAddress: orderData.delivery?.address?.formatted_address || ''
|
||||
},
|
||||
deliveryMethod: orderData.delivery?.method || 'courier',
|
||||
paymentMethod: orderData.payment_method || 'card',
|
||||
comment: orderData.comment || '',
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shipping_cost || 0,
|
||||
total: orderData.total || orderData.total_amount || 0,
|
||||
status: orderData.status || 'new',
|
||||
createdAt: orderData.created_at || '',
|
||||
updatedAt: orderData.updated_at || '',
|
||||
trackingNumber: orderData.tracking_number || ''
|
||||
};
|
||||
|
||||
return order;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении заказа ${orderId}:`, error);
|
||||
throw new Error("Не удалось получить информацию о заказе");
|
||||
throw new Error(error instanceof Error ? error.message : "Не удалось получить информацию о заказе");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const orderService = new OrderService();
|
||||
export const orderService = new OrderService();
|
||||
118
frontend/lib/reducers/product.ts
Normal file
118
frontend/lib/reducers/product.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { ProductState, ProductAction } from '../types/product';
|
||||
|
||||
const initialState: ProductState = {
|
||||
products: [],
|
||||
loading: {
|
||||
fetch: false,
|
||||
delete: false,
|
||||
update: false
|
||||
},
|
||||
error: null,
|
||||
selectedProducts: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
},
|
||||
filters: {
|
||||
search: '',
|
||||
category: '',
|
||||
collection: '',
|
||||
active: null
|
||||
}
|
||||
};
|
||||
|
||||
export function productReducer(state: ProductState = initialState, action: ProductAction): ProductState {
|
||||
switch (action.type) {
|
||||
case 'FETCH_PRODUCTS_START':
|
||||
return {
|
||||
...state,
|
||||
loading: { ...state.loading, fetch: true },
|
||||
error: null
|
||||
};
|
||||
|
||||
case 'FETCH_PRODUCTS_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
products: action.payload.products,
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
total: action.payload.total
|
||||
},
|
||||
loading: { ...state.loading, fetch: false },
|
||||
error: null
|
||||
};
|
||||
|
||||
case 'FETCH_PRODUCTS_ERROR':
|
||||
return {
|
||||
...state,
|
||||
loading: { ...state.loading, fetch: false },
|
||||
error: action.payload
|
||||
};
|
||||
|
||||
case 'SELECT_PRODUCTS':
|
||||
return {
|
||||
...state,
|
||||
selectedProducts: action.payload
|
||||
};
|
||||
|
||||
case 'SET_FILTER':
|
||||
return {
|
||||
...state,
|
||||
filters: {
|
||||
...state.filters,
|
||||
[action.payload.key]: action.payload.value
|
||||
},
|
||||
// Сброс пагинации при изменении фильтров
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
page: 1
|
||||
}
|
||||
};
|
||||
|
||||
case 'SET_PAGE':
|
||||
return {
|
||||
...state,
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
page: action.payload
|
||||
}
|
||||
};
|
||||
|
||||
case 'SET_PAGE_SIZE':
|
||||
return {
|
||||
...state,
|
||||
pagination: {
|
||||
...state.pagination,
|
||||
pageSize: action.payload,
|
||||
page: 1 // Сброс на первую страницу при изменении размера
|
||||
}
|
||||
};
|
||||
|
||||
case 'DELETE_PRODUCTS_START':
|
||||
return {
|
||||
...state,
|
||||
loading: { ...state.loading, delete: true },
|
||||
error: null
|
||||
};
|
||||
|
||||
case 'DELETE_PRODUCTS_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
products: state.products.filter(p => !action.payload.includes(p.id)),
|
||||
selectedProducts: [],
|
||||
loading: { ...state.loading, delete: false },
|
||||
error: null
|
||||
};
|
||||
|
||||
case 'DELETE_PRODUCTS_ERROR':
|
||||
return {
|
||||
...state,
|
||||
loading: { ...state.loading, delete: false },
|
||||
error: action.payload
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
65
frontend/lib/types/product.ts
Normal file
65
frontend/lib/types/product.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Product } from '@/lib/catalog';
|
||||
|
||||
export interface ProductState {
|
||||
products: Product[];
|
||||
loading: {
|
||||
fetch: boolean;
|
||||
delete: boolean;
|
||||
update: boolean;
|
||||
};
|
||||
error: Error | null;
|
||||
selectedProducts: number[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
filters: {
|
||||
search: string;
|
||||
category: string;
|
||||
collection: string;
|
||||
active: boolean | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type ProductAction =
|
||||
| { type: 'FETCH_PRODUCTS_START' }
|
||||
| { type: 'FETCH_PRODUCTS_SUCCESS'; payload: { products: Product[]; total: number } }
|
||||
| { type: 'FETCH_PRODUCTS_ERROR'; payload: Error }
|
||||
| { type: 'SELECT_PRODUCTS'; payload: number[] }
|
||||
| { type: 'SET_FILTER'; payload: { key: keyof ProductState['filters']; value: any } }
|
||||
| { type: 'SET_PAGE'; payload: number }
|
||||
| { type: 'SET_PAGE_SIZE'; payload: number }
|
||||
| { type: 'DELETE_PRODUCTS_START' }
|
||||
| { type: 'DELETE_PRODUCTS_SUCCESS'; payload: number[] }
|
||||
| { type: 'DELETE_PRODUCTS_ERROR'; payload: Error };
|
||||
|
||||
export interface ProductFormData {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
price: number;
|
||||
discount_price: number | null;
|
||||
care_instructions: Record<string, string>;
|
||||
is_active: boolean;
|
||||
category_id: number;
|
||||
collection_id: number | null;
|
||||
variants: ProductVariantFormData[];
|
||||
images: ProductImageFormData[];
|
||||
}
|
||||
|
||||
export interface ProductVariantFormData {
|
||||
id?: number;
|
||||
size_id: number;
|
||||
sku: string;
|
||||
stock: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ProductImageFormData {
|
||||
id?: number;
|
||||
image_url: string;
|
||||
alt_text?: string;
|
||||
is_primary: boolean;
|
||||
file?: File;
|
||||
}
|
||||
105
frontend/package-lock.json
generated
105
frontend/package-lock.json
generated
@ -37,6 +37,9 @@
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tanstack/react-query": "^5.74.3",
|
||||
"@tanstack/react-query-devtools": "^5.74.3",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@types/axios": "^0.9.36",
|
||||
"@types/react": "latest",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@ -71,9 +74,11 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
@ -2120,6 +2125,92 @@
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.74.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.3.tgz",
|
||||
"integrity": "sha512-Mqk+5o3qTuAiZML248XpNH8r2cOzl15+LTbUsZQEwvSvn1GU4VQhvqzAbil36p+MBxpr/58oBSnRzhrBevDhfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.73.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.73.3.tgz",
|
||||
"integrity": "sha512-hBQyYwsOuO7QOprK75NzfrWs/EQYjgFA0yykmcvsV62q0t6Ua97CU3sYgjHx0ZvxkXSOMkY24VRJ5uv9f5Ik4w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.74.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.74.3.tgz",
|
||||
"integrity": "sha512-QrycUn0wxjVPzITvQvOxFRdhlAwIoOQSuav7qWD4SWCoKCdLbyRZ2vji2GuBq/glaxbF4wBx3fqcYRDOt8KDTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.74.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.74.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.3.tgz",
|
||||
"integrity": "sha512-H7TsOBB1fRCuuawrBzKMoIszqqILr2IN5oGLYMl7QG7ERJpMdc4hH8OwzBhVxJnmKeGwgtTQgcdKepfoJCWvFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.73.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.74.3",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz",
|
||||
"integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
||||
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/axios": {
|
||||
"version": "0.9.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz",
|
||||
@ -2189,6 +2280,13 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||
@ -2219,6 +2317,13 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
|
||||
@ -38,6 +38,9 @@
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tanstack/react-query": "^5.74.3",
|
||||
"@tanstack/react-query-devtools": "^5.74.3",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@types/axios": "^0.9.36",
|
||||
"@types/react": "latest",
|
||||
"autoprefixer": "^10.4.20",
|
||||
@ -72,9 +75,11 @@
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
|
||||
21
frontend/providers/AdminQueryProvider.tsx
Normal file
21
frontend/providers/AdminQueryProvider.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface AdminQueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Провайдер для react-query в админке
|
||||
* Оборачивает все компоненты админки и предоставляет доступ к QueryClient
|
||||
*/
|
||||
export function AdminQueryProvider({ children }: AdminQueryProviderProps) {
|
||||
// Используем существующий QueryClient из корневого провайдера
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default AdminQueryProvider;
|
||||
27
frontend/providers/Providers.tsx
Normal file
27
frontend/providers/Providers.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
// Создаем экземпляр QueryClient
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 минут
|
||||
gcTime: 10 * 60 * 1000, // 10 минут (раньше было cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
BIN
frontend/public/.DS_Store
vendored
BIN
frontend/public/.DS_Store
vendored
Binary file not shown.
11
frontend/types/catalog-admin.ts
Normal file
11
frontend/types/catalog-admin.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface CatalogAdminCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
parentId?: string | null;
|
||||
children?: CatalogAdminCategory[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
88
frontend/types/order.ts
Normal file
88
frontend/types/order.ts
Normal file
@ -0,0 +1,88 @@
|
||||
// Типы для пользовательской информации
|
||||
export interface UserInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
// Тип для режима доставки CDEK
|
||||
export type CdekDeliveryMode = "door" | "office";
|
||||
|
||||
// Типы для адреса
|
||||
export interface Address {
|
||||
city: string;
|
||||
street: string;
|
||||
house: string;
|
||||
apartment: string;
|
||||
postalCode: string;
|
||||
cdekPvz?: any; // Информация о пункте выдачи CDEK
|
||||
cdekTariff?: any; // Информация о тарифе CDEK
|
||||
cdekDeliveryType?: CdekDeliveryMode; // Тип доставки CDEK
|
||||
formattedAddress?: string; // Полный форматированный адрес
|
||||
geo_lat?: string; // Координаты для геолокации
|
||||
geo_lon?: string;
|
||||
fias_id?: string; // Идентификаторы ФИАС/КЛАДР
|
||||
kladr_id?: string;
|
||||
}
|
||||
|
||||
// Типы для элементов заказа
|
||||
export interface OrderItem {
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
quantity: number;
|
||||
price?: number;
|
||||
|
||||
// Дополнительные поля для отображения
|
||||
name?: string;
|
||||
image?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
slug?: string;
|
||||
total_price?: number;
|
||||
}
|
||||
|
||||
// Типы для способов доставки
|
||||
export type DeliveryMethod = "courier" | "cdek" | "pickup";
|
||||
|
||||
// Типы для способов оплаты
|
||||
export type PaymentMethod = "card" | "sbp" | "cash";
|
||||
|
||||
// Типы для статусов заказа
|
||||
export type OrderStatus = "new" | "processing" | "shipped" | "delivered" | "cancelled";
|
||||
|
||||
// Интерфейс для создания заказа
|
||||
export interface OrderCreate {
|
||||
userInfo: UserInfo;
|
||||
items: OrderItem[];
|
||||
address: Address;
|
||||
deliveryMethod: DeliveryMethod;
|
||||
paymentMethod: PaymentMethod;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// Интерфейс для заказа
|
||||
export interface Order {
|
||||
orderId: string;
|
||||
userId?: number;
|
||||
userInfo: UserInfo;
|
||||
items: OrderItem[];
|
||||
address: Address;
|
||||
deliveryMethod: DeliveryMethod;
|
||||
paymentMethod: PaymentMethod;
|
||||
comment?: string;
|
||||
subtotal: number;
|
||||
shippingCost: number;
|
||||
total: number;
|
||||
status: OrderStatus;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
trackingNumber?: string;
|
||||
}
|
||||
|
||||
// Интерфейс для обновления заказа
|
||||
export interface OrderUpdate {
|
||||
status?: OrderStatus;
|
||||
trackingNumber?: string;
|
||||
comment?: string;
|
||||
}
|
||||
138
nginx/nginx.conf
138
nginx/nginx.conf
@ -1,115 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Настройка буферов для улучшения производительности
|
||||
client_body_buffer_size 128k;
|
||||
client_max_body_size 20M;
|
||||
client_body_timeout 60s;
|
||||
|
||||
# Буферизация для проксирования
|
||||
proxy_buffers 16 32k;
|
||||
proxy_buffer_size 64k;
|
||||
proxy_busy_buffers_size 128k;
|
||||
proxy_temp_file_write_size 256k;
|
||||
|
||||
# Настройка сжатия
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/xml+rss
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
# Frontend (Next.js)
|
||||
location / {
|
||||
proxy_pass http://frontend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream fastapi {
|
||||
server fastapi:8000; # контейнер FastAPI по DNS-имени
|
||||
}
|
||||
upstream php {
|
||||
server php:80; # контейнер PHP
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Docs для API
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend:8000/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend:8000/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
location /api/fastapi/ {
|
||||
proxy_pass http://fastapi/; # проксируем на FastAPI
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Статические файлы и загруженные изображения
|
||||
location /uploads/ {
|
||||
alias /app/uploads/;
|
||||
|
||||
# Настройки кэширования для оптимизации загрузки
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
|
||||
# Настройки для эффективной отдачи статических файлов
|
||||
tcp_nodelay on;
|
||||
tcp_nopush on;
|
||||
sendfile on;
|
||||
sendfile_max_chunk 1m;
|
||||
|
||||
# Отключаем логирование для статики
|
||||
access_log off;
|
||||
|
||||
# Заголовки для изображений разных форматов
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|webp)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
add_header Pragma public;
|
||||
add_header Vary Accept-Encoding;
|
||||
access_log off;
|
||||
try_files $uri $uri/ =404;
|
||||
location /api/php/ {
|
||||
rewrite ^/api/php/(.*)$ /$1 break;
|
||||
proxy_pass http://php/; # проксируем на PHP
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
node_modules/.DS_Store
generated
vendored
BIN
node_modules/.DS_Store
generated
vendored
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user