заказы!!

This commit is contained in:
ilya_zahvatkin 2025-04-27 03:00:13 +07:00
parent 0a56297ad7
commit 9974d41bd8
490 changed files with 6828 additions and 50254 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

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

@ -0,0 +1 @@
node_modules

BIN
backend/.DS_Store vendored

Binary file not shown.

View File

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

View File

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

View File

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

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

View 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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 пользователя

View File

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

View File

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

View File

@ -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]] = []

View File

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

View File

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

View File

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

View File

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

View 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 # Для обработки изображений

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import AdminDashboard from './dashboard/page';
export default function AdminPage() {
redirect('/admin/dashboard');
return <AdminDashboard />;
}

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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: 'Корзина очищена',

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

View File

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

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

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

View File

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

View File

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

View File

@ -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('Кэш продуктов очищен');
}
// Метод очистки кэша продуктов удален, так как кеширование реализовано на бэкенде
}
// Создаем экземпляр хранилища

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

Binary file not shown.

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

View File

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

Binary file not shown.

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