в админке точно работают товары и категории, коллекции, остальное не работает.

добавил отображение товара с бд, также все товары с бд
This commit is contained in:
Zikil 2025-03-02 22:16:32 +07:00
parent 834d7f59da
commit 695643a874
56 changed files with 3656 additions and 1257 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

116
backend/alembic.ini Normal file
View File

@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql://postgres:postgres@localhost:5434/shop_db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

Binary file not shown.

85
backend/alembic/env.py Normal file
View File

@ -0,0 +1,85 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.core import Base
from app.models.catalog_models import *
from app.models.order_models import *
from app.models.user_models import *
from app.models.review_models import *
from app.models.content_models import *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,30 @@
"""init
Revision ID: 2325dd0f1bd5
Revises:
Create Date: 2025-03-02 16:06:23.805347
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2325dd0f1bd5'
down_revision: Union[str, None] = None
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,52 @@
"""update products
Revision ID: ef40913679bd
Revises: 2325dd0f1bd5
Create Date: 2025-03-02 16:44:41.367742
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ef40913679bd'
down_revision: Union[str, None] = '2325dd0f1bd5'
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('collections', sa.Column('description', sa.Text(), nullable=True))
op.add_column('collections', sa.Column('is_active', sa.Boolean(), nullable=True))
op.add_column('collections', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.add_column('collections', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('product_variants', sa.Column('price', sa.Float(), nullable=False))
op.add_column('product_variants', sa.Column('discount_price', sa.Float(), nullable=True))
op.drop_column('product_variants', 'price_adjustment')
op.add_column('products', sa.Column('collection_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'products', 'collections', ['collection_id'], ['id'])
op.drop_column('products', 'discount_price')
op.drop_column('products', 'price')
op.drop_column('products', 'stock')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('products', sa.Column('stock', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('products', sa.Column('price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False))
op.add_column('products', sa.Column('discount_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
op.drop_constraint(None, 'products', type_='foreignkey')
op.drop_column('products', 'collection_id')
op.add_column('product_variants', sa.Column('price_adjustment', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
op.drop_column('product_variants', 'discount_price')
op.drop_column('product_variants', 'price')
op.drop_column('collections', 'updated_at')
op.drop_column('collections', 'created_at')
op.drop_column('collections', 'is_active')
op.drop_column('collections', 'description')
# ### end Alembic commands ###

View File

@ -18,7 +18,7 @@ class Settings(BaseSettings):
# Настройки безопасности
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-jwt-please-change-in-production")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30*60*24
# Настройки CORS
CORS_ORIGINS: list = [

View File

@ -5,6 +5,21 @@ from sqlalchemy.sql import func
from app.core import Base
class Collection(Base):
__tablename__ = "collections"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
slug = Column(String, unique=True, index=True, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Отношения
products = relationship("Product", back_populates="collection")
class Category(Base):
__tablename__ = "categories"
@ -29,16 +44,16 @@ class Product(Base):
name = Column(String, nullable=False)
slug = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
discount_price = Column(Float, nullable=True)
stock = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
collection_id = Column(Integer, ForeignKey("collections.id"))
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Отношения
category = relationship("Category", back_populates="products")
collection = relationship("Collection", back_populates="products")
variants = relationship("ProductVariant", back_populates="product", cascade="all, delete-orphan")
reviews = relationship("Review", back_populates="product", cascade="all, delete-orphan")
images = relationship("ProductImage", backref="product", cascade="all, delete-orphan")
@ -49,9 +64,10 @@ class ProductVariant(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
name = Column(String, nullable=False)
sku = Column(String, unique=True, nullable=False)
price_adjustment = Column(Float, default=0.0)
name = Column(String, nullable=False)
sku = Column(String, unique=True, nullable=False) # артикул
price = Column(Float, nullable=False)
discount_price = Column(Float, nullable=True)
stock = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@ -5,12 +5,13 @@ from typing import List, Optional, Dict, Any
import re
from datetime import datetime
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection
from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate,
ProductCreate, ProductUpdate,
ProductVariantCreate, ProductVariantUpdate,
ProductImageCreate, ProductImageUpdate
ProductImageCreate, ProductImageUpdate,
CollectionCreate, CollectionUpdate
)
@ -30,6 +31,134 @@ def generate_slug(name: str) -> str:
return slug
# Функции для работы с коллекциями
def get_collection(db: Session, collection_id: int) -> Optional[Collection]:
return db.query(Collection).filter(Collection.id == collection_id).first()
def get_collection_by_slug(db: Session, slug: str) -> Optional[Collection]:
return db.query(Collection).filter(Collection.slug == slug).first()
def get_collections(
db: Session,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = True
) -> List[Collection]:
query = db.query(Collection)
if is_active is not None:
query = query.filter(Collection.is_active == is_active)
return query.offset(skip).limit(limit).all()
def create_collection(db: Session, collection: CollectionCreate) -> Collection:
# Если slug не предоставлен, генерируем его из имени
if not collection.slug:
collection.slug = generate_slug(collection.name)
# Проверяем, что коллекция с таким slug не существует
if get_collection_by_slug(db, collection.slug):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Создаем новую коллекцию
db_collection = Collection(
name=collection.name,
slug=collection.slug,
description=collection.description,
is_active=collection.is_active
)
try:
db.add(db_collection)
db.commit()
db.refresh(db_collection)
return db_collection
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при создании коллекции"
)
def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Collection:
db_collection = get_collection(db, collection_id)
if not db_collection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Обновляем только предоставленные поля
update_data = collection.dict(exclude_unset=True)
# Если slug изменяется, проверяем его уникальность
if "slug" in update_data and update_data["slug"] != db_collection.slug:
if get_collection_by_slug(db, update_data["slug"]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Если имя изменяется и slug не предоставлен, генерируем новый slug
if "name" in update_data and "slug" not in update_data:
update_data["slug"] = generate_slug(update_data["name"])
# Проверяем уникальность сгенерированного slug
if get_collection_by_slug(db, update_data["slug"]) and get_collection_by_slug(db, update_data["slug"]).id != collection_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Коллекция с таким slug уже существует"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_collection, key, value)
try:
db.commit()
db.refresh(db_collection)
return db_collection
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении коллекции"
)
def delete_collection(db: Session, collection_id: int) -> bool:
db_collection = get_collection(db, collection_id)
if not db_collection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Проверяем, есть ли у коллекции продукты
if db.query(Product).filter(Product.collection_id == collection_id).count() > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя удалить коллекцию, у которой есть продукты"
)
try:
db.delete(db_collection)
db.commit()
return True
except Exception:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при удалении коллекции"
)
# Функции для работы с категориями
def get_category(db: Session, category_id: int) -> Optional[Category]:
return db.query(Category).filter(Category.id == category_id).first()
@ -203,10 +332,12 @@ def get_products(
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
collection_id: Optional[int] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
is_active: Optional[bool] = True
is_active: Optional[bool] = True,
include_variants: Optional[bool] = False
) -> List[Product]:
query = db.query(Product)
@ -214,19 +345,34 @@ def get_products(
if category_id:
query = query.filter(Product.category_id == category_id)
if collection_id:
query = query.filter(Product.collection_id == collection_id)
if search:
query = query.filter(Product.name.ilike(f"%{search}%"))
if min_price is not None:
query = query.filter(Product.price >= min_price)
if max_price is not None:
query = query.filter(Product.price <= max_price)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
return query.offset(skip).limit(limit).all()
# Фильтрация по цене теперь должна быть через варианты продукта
if min_price is not None or max_price is not None:
query = query.join(ProductVariant)
if min_price is not None:
query = query.filter(ProductVariant.price >= min_price)
if max_price is not None:
query = query.filter(ProductVariant.price <= max_price)
products = query.offset(skip).limit(limit).all()
# Если нужно включить варианты, загружаем их для каждого продукта
if include_variants:
for product in products:
product.variants = db.query(ProductVariant).filter(ProductVariant.product_id == product.id).all()
product.images = db.query(ProductImage).filter(ProductImage.product_id == product.id).all()
return products
def create_product(db: Session, product: ProductCreate) -> Product:
@ -248,16 +394,21 @@ def create_product(db: Session, product: ProductCreate) -> Product:
detail="Категория не найдена"
)
# Проверяем, что коллекция существует, если указана
if product.collection_id and not get_collection(db, product.collection_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Создаем новый продукт
db_product = Product(
name=product.name,
slug=product.slug,
description=product.description,
price=product.price,
discount_price=product.discount_price,
stock=product.stock,
is_active=product.is_active,
category_id=product.category_id
category_id=product.category_id,
collection_id=product.collection_id
)
try:
@ -303,12 +454,19 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
)
# Проверяем, что категория существует, если указана
if "category_id" in update_data and not get_category(db, update_data["category_id"]):
if "category_id" in update_data and update_data["category_id"] and not get_category(db, update_data["category_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Категория не найдена"
)
# Проверяем, что коллекция существует, если указана
if "collection_id" in update_data and update_data["collection_id"] and not get_collection(db, update_data["collection_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Коллекция не найдена"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_product, key, value)
@ -374,7 +532,8 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
product_id=variant.product_id,
name=variant.name,
sku=variant.sku,
price_adjustment=variant.price_adjustment,
price=variant.price,
discount_price=variant.discount_price,
stock=variant.stock,
is_active=variant.is_active
)
@ -403,6 +562,14 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
# Обновляем только предоставленные поля
update_data = variant.dict(exclude_unset=True)
# Если product_id изменяется, проверяем, что продукт существует
if "product_id" in update_data and update_data["product_id"] != db_variant.product_id:
if not get_product(db, update_data["product_id"]):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
# Если SKU изменяется, проверяем его уникальность
if "sku" in update_data and update_data["sku"] != db_variant.sku:
if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first():

View File

@ -8,14 +8,36 @@ from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate, Category,
ProductCreate, ProductUpdate, Product,
ProductVariantCreate, ProductVariantUpdate, ProductVariant,
ProductImageCreate, ProductImageUpdate, ProductImage
ProductImageCreate, ProductImageUpdate, ProductImage,
CollectionCreate, CollectionUpdate, Collection
)
from app.models.user_models import User as UserModel
from app.repositories.catalog_repo import get_products
from app.repositories.catalog_repo import get_products, get_product_by_slug
# Роутер для каталога
catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"])
# Маршруты для коллекций
@catalog_router.post("/collections", response_model=Dict[str, Any])
async def create_collection_endpoint(collection: CollectionCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.create_collection(db, collection)
@catalog_router.put("/collections/{collection_id}", response_model=Dict[str, Any])
async def update_collection_endpoint(collection_id: int, collection: CollectionUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.update_collection(db, collection_id, collection)
@catalog_router.delete("/collections/{collection_id}", response_model=Dict[str, Any])
async def delete_collection_endpoint(collection_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.delete_collection(db, collection_id)
@catalog_router.get("/collections", response_model=Dict[str, Any])
async def get_collections_endpoint(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return services.get_collections(db, skip, limit)
@catalog_router.post("/categories", response_model=Dict[str, Any])
async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
return services.create_category(db, category)
@ -56,6 +78,17 @@ async def get_product_details_endpoint(product_id: int, db: Session = Depends(ge
return services.get_product_details(db, product_id)
@catalog_router.get("/products/slug/{slug}", response_model=Dict[str, Any])
async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db)):
product = get_product_by_slug(db, slug)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Продукт не найден"
)
return services.get_product_details(db, product.id)
@catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any])
async def add_product_variant_endpoint(product_id: int, variant: ProductVariantCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
variant.product_id = product_id
@ -98,12 +131,14 @@ async def get_products_endpoint(
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
collection_id: Optional[int] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
is_active: Optional[bool] = True,
include_variants: Optional[bool] = False,
db: Session = Depends(get_db)
):
products = get_products(db, skip, limit, category_id, search, min_price, max_price, is_active)
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
return [Product.model_validate(product) for product in products]

View File

@ -3,6 +3,34 @@ from typing import Optional, List, Union
from datetime import datetime
# Схемы для коллекций
class CollectionBase(BaseModel):
name: str
slug: Optional[str] = None
description: Optional[str] = None
is_active: bool = True
class CollectionCreate(CollectionBase):
pass
class CollectionUpdate(CollectionBase):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class Collection(CollectionBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Схемы для категорий
class CategoryBase(BaseModel):
name: str
@ -28,53 +56,20 @@ class Category(CategoryBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
products_count: Optional[int] = 0
class Config:
from_attributes = True
# Схемы для продуктов
class ProductBase(BaseModel):
name: str
slug: Optional[str] = None
description: Optional[str] = None
price: float
discount_price: Optional[float] = None
stock: int = 0
is_active: bool = True
category_id: int
class ProductCreate(ProductBase):
pass
class ProductUpdate(ProductBase):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
discount_price: Optional[float] = None
stock: Optional[int] = None
is_active: Optional[bool] = None
category_id: Optional[int] = None
class Product(ProductBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Схемы для вариантов продуктов
class ProductVariantBase(BaseModel):
product_id: int
name: str
sku: str
price_adjustment: float = 0.0
price: float
discount_price: Optional[float] = None
stock: int = 0
is_active: bool = True
@ -87,7 +82,8 @@ class ProductVariantUpdate(ProductVariantBase):
product_id: Optional[int] = None
name: Optional[str] = None
sku: Optional[str] = None
price_adjustment: Optional[float] = None
price: Optional[float] = None
discount_price: Optional[float] = None
stock: Optional[int] = None
is_active: Optional[bool] = None
@ -127,9 +123,45 @@ class ProductImage(ProductImageBase):
from_attributes = True
# Схемы для продуктов
class ProductBase(BaseModel):
name: str
slug: Optional[str] = None
description: Optional[str] = None
is_active: bool = True
category_id: int
collection_id: Optional[int] = None
class ProductCreate(ProductBase):
pass
class ProductUpdate(ProductBase):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
category_id: Optional[int] = None
collection_id: Optional[int] = None
class Product(ProductBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
variants: Optional[List[ProductVariant]] = []
images: Optional[List[ProductImage]] = []
class Config:
from_attributes = True
# Расширенные схемы для отображения
class CategoryWithSubcategories(Category):
subcategories: List['CategoryWithSubcategories'] = []
products_count: Optional[int] = 0
class Config:
from_attributes = True
@ -137,6 +169,7 @@ class CategoryWithSubcategories(Category):
class ProductWithDetails(Product):
category: Category
collection: Optional[Collection] = None
variants: List[ProductVariant] = []
images: List[ProductImage] = []

View File

@ -8,7 +8,8 @@ from app.services.catalog_service import (
create_category, update_category, delete_category, get_category_tree,
create_product, update_product, delete_product, get_product_details,
add_product_variant, update_product_variant, delete_product_variant,
upload_product_image, update_product_image, delete_product_image
upload_product_image, update_product_image, delete_product_image,
create_collection, update_collection, delete_collection, get_collections
)
from app.services.order_service import (

View File

@ -12,10 +12,44 @@ from app.schemas.catalog_schemas import (
CategoryCreate, CategoryUpdate,
ProductCreate, ProductUpdate,
ProductVariantCreate, ProductVariantUpdate,
ProductImageCreate, ProductImageUpdate
ProductImageCreate, ProductImageUpdate,
CollectionCreate, CollectionUpdate
)
# Сервисы для коллекций
def create_collection(db: Session, collection: CollectionCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
new_collection = catalog_repo.create_collection(db, collection)
# Преобразуем объект SQLAlchemy в схему Pydantic
collection_schema = CollectionSchema.model_validate(new_collection)
return {"collection": collection_schema}
def update_collection(db: Session, collection_id: int, collection: CollectionUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
updated_collection = catalog_repo.update_collection(db, collection_id, collection)
# Преобразуем объект SQLAlchemy в схему Pydantic
collection_schema = CollectionSchema.model_validate(updated_collection)
return {"collection": collection_schema}
def delete_collection(db: Session, collection_id: int) -> Dict[str, Any]:
success = catalog_repo.delete_collection(db, collection_id)
return {"success": success}
def get_collections(db: Session, skip: int = 0, limit: int = 100) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Collection as CollectionSchema
collections = catalog_repo.get_collections(db, skip, limit)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
collections_schema = [CollectionSchema.model_validate(collection) for collection in collections]
return {"collections": collections_schema, "total": len(collections_schema)}
# Сервисы каталога
def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Category as CategorySchema
@ -42,6 +76,8 @@ def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
def get_category_tree(db: Session) -> List[Dict[str, Any]]:
from app.schemas.catalog_schemas import Category as CategorySchema
from sqlalchemy import func
from app.models.catalog_models import Product
# Получаем все категории верхнего уровня
root_categories = catalog_repo.get_categories(db, parent_id=None)
@ -50,28 +86,54 @@ def get_category_tree(db: Session) -> List[Dict[str, Any]]:
for category in root_categories:
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(category)
# Получаем количество продуктов в категории
products_count = db.query(func.count(Product.id)).filter(
Product.category_id == category.id,
Product.is_active == True
).scalar() or 0
# Рекурсивно получаем подкатегории
category_dict = category_schema.model_dump()
category_dict["subcategories"] = _get_subcategories(db, category.id)
subcategories, subcategories_products_count = _get_subcategories(db, category.id)
category_dict["subcategories"] = subcategories
category_dict["products_count"] = products_count + subcategories_products_count
result.append(category_dict)
return result
def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]:
def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any]], int]:
from app.schemas.catalog_schemas import Category as CategorySchema
from sqlalchemy import func
from app.models.catalog_models import Product
subcategories = catalog_repo.get_categories(db, parent_id=parent_id)
result = []
total_products_count = 0
for category in subcategories:
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(category)
# Получаем количество продуктов в категории
products_count = db.query(func.count(Product.id)).filter(
Product.category_id == category.id,
Product.is_active == True
).scalar() or 0
category_dict = category_schema.model_dump()
category_dict["subcategories"] = _get_subcategories(db, category.id)
sub_subcategories, sub_products_count = _get_subcategories(db, category.id)
total_products_in_category = products_count + sub_products_count
total_products_count += total_products_in_category
category_dict["subcategories"] = sub_subcategories
category_dict["products_count"] = total_products_in_category
result.append(category_dict)
return result
return result, total_products_count
def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
@ -101,6 +163,7 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
from app.schemas.catalog_schemas import Collection as CollectionSchema
product = catalog_repo.get_product(db, product_id)
if not product:
@ -126,10 +189,18 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
variants_schema = [ProductVariantSchema.model_validate(variant) for variant in variants]
images_schema = [ProductImageSchema.model_validate(image) for image in images]
# Добавляем информацию о коллекции, если она есть
collection_schema = None
if product.collection_id:
collection = catalog_repo.get_collection(db, product.collection_id)
if collection:
collection_schema = CollectionSchema.model_validate(collection)
return {
"product": product_schema,
"variants": variants_schema,
"images": images_schema,
"collection": collection_schema,
"rating": rating,
"reviews": reviews
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@ -75,6 +75,9 @@ export default function Header() {
<Link href="/category" className="text-sm font-medium hover:opacity-70 transition-opacity">
Каталог
</Link>
<Link href="/all-products" className="text-sm font-medium hover:opacity-70 transition-opacity">
Все товары
</Link>
<Link href="/collections" className="text-sm font-medium hover:opacity-70 transition-opacity">
Коллекции
</Link>

View File

@ -13,7 +13,8 @@ import {
LogOut,
Menu,
X,
Home
Home,
Grid
} from 'lucide-react';
import Image from 'next/image';
import authService from '../../services/auth';
@ -122,6 +123,12 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) {
text: 'Настройки',
href: '/admin/settings',
active: currentPath.startsWith('/admin/settings')
},
{
icon: <Grid className="w-5 h-5" />,
text: 'Коллекции',
href: '/admin/collections',
active: currentPath.startsWith('/admin/collections')
}
];
@ -202,6 +209,13 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) {
</nav>
</div>
<div className="p-4 border-t border-gray-200">
<Link
href="/"
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
>
<Home className="w-5 h-5" />
<span className="ml-3 font-medium">На главную</span>
</Link>
<button
onClick={() => {
authService.logout();
@ -247,6 +261,13 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) {
</nav>
</div>
<div className="p-4 border-t border-gray-200">
<Link
href="/"
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
>
<Home className="w-5 h-5" />
<span className="ml-3 font-medium">На главную</span>
</Link>
<button
onClick={() => {
authService.logout();

View File

@ -0,0 +1,391 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Save, X } from 'lucide-react';
import { Product, ProductVariant, Category, Collection, ProductImage } from '../../services/catalog';
import { productService, categoryService, collectionService } from '../../services/catalog';
import ProductImageUploader, { ProductImageWithFile } from './ProductImageUploader';
import ProductVariantManager from './ProductVariantManager';
interface ProductFormProps {
product?: Product;
onSuccess?: () => void;
}
const ProductForm: React.FC<ProductFormProps> = ({ product, onSuccess }) => {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [images, setImages] = useState<ProductImageWithFile[]>([]);
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
category_id: '',
collection_id: '',
is_active: true
});
const [autoGenerateSlug, setAutoGenerateSlug] = useState(true);
// Загрузка категорий и коллекций при монтировании компонента
useEffect(() => {
const fetchData = async () => {
try {
const [categoriesData, collectionsData] = await Promise.all([
categoryService.getCategories(),
collectionService.getCollections()
]);
setCategories(categoriesData);
setCollections(collectionsData);
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные категорий и коллекций.');
}
};
fetchData();
// Если передан продукт, заполняем форму его данными
if (product) {
setFormData({
name: product.name || '',
slug: product.slug || '',
description: product.description || '',
category_id: product.category_id ? String(product.category_id) : '',
collection_id: product.collection_id ? String(product.collection_id) : '',
is_active: typeof product.is_active === 'boolean' ? product.is_active : true
});
setAutoGenerateSlug(false);
// Загружаем изображения
if (product.images && Array.isArray(product.images)) {
const productImages: ProductImageWithFile[] = product.images.map(img => ({
id: img.id,
url: img.url,
is_primary: img.is_primary,
product_id: img.product_id
}));
setImages(productImages);
}
// Загружаем варианты
if (product.variants && Array.isArray(product.variants)) {
setVariants(product.variants);
}
}
}, [product]);
// Автоматическая генерация slug при изменении названия товара
useEffect(() => {
if (autoGenerateSlug && formData.name) {
setFormData(prev => ({
...prev,
slug: generateSlug(formData.name)
}));
}
}, [formData.name, autoGenerateSlug]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
});
// Если пользователь изменяет slug вручную, отключаем автогенерацию
if (name === 'slug') {
setAutoGenerateSlug(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name || !formData.category_id || variants.length === 0) {
setError('Пожалуйста, заполните все обязательные поля и добавьте хотя бы один вариант товара.');
return;
}
try {
setLoading(true);
setError('');
// Используем введенный slug или генерируем новый, если поле пустое
const slug = formData.slug || generateSlug(formData.name);
// Подготавливаем данные продукта
const productData = {
name: formData.name,
slug,
description: formData.description,
category_id: parseInt(formData.category_id, 10),
collection_id: formData.collection_id ? parseInt(formData.collection_id, 10) : null,
is_active: formData.is_active
};
console.log('Отправляемые данные продукта:', productData);
let productId: number;
if (product) {
// Обновляем существующий продукт
console.log(`Обновление продукта с ID: ${product.id}`);
const updatedProduct = await productService.updateProduct(product.id, productData);
console.log('Обновленный продукт:', updatedProduct);
productId = updatedProduct.id;
} else {
// Создаем новый продукт
console.log('Создание нового продукта');
const newProduct = await productService.createProduct(productData);
console.log('Созданный продукт:', newProduct);
productId = newProduct.id;
}
// Загружаем изображения
console.log(`Загрузка ${images.length} изображений для продукта ${productId}`);
// Используем Promise.all для параллельной загрузки изображений
const imagePromises = images.map(async (image) => {
try {
if (image.file) {
// Загружаем новое изображение
console.log(`Загрузка нового изображения: ${image.file.name}`);
const uploadedImage = await productService.uploadProductImage(productId, image.file, image.is_primary);
console.log('Загруженное изображение:', uploadedImage);
return uploadedImage;
} else if (image.id && product) {
// Обновляем существующее изображение
console.log(`Обновление существующего изображения с ID: ${image.id}`);
const updatedImage = await productService.updateProductImage(productId, image.id, { is_primary: image.is_primary });
console.log('Обновленное изображение:', updatedImage);
return updatedImage;
}
return null;
} catch (imgError) {
console.error('Ошибка при обработке изображения:', imgError);
if (imgError.response) {
console.error('Данные ответа:', imgError.response.data);
console.error('Статус ответа:', imgError.response.status);
}
return null;
}
});
const uploadedImages = await Promise.all(imagePromises);
console.log('Все изображения обработаны:', uploadedImages.filter(Boolean));
// Обрабатываем варианты товара
if (product) {
// Для существующего продукта варианты уже обработаны через API в компоненте ProductVariantManager
console.log('Варианты для существующего продукта уже обработаны');
} else {
// Для нового продукта создаем варианты
console.log(`Создание ${variants.length} вариантов для нового продукта ${productId}`);
const variantPromises = variants.map(async (variant) => {
try {
console.log('Отправка данных варианта:', variant);
// Явно указываем все поля, чтобы избежать лишних данных
const variantData = {
name: variant.name,
sku: variant.sku,
price: Number(variant.price), // Убедимся, что это число
discount_price: variant.discount_price ? Number(variant.discount_price) : null,
stock: Number(variant.stock),
is_active: Boolean(variant.is_active || true)
};
const newVariant = await productService.addProductVariant(productId, variantData);
console.log('Созданный вариант:', newVariant);
return newVariant;
} catch (varError) {
console.error('Ошибка при создании варианта:', varError);
if (varError.response) {
console.error('Ответ сервера:', varError.response.data);
console.error('Статус ответа:', varError.response.status);
}
return null;
}
});
const createdVariants = await Promise.all(variantPromises);
console.log('Все варианты обработаны:', createdVariants.filter(Boolean));
}
// Вызываем колбэк успешного завершения или перенаправляем на страницу списка товаров
if (onSuccess) {
onSuccess();
} else {
router.push('/admin/products');
}
} catch (err) {
console.error('Ошибка при сохранении товара:', err);
if (err.response) {
console.error('Ответ сервера:', err.response.data);
console.error('Статус ответа:', err.response.status);
}
setError('Не удалось сохранить товар. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setLoading(false);
}
};
const generateSlug = (name) => {
return name
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
return (
<div className="bg-white rounded-lg shadow p-6">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
</div>
)}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Название товара *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">
URL-адрес (slug)
<span className="ml-1 text-xs text-gray-500">
{autoGenerateSlug ? '(генерируется автоматически)' : ''}
</span>
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<input
type="text"
id="slug"
name="slug"
value={formData.slug}
onChange={handleChange}
placeholder="url-adres-tovara"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="mt-1">
<label className="inline-flex items-center text-sm text-gray-500">
<input
type="checkbox"
checked={autoGenerateSlug}
onChange={() => setAutoGenerateSlug(!autoGenerateSlug)}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mr-2"
/>
Генерировать автоматически
</label>
</div>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">Категория *</label>
<select
id="category_id"
name="category_id"
value={formData.category_id}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Выберите категорию</option>
{categories.map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="collection_id" className="block text-sm font-medium text-gray-700">Коллекция</label>
<select
id="collection_id"
name="collection_id"
value={formData.collection_id}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Выберите коллекцию</option>
{collections.map(collection => (
<option key={collection.id} value={collection.id}>{collection.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="is_active" className="flex items-center">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Активный товар</span>
</label>
</div>
</div>
<div className="mt-6">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Описание</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={4}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<ProductImageUploader
images={images}
setImages={setImages}
productId={product?.id}
/>
<ProductVariantManager
variants={variants}
setVariants={setVariants}
productId={product?.id}
/>
<div className="mt-8 flex justify-end space-x-3">
<button
type="button"
onClick={() => router.push('/admin/products')}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? 'Сохранение...' : product ? 'Обновить товар' : 'Создать товар'}
</button>
</div>
</form>
</div>
);
};
export default ProductForm;

View File

@ -0,0 +1,230 @@
import { useState } from 'react';
import { X, Upload, Check } from 'lucide-react';
import { ProductImage } from '../../services/catalog';
import { productService } from '../../services/catalog';
export interface ProductImageWithFile extends ProductImage {
file?: File;
isUploading?: boolean;
image_url?: string;
}
interface ProductImageUploaderProps {
images: ProductImageWithFile[];
setImages: (images: ProductImageWithFile[]) => void;
productId?: number; // Опционально, если редактируем существующий продукт
}
const ProductImageUploader: React.FC<ProductImageUploaderProps> = ({
images,
setImages,
productId
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleFileChange = (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
console.log(`Выбрано ${files.length} файлов для загрузки`);
const newImages = files.map((file: File) => {
const isPrimary = images.length === 0; // Первое изображение будет основным
const objectUrl = URL.createObjectURL(file);
console.log(`Создан временный URL для файла ${file.name}: ${objectUrl}`);
return {
id: Math.random(), // Временный ID
url: objectUrl,
is_primary: isPrimary,
product_id: productId || 0,
file,
isUploading: false // Изменено с true на false, так как загрузка будет происходить при сохранении формы
};
});
console.log('Новые изображения для добавления:', newImages);
setImages([...images, ...newImages]);
// Сбрасываем значение input, чтобы можно было загрузить тот же файл повторно
e.target.value = '';
};
const handleRemoveImage = async (id) => {
try {
console.log(`Удаление изображения с ID: ${id}`);
const image = images.find(img => img.id === id);
if (!image) {
console.error(`Изображение с ID ${id} не найдено`);
return;
}
if (productId && !image.file) {
// Если изображение уже загружено на сервер, удаляем его с сервера
console.log(`Удаление изображения с сервера: ${id}`);
await productService.deleteProductImage(productId, id);
} else {
console.log(`Удаление локального изображения: ${id}`);
// Если это локальное изображение с временным URL, освобождаем ресурсы
if (image.url && image.url.startsWith('blob:')) {
URL.revokeObjectURL(image.url);
}
}
const updatedImages = images.filter(image => image.id !== id);
// Если удалили основное изображение, делаем первое из оставшихся основным
if (image.is_primary && updatedImages.length > 0 && !updatedImages.some(img => img.is_primary)) {
const firstImage = updatedImages[0];
if (productId && !firstImage.file) {
console.log(`Установка нового основного изображения: ${firstImage.id}`);
await productService.updateProductImage(productId, firstImage.id, { is_primary: true });
}
updatedImages[0] = { ...firstImage, is_primary: true };
}
console.log('Обновленный список изображений:', updatedImages);
setImages(updatedImages);
} catch (err) {
console.error('Ошибка при удалении изображения:', err);
if (err.response) {
console.error('Данные ответа:', err.response.data);
console.error('Статус ответа:', err.response.status);
}
setError('Не удалось удалить изображение. Пожалуйста, попробуйте снова.');
}
};
const handleSetPrimary = async (id) => {
try {
console.log(`Установка изображения ${id} как основного`);
// Обновляем на сервере только если продукт уже существует и изображение уже загружено
if (productId) {
const image = images.find(img => img.id === id);
if (image && !image.file) {
await productService.updateProductImage(productId, id, { is_primary: true });
}
}
// Обновляем локальное состояние
const updatedImages = images.map(image => ({
...image,
is_primary: image.id === id
}));
console.log('Обновленный список изображений:', updatedImages);
setImages(updatedImages);
} catch (err) {
console.error('Ошибка при установке основного изображения:', err);
if (err.response) {
console.error('Данные ответа:', err.response.data);
console.error('Статус ответа:', err.response.status);
}
setError('Не удалось установить основное изображение. Пожалуйста, попробуйте снова.');
}
};
return (
<div className="mt-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Изображения товара</h2>
{error && (
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<span className="block sm:inline">{error}</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{images.map((image) => {
console.log(`Отображение изображения:`, {
id: image.id,
url: image.url,
image_url: image.image_url,
is_primary: image.is_primary,
isFile: !!image.file
});
return (
<div key={image.id} className="relative group">
<div className={`aspect-square rounded-md overflow-hidden border-2 ${image.is_primary ? 'border-indigo-500' : 'border-gray-200'}`}>
<img
src={image.url || image.image_url}
alt="Product"
className="w-full h-full object-cover"
onError={(e) => {
console.error(`Ошибка загрузки изображения:`, {
src: e.currentTarget.src,
imageData: {
id: image.id,
url: image.url,
image_url: image.image_url
}
});
e.currentTarget.src = '/placeholder-image.jpg'; // Запасное изображение
}}
/>
{image.isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
)}
</div>
<div className="absolute top-2 right-2 flex space-x-1">
{!image.is_primary && (
<button
type="button"
onClick={() => handleSetPrimary(image.id)}
className="bg-white p-1 rounded-full shadow hover:bg-gray-100 focus:outline-none"
title="Сделать основным"
>
<Check className="h-4 w-4 text-green-600" />
</button>
)}
<button
type="button"
onClick={() => handleRemoveImage(image.id)}
className="bg-white p-1 rounded-full shadow hover:bg-gray-100 focus:outline-none"
title="Удалить"
>
<X className="h-4 w-4 text-red-600" />
</button>
</div>
{image.is_primary && (
<div className="absolute bottom-2 left-2 bg-indigo-500 text-white text-xs px-2 py-1 rounded">
Основное
</div>
)}
</div>
);
})}
<div className="aspect-square rounded-md border-2 border-dashed border-gray-300 flex flex-col items-center justify-center p-4 hover:border-indigo-500 transition-colors relative">
<Upload className="h-8 w-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 text-center mb-2">Перетащите файлы или нажмите для загрузки</p>
<label className="w-full h-full absolute inset-0 cursor-pointer">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
</div>
</div>
</div>
);
};
export default ProductImageUploader;

View File

@ -0,0 +1,323 @@
import { useState } from 'react';
import { Plus, Edit, Trash } from 'lucide-react';
import { ProductVariant } from '../../services/catalog';
import { productService } from '../../services/catalog';
interface ProductVariantManagerProps {
variants: ProductVariant[];
setVariants: (variants: ProductVariant[]) => void;
productId?: number; // Опционально, если редактируем существующий продукт
}
const ProductVariantManager: React.FC<ProductVariantManagerProps> = ({
variants,
setVariants,
productId
}) => {
const [newVariant, setNewVariant] = useState({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
const [editingVariant, setEditingVariant] = useState<ProductVariant | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleAddVariant = async () => {
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все обязательные поля варианта');
return;
}
try {
setLoading(true);
setError('');
const variantData = {
name: newVariant.name,
sku: newVariant.sku,
price: parseFloat(newVariant.price),
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
stock: parseInt(newVariant.stock, 10),
is_active: true
};
if (productId) {
// Если есть ID продукта, добавляем вариант через API
const newVariantData = await productService.addProductVariant(productId, variantData);
setVariants([...variants, newVariantData]);
} else {
// Иначе добавляем временный вариант (для новых продуктов)
setVariants([...variants, {
...variantData,
id: Date.now(), // Временный ID
product_id: 0
}]);
}
// Сбрасываем форму
setNewVariant({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
} catch (err) {
console.error('Ошибка при добавлении варианта:', err);
setError('Не удалось добавить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleEditVariant = (variant: ProductVariant) => {
setEditingVariant(variant);
setNewVariant({
name: variant.name,
sku: variant.sku,
price: variant.price.toString(),
discount_price: variant.discount_price ? variant.discount_price.toString() : '',
stock: variant.stock.toString()
});
};
const handleUpdateVariant = async () => {
if (!editingVariant) return;
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все обязательные поля варианта');
return;
}
try {
setLoading(true);
setError('');
const variantData = {
name: newVariant.name,
sku: newVariant.sku,
price: parseFloat(newVariant.price),
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
stock: parseInt(newVariant.stock, 10),
is_active: editingVariant.is_active
};
if (productId && editingVariant.id) {
// Если есть ID продукта и ID варианта, обновляем через API
await productService.updateProductVariant(editingVariant.id, variantData);
}
// Обновляем локальное состояние
setVariants(variants.map(v =>
v.id === editingVariant.id
? { ...v, ...variantData }
: v
));
// Сбрасываем форму и состояние редактирования
setNewVariant({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
setEditingVariant(null);
} catch (err) {
console.error('Ошибка при обновлении варианта:', err);
setError('Не удалось обновить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleCancelEdit = () => {
setEditingVariant(null);
setNewVariant({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
};
const handleRemoveVariant = async (id: number) => {
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
return;
}
try {
setLoading(true);
setError('');
if (productId) {
// Если есть ID продукта, удаляем через API
await productService.deleteProductVariant(productId, id);
}
// Обновляем локальное состояние
setVariants(variants.filter(variant => variant.id !== id));
} catch (err) {
console.error('Ошибка при удалении варианта:', err);
setError('Не удалось удалить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleNewVariantChange = (e) => {
const { name, value } = e.target;
setNewVariant({
...newVariant,
[name]: value
});
};
return (
<div className="mt-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Варианты товара</h2>
{error && (
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<span className="block sm:inline">{error}</span>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Артикул</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цена</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Скидка</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Наличие</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{variants.map(variant => (
<tr key={variant.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.discount_price || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2">
<button
type="button"
onClick={() => handleEditVariant(variant)}
className="text-indigo-600 hover:text-indigo-900"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleRemoveVariant(variant.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="name"
value={newVariant.name}
onChange={handleNewVariantChange}
placeholder="Название варианта"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="sku"
value={newVariant.sku}
onChange={handleNewVariantChange}
placeholder="Артикул"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="price"
value={newVariant.price}
onChange={handleNewVariantChange}
placeholder="Цена"
min="0"
step="0.01"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="discount_price"
value={newVariant.discount_price}
onChange={handleNewVariantChange}
placeholder="Скидка (необяз.)"
min="0"
step="0.01"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="stock"
value={newVariant.stock}
onChange={handleNewVariantChange}
placeholder="Наличие"
min="0"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingVariant ? (
<div className="flex space-x-2">
<button
type="button"
onClick={handleUpdateVariant}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Сохранить
</button>
<button
type="button"
onClick={handleCancelEdit}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Отмена
</button>
</div>
) : (
<button
type="button"
onClick={handleAddVariant}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Plus className="h-4 w-4 mr-1" />
Добавить
</button>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
export default ProductVariantManager;

View File

@ -0,0 +1,107 @@
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
Home,
ShoppingBag,
Package,
Layers,
Grid,
Users,
Settings,
Menu,
X
} from 'lucide-react';
// Массив навигации
const navigation = [
{ name: 'Панель управления', href: '/admin', icon: Home },
{ name: 'Заказы', href: '/admin/orders', icon: ShoppingBag },
{ name: 'Товары', href: '/admin/products', icon: Package },
{ name: 'Категории', href: '/admin/categories', icon: Layers },
{ name: 'Коллекции', href: '/admin/collections', icon: Grid },
{ name: 'Пользователи', href: '/admin/users', icon: Users },
{ name: 'Настройки', href: '/admin/settings', icon: Settings },
];
export default function Sidebar({ isOpen, setIsOpen }) {
const router = useRouter();
return (
<>
{/* Мобильная версия */}
<div className={`fixed inset-0 bg-gray-600 bg-opacity-75 z-20 transition-opacity ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} />
<div className={`fixed inset-y-0 left-0 flex flex-col z-30 max-w-xs w-full bg-white transform transition-transform ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className="flex items-center justify-between h-16 flex-shrink-0 px-4 bg-indigo-600">
<div className="text-white font-bold text-xl">Админ-панель</div>
<button
className="text-white focus:outline-none"
onClick={() => setIsOpen(false)}
>
<X className="h-6 w-6" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<nav className="px-2 py-4 space-y-1">
{navigation.map((item) => {
const isActive = router.pathname === item.href || router.pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon
className={`mr-3 flex-shrink-0 h-6 w-6 ${
isActive ? 'text-indigo-700' : 'text-gray-400 group-hover:text-gray-500'
}`}
/>
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
{/* Десктопная версия */}
<div className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:w-64 bg-white border-r border-gray-200">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-indigo-600">
<div className="text-white font-bold text-xl">Админ-панель</div>
</div>
<div className="flex-1 overflow-y-auto">
<nav className="px-2 py-4 space-y-1">
{navigation.map((item) => {
const isActive = router.pathname === item.href || router.pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon
className={`mr-3 flex-shrink-0 h-6 w-6 ${
isActive ? 'text-indigo-700' : 'text-gray-400 group-hover:text-gray-500'
}`}
/>
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, MapPin, Plus, Edit, Trash, Check } from 'lucide-react';
import { User, Package, Heart, LogOut, MapPin, Plus, Edit, Trash, Check, Home } from 'lucide-react';
import authService from '../../services/auth';
import { userService, Address } from '../../services/users';
@ -221,6 +221,10 @@ export default function AddressesPage() {
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Lock, AlertCircle, CheckCircle } from 'lucide-react';
import { User, Package, Heart, LogOut, Lock, AlertCircle, CheckCircle, MapPin, Home } from 'lucide-react';
import authService from '../../services/auth';
export default function ChangePasswordPage() {
@ -80,10 +80,18 @@ export default function ChangePasswordPage() {
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Save, X, MapPin } from 'lucide-react';
import { User, Package, Heart, LogOut, Save, X, MapPin, Home } from 'lucide-react';
import authService from '../../services/auth';
import { userService, UserUpdate } from '../../services/users';
@ -130,6 +130,10 @@ export default function EditProfilePage() {
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Edit, MapPin } from 'lucide-react';
import { User, Package, Heart, LogOut, Edit, MapPin, Home } from 'lucide-react';
import authService from '../../services/auth';
import { userService } from '../../services/users';
@ -86,6 +86,10 @@ export default function AccountPage() {
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, ExternalLink, Clock, CheckCircle, XCircle } from 'lucide-react';
import { User, Package, Heart, LogOut, ExternalLink, Clock, CheckCircle, XCircle, Home, MapPin } from 'lucide-react';
import authService from '../../services/auth';
import { orderService } from '../../services/orders';
@ -108,10 +108,18 @@ export default function OrdersPage() {
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, ArrowLeft, Clock, CheckCircle, XCircle, Truck, CreditCard, MapPin } from 'lucide-react';
import Image from 'next/image';
import { User, Package, Heart, LogOut, Home, MapPin, ArrowLeft, ExternalLink, Clock, CheckCircle, XCircle, Truck, CreditCard } from 'lucide-react';
import authService from '../../../services/auth';
import { orderService, Order } from '../../../services/orders';
@ -153,10 +154,18 @@ export default function OrderDetailsPage() {
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<Link href="/" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Home className="h-5 w-5" />
<span>На главную</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"

View File

@ -111,7 +111,69 @@ export default function CategoriesPage() {
// Получаем корневые категории и подкатегории
const rootCategories = categories.filter(cat => cat.parent_id === null);
const getSubcategories = (parentId: number) => categories.filter(cat => cat.parent_id === parentId);
// Рекурсивная функция для отображения категорий и их подкатегорий
const renderCategoryRow = (category: Category, level: number = 0) => {
const indent = level * 12; // Отступ для подкатегорий
return (
<>
<tr key={category.id} className={level === 0 ? "bg-gray-50" : ""}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900" style={{ paddingLeft: `${6 + indent}px` }}>
{level > 0 && "— "}{category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.slug}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{category.parent_id ? categories.find(c => c.id === category.parent_id)?.name || '-' : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.products_count || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(category.id, category.is_active)}
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${category.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{category.is_active ? 'Активна' : 'Неактивна'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={() => handleReorder(category.id, 'up')}
disabled={category.order === 1}
className={`p-1 rounded-full ${category.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleReorder(category.id, 'down')}
disabled={category.order === (level === 0 ? rootCategories.length : categories.filter(c => c.parent_id === category.parent_id).length)}
className={`p-1 rounded-full ${category.order === (level === 0 ? rootCategories.length : categories.filter(c => c.parent_id === category.parent_id).length) ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowDown className="h-4 w-4" />
</button>
<span>{category.order}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/categories/${category.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDelete(category.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
{/* Рекурсивно отображаем подкатегории */}
{category.subcategories?.map(subcat => renderCategoryRow(subcat, level + 1))}
</>
);
};
if (loading) {
return (
@ -165,111 +227,7 @@ export default function CategoriesPage() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rootCategories.map((category) => (
<>
<tr key={category.id} className="bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.slug}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">-</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.products_count || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(category.id, category.is_active)}
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${category.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{category.is_active ? 'Активна' : 'Неактивна'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={() => handleReorder(category.id, 'up')}
disabled={category.order === 1}
className={`p-1 rounded-full ${category.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleReorder(category.id, 'down')}
disabled={category.order === rootCategories.length}
className={`p-1 rounded-full ${category.order === rootCategories.length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowDown className="h-4 w-4" />
</button>
<span>{category.order}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/categories/${category.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDelete(category.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
{/* Подкатегории */}
{getSubcategories(category.id).map(subcat => (
<tr key={subcat.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 pl-12">
{subcat.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.slug}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.products_count || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(subcat.id, subcat.is_active)}
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${subcat.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{subcat.is_active ? 'Активна' : 'Неактивна'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={() => handleReorder(subcat.id, 'up')}
disabled={subcat.order === 1}
className={`p-1 rounded-full ${subcat.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleReorder(subcat.id, 'down')}
disabled={subcat.order === getSubcategories(category.id).length}
className={`p-1 rounded-full ${subcat.order === getSubcategories(category.id).length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowDown className="h-4 w-4" />
</button>
<span>{subcat.order}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/categories/${subcat.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDelete(subcat.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
</>
))}
{rootCategories.map(category => renderCategoryRow(category))}
</tbody>
</table>
</div>

View File

@ -0,0 +1,348 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Edit, Trash, Plus, Check, X } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { collectionService, Collection } from '../../../services/catalog';
export default function CollectionsPage() {
const router = useRouter();
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [newCollection, setNewCollection] = useState({
name: '',
description: '',
is_active: true
});
const [editForm, setEditForm] = useState({
name: '',
description: '',
is_active: true
});
// Загрузка коллекций при монтировании компонента
useEffect(() => {
fetchCollections();
}, []);
const fetchCollections = async () => {
try {
setLoading(true);
setError('');
const data = await collectionService.getCollections();
setCollections(data);
} catch (err) {
console.error('Ошибка при загрузке коллекций:', err);
setError('Не удалось загрузить коллекции. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
const handleCreateCollection = async (e) => {
e.preventDefault();
if (!newCollection.name) {
setError('Название коллекции обязательно');
return;
}
try {
setLoading(true);
setError('');
// Создаем slug из названия
const slug = generateSlug(newCollection.name);
const createdCollection = await collectionService.createCollection({
name: newCollection.name,
slug,
description: newCollection.description,
is_active: newCollection.is_active
});
setCollections([...collections, createdCollection]);
setNewCollection({
name: '',
description: '',
is_active: true
});
} catch (err) {
console.error('Ошибка при создании коллекции:', err);
setError('Не удалось создать коллекцию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleEditCollection = (collection: Collection) => {
setEditingId(collection.id);
setEditForm({
name: collection.name,
description: collection.description || '',
is_active: collection.is_active
});
};
const handleUpdateCollection = async (id: number) => {
if (!editForm.name) {
setError('Название коллекции обязательно');
return;
}
try {
setLoading(true);
setError('');
// Создаем slug из названия
const slug = generateSlug(editForm.name);
const updatedCollection = await collectionService.updateCollection(id, {
name: editForm.name,
slug,
description: editForm.description,
is_active: editForm.is_active
});
setCollections(collections.map(collection =>
collection.id === id ? updatedCollection : collection
));
setEditingId(null);
} catch (err) {
console.error('Ошибка при обновлении коллекции:', err);
setError('Не удалось обновить коллекцию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleDeleteCollection = async (id: number) => {
if (!confirm('Вы уверены, что хотите удалить эту коллекцию?')) {
return;
}
try {
setLoading(true);
setError('');
await collectionService.deleteCollection(id);
setCollections(collections.filter(collection => collection.id !== id));
} catch (err) {
console.error('Ошибка при удалении коллекции:', err);
setError('Не удалось удалить коллекцию. Возможно, она используется в товарах.');
} finally {
setLoading(false);
}
};
const handleCancelEdit = () => {
setEditingId(null);
};
const handleNewCollectionChange = (e) => {
const { name, value, type, checked } = e.target;
setNewCollection({
...newCollection,
[name]: type === 'checkbox' ? checked : value
});
};
const handleEditFormChange = (e) => {
const { name, value, type, checked } = e.target;
setEditForm({
...editForm,
[name]: type === 'checkbox' ? checked : value
});
};
const generateSlug = (name) => {
return name
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
return (
<AdminLayout title="Управление коллекциями">
<div className="bg-white rounded-lg shadow p-6">
{error && (
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
</div>
)}
<div className="mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Добавить новую коллекцию</h2>
<form onSubmit={handleCreateCollection} className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Название *</label>
<input
type="text"
id="name"
name="name"
value={newCollection.name}
onChange={handleNewCollectionChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Описание</label>
<input
type="text"
id="description"
name="description"
value={newCollection.description}
onChange={handleNewCollectionChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="flex items-end">
<div className="mr-4">
<label htmlFor="is_active" className="flex items-center">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={newCollection.is_active}
onChange={handleNewCollectionChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Активна</span>
</label>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Plus className="h-4 w-4 mr-2" />
Добавить
</button>
</div>
</form>
</div>
<h2 className="text-lg font-medium text-gray-900 mb-4">Список коллекций</h2>
{loading && collections.length === 0 ? (
<div className="flex justify-center items-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
) : collections.length === 0 ? (
<div className="text-center py-4 text-gray-500">
Коллекции не найдены
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Описание</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{collections.map(collection => (
<tr key={collection.id}>
{editingId === collection.id ? (
<>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="name"
value={editForm.name}
onChange={handleEditFormChange}
required
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="description"
value={editForm.description}
onChange={handleEditFormChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<label className="flex items-center">
<input
type="checkbox"
name="is_active"
checked={editForm.is_active}
onChange={handleEditFormChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Активна</span>
</label>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleUpdateCollection(collection.id)}
disabled={loading}
className="text-green-600 hover:text-green-900 mr-3"
>
<Check className="h-5 w-5" />
</button>
<button
onClick={handleCancelEdit}
className="text-gray-600 hover:text-gray-900"
>
<X className="h-5 w-5" />
</button>
</td>
</>
) : (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{collection.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{collection.description || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
collection.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{collection.is_active ? 'Активна' : 'Неактивна'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEditCollection(collection)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
<Edit className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteCollection(collection.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -3,6 +3,22 @@ import { useRouter } from 'next/router';
import { Save, X, Plus, Trash, ArrowLeft } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { productService, categoryService, Category, Product, ProductVariant, ProductImage } from '../../../services/catalog';
import ProductForm from '../../../components/admin/ProductForm';
// Расширяем интерфейс ProductImage для поддержки загрузки файлов
interface ProductImageWithFile extends ProductImage {
file?: File;
image_url?: string;
}
// Расширяем интерфейс для формы
interface ProductFormData {
name: string;
description: string;
category_id: string;
collection_id: string;
is_active: boolean;
}
// Компонент для загрузки изображений
const ImageUploader = ({ images, setImages, productId }) => {
@ -59,7 +75,7 @@ const ImageUploader = ({ images, setImages, productId }) => {
if (!image.isUploading) {
// Если изображение уже загружено, удаляем его с сервера
await productService.deleteProductImage(id);
await productService.deleteProductImage(productId, id);
}
const updatedImages = images.filter(image => image.id !== id);
@ -67,7 +83,7 @@ const ImageUploader = ({ images, setImages, productId }) => {
// Если удалили основное изображение, делаем первое в списке основным
if (updatedImages.length > 0 && !updatedImages.some(img => img.is_primary)) {
const firstImage = updatedImages[0];
await productService.updateProductImage(firstImage.id, { is_primary: true });
await productService.updateProductImage(productId, firstImage.id, { is_primary: true });
updatedImages[0] = { ...firstImage, is_primary: true };
}
@ -81,7 +97,7 @@ const ImageUploader = ({ images, setImages, productId }) => {
const handleSetPrimary = async (id) => {
try {
// Обновляем на сервере
await productService.updateProductImage(id, { is_primary: true });
await productService.updateProductImage(productId, id, { is_primary: true });
// Обновляем локальное состояние
const updatedImages = images.map(image => ({
@ -153,56 +169,152 @@ const ImageUploader = ({ images, setImages, productId }) => {
// Компонент для вариантов товара
const VariantManager = ({ variants, setVariants, productId }) => {
const [newVariant, setNewVariant] = useState({
color: '',
size: '',
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
const [editingVariant, setEditingVariant] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleAddVariant = async () => {
if (!newVariant.color || !newVariant.size || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все поля варианта');
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все обязательные поля варианта');
return;
}
try {
// Создаем вариант на сервере
const variant = await productService.addProductVariant(productId, {
sku: newVariant.sku,
size: newVariant.size,
color: newVariant.color,
price: parseFloat(newVariant.price),
stock: parseInt(newVariant.stock, 10)
});
setLoading(true);
setError('');
// Добавляем вариант в локальное состояние
setVariants([...variants, variant]);
const variantData = {
name: newVariant.name,
sku: newVariant.sku,
price: parseFloat(newVariant.price),
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
stock: parseInt(newVariant.stock, 10),
is_active: true
};
if (productId) {
// Если есть ID продукта, добавляем вариант через API
const newVariantData = await productService.addProductVariant(productId, variantData);
setVariants([...variants, newVariantData]);
} else {
// Иначе добавляем временный вариант (для новых продуктов)
const variant = {
id: Date.now(),
...variantData,
product_id: 0 // Будет заполнено при сохранении
};
setVariants([...variants, variant]);
}
// Сбрасываем форму
setNewVariant({
color: '',
size: '',
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
} catch (error) {
console.error('Ошибка при добавлении варианта:', error);
alert('Не удалось добавить вариант товара');
} catch (err) {
console.error('Ошибка при добавлении варианта:', err);
setError('Не удалось добавить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleRemoveVariant = async (id) => {
const handleEditVariant = (variant) => {
setEditingVariant(variant);
setNewVariant({
name: variant.name,
sku: variant.sku,
price: variant.price.toString(),
discount_price: variant.discount_price ? variant.discount_price.toString() : '',
stock: variant.stock.toString()
});
};
const handleUpdateVariant = async () => {
if (!editingVariant) return;
if (!newVariant.name || !newVariant.sku || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все обязательные поля варианта');
return;
}
try {
// Удаляем вариант на сервере
await productService.deleteProductVariant(id);
setLoading(true);
setError('');
const variantData = {
name: newVariant.name,
sku: newVariant.sku,
price: parseFloat(newVariant.price),
discount_price: newVariant.discount_price ? parseFloat(newVariant.discount_price) : null,
stock: parseInt(newVariant.stock, 10),
is_active: editingVariant.is_active
};
if (productId && editingVariant.id) {
// Если есть ID продукта и ID варианта, обновляем через API
const updatedVariant = await productService.updateProductVariant(editingVariant.id, variantData);
setVariants(variants.map(v => v.id === editingVariant.id ? updatedVariant : v));
} else {
// Иначе обновляем локально
setVariants(variants.map(v => v.id === editingVariant.id ? { ...v, ...variantData } : v));
}
setNewVariant({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
setEditingVariant(null);
} catch (err) {
console.error('Ошибка при обновлении варианта:', err);
setError('Не удалось обновить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
const handleCancelEdit = () => {
setEditingVariant(null);
setNewVariant({
name: '',
sku: '',
price: '',
discount_price: '',
stock: ''
});
};
const handleRemoveVariant = async (id) => {
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
return;
}
try {
setLoading(true);
setError('');
if (productId) {
// Если есть ID продукта, удаляем через API
await productService.deleteProductVariant(productId, id);
}
// Обновляем локальное состояние
setVariants(variants.filter(variant => variant.id !== id));
} catch (error) {
console.error('Ошибка при удалении варианта:', error);
alert('Не удалось удалить вариант товара');
} catch (err) {
console.error('Ошибка при удалении варианта:', err);
setError('Не удалось удалить вариант. Пожалуйста, попробуйте снова.');
} finally {
setLoading(false);
}
};
@ -217,14 +329,19 @@ const VariantManager = ({ variants, setVariants, productId }) => {
return (
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Варианты товара</label>
{error && (
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<span className="block sm:inline">{error}</span>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цвет</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Размер</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Артикул</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цена</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Скидка</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Наличие</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
@ -232,19 +349,32 @@ const VariantManager = ({ variants, setVariants, productId }) => {
<tbody className="bg-white divide-y divide-gray-200">
{variants.map(variant => (
<tr key={variant.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.color}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.size}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.discount_price || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => handleRemoveVariant(variant.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-4 w-4" />
</button>
<div className="flex space-x-2">
<button
type="button"
onClick={() => handleEditVariant(variant)}
className="text-indigo-600 hover:text-indigo-900"
disabled={loading}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
onClick={() => handleRemoveVariant(variant.id)}
className="text-red-600 hover:text-red-900"
disabled={loading}
>
<Trash className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
@ -252,20 +382,10 @@ const VariantManager = ({ variants, setVariants, productId }) => {
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="color"
value={newVariant.color}
name="name"
value={newVariant.name}
onChange={handleNewVariantChange}
placeholder="Цвет"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="size"
value={newVariant.size}
onChange={handleNewVariantChange}
placeholder="Размер"
placeholder="Название варианта"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
@ -291,6 +411,18 @@ const VariantManager = ({ variants, setVariants, productId }) => {
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="discount_price"
value={newVariant.discount_price}
onChange={handleNewVariantChange}
placeholder="Скидка (необяз.)"
min="0"
step="0.01"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
@ -303,14 +435,48 @@ const VariantManager = ({ variants, setVariants, productId }) => {
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
type="button"
onClick={handleAddVariant}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Plus className="h-4 w-4 mr-1" />
Добавить
</button>
{editingVariant ? (
<div className="flex space-x-2">
<button
type="button"
onClick={handleUpdateVariant}
disabled={loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
<button
type="button"
onClick={handleCancelEdit}
disabled={loading}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm leading-4 font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Отмена
</button>
</div>
) : (
<button
type="button"
onClick={handleAddVariant}
disabled={loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? (
<span className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-1 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Добавление...
</span>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Добавить
</>
)}
</button>
)}
</td>
</tr>
</tbody>
@ -323,163 +489,29 @@ const VariantManager = ({ variants, setVariants, productId }) => {
export default function EditProductPage() {
const router = useRouter();
const { id } = router.query;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [product, setProduct] = useState<Product | null>(null);
const [images, setImages] = useState<ProductImage[]>([]);
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [formData, setFormData] = useState({
name: '',
description: '',
category_id: '',
sku: '',
price: '',
stock: '',
is_active: true
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Загрузка данных продукта и категорий при монтировании компонента
useEffect(() => {
const fetchData = async () => {
if (!id) return;
try {
setLoading(true);
// Загружаем категории
const categoriesData = await categoryService.getCategories();
setCategories(categoriesData);
// Загружаем данные продукта
const productData = await productService.getProductById(Number(id));
console.log('Полученные данные продукта:', productData);
if (!productData || !productData.id) {
console.error('Продукт не найден или данные некорректны:', productData);
setError('Продукт не найден или данные некорректны');
setLoading(false);
return;
}
setProduct(productData);
// Заполняем форму данными продукта
setFormData({
name: productData.name || '',
description: productData.description || '',
category_id: productData.category_id ? String(productData.category_id) : '',
sku: productData.sku || '',
price: productData.price !== undefined ? String(productData.price) : '',
stock: productData.stock !== undefined ? String(productData.stock) : '',
is_active: typeof productData.is_active === 'boolean' ? productData.is_active : true
});
// Загружаем изображения и варианты
if (productData.images && Array.isArray(productData.images)) {
setImages(productData.images);
} else {
console.log('Изображения отсутствуют или не являются массивом');
setImages([]);
}
if (productData.variants && Array.isArray(productData.variants)) {
setVariants(productData.variants);
} else {
console.log('Варианты отсутствуют или не являются массивом');
setVariants([]);
}
setError('');
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные продукта. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchData();
if (id) {
fetchProduct(Number(id));
}
}, [id]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError('');
const fetchProduct = async (productId: number) => {
try {
if (!product) {
console.error('Продукт не найден');
setError('Продукт не найден');
setSaving(false);
return;
}
// Проверяем и логируем данные перед отправкой
console.log('Данные формы перед отправкой:', formData);
// Генерируем slug, если он отсутствует
const slug = product.slug && product.slug.trim() !== ''
? product.slug
: generateSlug(formData.name);
// Обновляем данные продукта
const productData = {
name: formData.name,
description: formData.description,
category_id: parseInt(formData.category_id, 10),
sku: formData.sku,
price: parseFloat(formData.price),
stock: parseInt(formData.stock, 10),
is_active: formData.is_active,
slug: slug
};
console.log('Данные для отправки на сервер:', productData);
// Отправляем данные на сервер
const updatedProduct = await productService.updateProduct(product.id, productData);
console.log('Ответ сервера:', updatedProduct);
// После успешного обновления перенаправляем на страницу товаров
router.push('/admin/products');
} catch (error) {
console.error('Ошибка при обновлении товара:', error);
setError('Произошла ошибка при обновлении товара. Пожалуйста, проверьте введенные данные и попробуйте снова.');
setLoading(true);
const data = await productService.getProductById(productId);
setProduct(data);
} catch (err) {
console.error('Ошибка при загрузке товара:', err);
setError('Не удалось загрузить данные товара.');
} finally {
setSaving(false);
setLoading(false);
}
};
// Функция для генерации slug из названия
const generateSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу и дефисы
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
const translitMap = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
};
return translitMap[char] || char;
})
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
};
if (loading) {
return (
<AdminLayout title="Редактирование товара">
@ -490,190 +522,26 @@ export default function EditProductPage() {
);
}
if (!product && !loading) {
if (error) {
return (
<AdminLayout title="Товар не найден">
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">Товар не найден</p>
</div>
</div>
<AdminLayout title="Редактирование товара">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
<button
onClick={() => router.push('/admin/products')}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700"
>
Вернуться к списку товаров
</button>
</div>
<button
onClick={() => router.push('/admin/products')}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Вернуться к списку товаров
</button>
</AdminLayout>
);
}
return (
<AdminLayout title={`Редактирование: ${product?.name}`}>
<form onSubmit={handleSubmit}>
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center">
<button
type="button"
onClick={() => router.push('/admin/products')}
className="mr-4 text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h2 className="text-xl font-semibold text-gray-800">Редактирование товара</h2>
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={() => router.push('/admin/products')}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<X className="h-5 w-5 mr-2" />
Отмена
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Save className="h-5 w-5 mr-2" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Название товара</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
<select
id="category_id"
name="category_id"
required
value={formData.category_id}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Выберите категорию</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input
type="text"
id="sku"
name="sku"
required
value={formData.sku}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Цена</label>
<input
type="number"
id="price"
name="price"
required
min="0"
step="0.01"
value={formData.price}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="stock" className="block text-sm font-medium text-gray-700 mb-1">Количество на складе</label>
<input
type="number"
id="stock"
name="stock"
required
min="0"
value={formData.stock}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<div className="flex items-center h-full">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
Активен (отображается на сайте)
</label>
</div>
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
<textarea
id="description"
name="description"
rows={4}
required
value={formData.description}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
{product && (
<>
<ImageUploader images={images} setImages={setImages} productId={product.id} />
<VariantManager variants={variants} setVariants={setVariants} productId={product.id} />
</>
)}
</div>
</div>
</form>
<AdminLayout title={product ? `Редактирование: ${product.name}` : "Редактирование товара"}>
{product && <ProductForm product={product} />}
</AdminLayout>
);
}

View File

@ -1,503 +1,10 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Save, X, Plus, Trash } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { productService, categoryService, Category, ProductVariant } from '../../../services/catalog';
// Компонент для загрузки изображений
const ImageUploader = ({ images, setImages }) => {
const handleImageUpload = (e) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
// Создаем временные объекты изображений для предпросмотра
const newImages = files.map(file => {
if (!(file instanceof File)) {
console.error('Объект не является файлом:', file);
return null;
}
return {
id: Date.now() + Math.random().toString(36).substring(2, 9),
url: URL.createObjectURL(file),
file,
isPrimary: images.length === 0 // Первое изображение будет основным
};
}).filter(Boolean); // Удаляем null значения
setImages([...images, ...newImages]);
};
const handleRemoveImage = (id) => {
const updatedImages = images.filter(image => image.id !== id);
// Если удалили основное изображение, делаем первое в списке основным
if (updatedImages.length > 0 && !updatedImages.some(img => img.isPrimary)) {
updatedImages[0].isPrimary = true;
}
setImages(updatedImages);
};
const handleSetPrimary = (id) => {
const updatedImages = images.map(image => ({
...image,
isPrimary: image.id === id
}));
setImages(updatedImages);
};
return (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Изображения товара</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{images.map(image => (
<div key={image.id} className="relative border rounded-lg overflow-hidden group">
<img src={image.url} alt="Product" className="w-full h-32 object-cover" />
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => handleSetPrimary(image.id)}
className={`p-1 rounded-full ${image.isPrimary ? 'bg-green-500' : 'bg-white'} text-black mr-2`}
title={image.isPrimary ? "Основное изображение" : "Сделать основным"}
>
</button>
<button
onClick={() => handleRemoveImage(image.id)}
className="p-1 rounded-full bg-red-500 text-white"
title="Удалить"
>
<Trash className="h-4 w-4" />
</button>
</div>
{image.isPrimary && (
<div className="absolute top-0 left-0 bg-green-500 text-white text-xs px-2 py-1">
Основное
</div>
)}
</div>
))}
<label className="border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center h-32 cursor-pointer hover:border-indigo-500 transition-colors">
<Plus className="h-8 w-8 text-gray-400" />
<span className="mt-2 text-sm text-gray-500">Добавить изображение</span>
<input type="file" className="hidden" accept="image/*" multiple onChange={handleImageUpload} />
</label>
</div>
<p className="text-sm text-gray-500 mt-2">Загрузите до 8 изображений товара. Первое изображение будет использоваться как основное.</p>
</div>
);
};
// Компонент для вариантов товара
const VariantManager = ({ variants, setVariants }) => {
const [newVariant, setNewVariant] = useState({
color: '',
size: '',
sku: '',
price: '',
stock: ''
});
const handleAddVariant = () => {
if (!newVariant.color || !newVariant.size || !newVariant.price || !newVariant.stock) {
alert('Пожалуйста, заполните все поля варианта');
return;
}
const variant = {
id: Date.now(),
color: newVariant.color,
size: newVariant.size,
sku: newVariant.sku,
price: parseFloat(newVariant.price),
stock: parseInt(newVariant.stock, 10)
};
setVariants([...variants, variant]);
setNewVariant({
color: '',
size: '',
sku: '',
price: '',
stock: ''
});
};
const handleRemoveVariant = (id) => {
setVariants(variants.filter(variant => variant.id !== id));
};
const handleNewVariantChange = (e) => {
const { name, value } = e.target;
setNewVariant({
...newVariant,
[name]: value
});
};
return (
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">Варианты товара</label>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цвет</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Размер</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Артикул</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цена</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Наличие</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{variants.map(variant => (
<tr key={variant.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.color}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.size}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => handleRemoveVariant(variant.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-4 w-4" />
</button>
</td>
</tr>
))}
<tr>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="color"
value={newVariant.color}
onChange={handleNewVariantChange}
placeholder="Цвет"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="size"
value={newVariant.size}
onChange={handleNewVariantChange}
placeholder="Размер"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="text"
name="sku"
value={newVariant.sku}
onChange={handleNewVariantChange}
placeholder="Артикул"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="price"
value={newVariant.price}
onChange={handleNewVariantChange}
placeholder="Цена"
min="0"
step="0.01"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<input
type="number"
name="stock"
value={newVariant.stock}
onChange={handleNewVariantChange}
placeholder="Наличие"
min="0"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
type="button"
onClick={handleAddVariant}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Plus className="h-4 w-4 mr-1" />
Добавить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
import ProductForm from '../../../components/admin/ProductForm';
export default function CreateProductPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [images, setImages] = useState([]);
const [variants, setVariants] = useState<ProductVariant[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [formData, setFormData] = useState({
name: '',
description: '',
category_id: '',
sku: '',
price: '',
stock: '',
is_active: true
});
// Загрузка категорий при монтировании компонента
useEffect(() => {
const fetchCategories = async () => {
try {
const data = await categoryService.getCategories();
setCategories(data);
} catch (err) {
console.error('Ошибка при загрузке категорий:', err);
setError('Не удалось загрузить категории. Пожалуйста, попробуйте позже.');
}
};
fetchCategories();
}, []);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// Создаем базовый продукт
const productData = {
name: formData.name,
description: formData.description,
category_id: parseInt(formData.category_id, 10),
sku: formData.sku,
price: parseFloat(formData.price),
stock: parseInt(formData.stock, 10),
is_active: formData.is_active,
slug: generateSlug(formData.name)
};
// Отправляем данные на сервер
const product = await productService.createProduct(productData);
// Загружаем изображения
for (const image of images) {
await productService.uploadProductImage(
product.id,
image.file,
image.isPrimary
);
}
// Добавляем варианты
for (const variant of variants) {
await productService.addProductVariant(product.id, {
sku: variant.sku,
size: variant.size,
color: variant.color,
price: variant.price,
stock: variant.stock
});
}
// После успешного создания перенаправляем на страницу товаров
router.push('/admin/products');
} catch (error) {
console.error('Ошибка при создании товара:', error);
setError('Произошла ошибка при создании товара. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setLoading(false);
}
};
// Генерация slug из названия
const generateSlug = (name) => {
return name
.toLowerCase()
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
const translitMap = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
};
return translitMap[char] || char;
})
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
};
return (
<AdminLayout title="Добавление товара">
<form onSubmit={handleSubmit}>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-800">Новый товар</h2>
<div className="flex space-x-2">
<button
type="button"
onClick={() => router.push('/admin/products')}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<X className="h-5 w-5 mr-2" />
Отмена
</button>
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Save className="h-5 w-5 mr-2" />
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Название товара</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
<select
id="category_id"
name="category_id"
required
value={formData.category_id}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Выберите категорию</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input
type="text"
id="sku"
name="sku"
required
value={formData.sku}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Цена</label>
<input
type="number"
id="price"
name="price"
required
min="0"
step="0.01"
value={formData.price}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="stock" className="block text-sm font-medium text-gray-700 mb-1">Количество на складе</label>
<input
type="number"
id="stock"
name="stock"
required
min="0"
value={formData.stock}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<div className="flex items-center h-full">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
Активен (отображается на сайте)
</label>
</div>
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
<textarea
id="description"
name="description"
rows={4}
required
value={formData.description}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<ImageUploader images={images} setImages={setImages} />
<VariantManager variants={variants} setVariants={setVariants} />
</div>
</div>
</form>
<AdminLayout title="Создание товара">
<ProductForm />
</AdminLayout>
);
}

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Plus, Search, Edit, Trash, ChevronLeft, ChevronRight } from 'lucide-react';
import { Plus, Search, Edit, Trash, ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import Image from 'next/image';
import { productService, categoryService, Product, Category } from '../../../services/catalog';
@ -77,6 +77,7 @@ export default function ProductsPage() {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState<string>('');
const [expandedProducts, setExpandedProducts] = useState<number[]>([]);
const [filters, setFilters] = useState<{
category_id?: number;
is_active?: boolean;
@ -102,7 +103,16 @@ export default function ProductsPage() {
skip: pagination.skip,
limit: pagination.limit,
search: searchTerm,
...filters
...filters,
include_variants: true // Явно запрашиваем варианты
});
console.log('Загруженные продукты:', productsData);
// Проверяем наличие вариантов у продуктов
productsData.forEach(product => {
console.log(`Продукт ${product.name} (ID: ${product.id}):`, product);
console.log(`Варианты:`, product.variants);
});
setProducts(productsData);
@ -177,6 +187,15 @@ export default function ProductsPage() {
const currentPage = Math.floor(pagination.skip / pagination.limit) + 1;
const totalPages = Math.ceil(pagination.total / pagination.limit);
// Функция для разворачивания/сворачивания вариантов продукта
const toggleProductVariants = (productId: number) => {
setExpandedProducts(prev =>
prev.includes(productId)
? prev.filter(id => id !== productId)
: [...prev, productId]
);
};
if (loading && products.length === 0) {
return (
<AdminLayout title="Управление товарами">
@ -236,58 +255,130 @@ export default function ProductsPage() {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 relative">
<Image
src={product.images && product.images.length > 0
? product.images.find(img => img.is_primary)?.url || product.images[0].url
: '/placeholder.jpg'}
alt={product.name}
fill
className="object-cover rounded-md"
/>
<>
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 relative">
<Image
src={product.images && product.images.length > 0
? product.images.find(img => img.is_primary)?.url || product.images[0].url
: '/placeholder-image.jpg'}
alt={product.name}
fill
className="object-cover rounded-md"
onError={(e) => {
console.error(`Ошибка загрузки изображения в списке:`, {
productId: product.id,
productName: product.name,
imageSrc: e.currentTarget.src
});
e.currentTarget.src = '/placeholder-image.jpg';
}}
/>
</div>
<div className="ml-4 flex items-center">
<div className="text-sm font-medium text-gray-900">{product.name}</div>
{product.variants && product.variants.length > 0 && (
<button
onClick={() => toggleProductVariants(product.id)}
className="ml-2 text-gray-500 hover:text-gray-700"
>
{expandedProducts.includes(product.id) ?
<ChevronUp className="h-4 w-4" /> :
<ChevronDown className="h-4 w-4" />
}
</button>
)}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{product.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.price.toLocaleString('ru-RU')} </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">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.stock > 20 ? 'bg-green-100 text-green-800' :
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
{product.stock}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleStatus(product.id, product.is_active)}
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{product.is_active ? 'Активен' : 'Неактивен'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDeleteProduct(product.id)}
className="text-red-600 hover:text-red-900"
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.variants && product.variants.length > 0
? product.variants[0].sku
: 'Нет артикула'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.variants && product.variants.length > 0
? `${product.variants[0].price.toLocaleString('ru-RU')}${product.variants.length > 1 && !expandedProducts.includes(product.id) ? ' (и другие)' : ''}`
: 'Нет цены'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{categories.find(c => c.id === product.category_id)?.name || 'Без категории'}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.variants && product.variants.reduce((total, variant) => total + variant.stock, 0) > 20 ? 'bg-green-100 text-green-800' :
product.variants && product.variants.reduce((total, variant) => total + variant.stock, 0) > 10 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
{product.variants ? product.variants.reduce((total, variant) => total + variant.stock, 0) : 0}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleStatus(product.id, product.is_active)}
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
<Trash className="h-5 w-5" />
{product.is_active ? 'Активен' : 'Неактивен'}
</button>
</div>
</td>
</tr>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDeleteProduct(product.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
{/* Варианты продукта */}
{expandedProducts.includes(product.id) && product.variants && product.variants.map((variant) => (
<tr key={`variant-${variant.id}`} className="bg-gray-50">
<td className="px-6 py-2 whitespace-nowrap">
<div className="flex items-center">
<div className="ml-14">
<div className="text-xs text-gray-500">
Вариант: <span className="font-medium text-gray-700">{variant.name}</span>
</div>
</div>
</div>
</td>
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">
{variant.sku}
</td>
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">
{`${variant.price.toLocaleString('ru-RU')}`}
{variant.discount_price && (
<span className="ml-1 line-through text-gray-400">
{variant.discount_price.toLocaleString('ru-RU')}
</span>
)}
</td>
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">-</td>
<td className="px-6 py-2 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${variant.stock > 10 ? 'bg-green-100 text-green-800' :
variant.stock > 5 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
{variant.stock}
</span>
</td>
<td className="px-6 py-2 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${variant.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{variant.is_active ? 'Активен' : 'Неактивен'}
</span>
</td>
<td className="px-6 py-2 whitespace-nowrap text-right text-xs font-medium">-</td>
</tr>
))}
</>
))}
{products.length === 0 && !loading && (
<tr>

View File

@ -0,0 +1,324 @@
import { useState, useEffect } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import Link from 'next/link';
import { Heart, Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
import Header from '../components/Header';
import Footer from '../components/Footer';
import { productService, categoryService, Product, Category } from '../services/catalog';
export default function AllProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [favorites, setFavorites] = useState<number[]>([]);
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
// Состояние для фильтров
const [searchTerm, setSearchTerm] = useState<string>('');
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ min: null, max: null });
const [showFilters, setShowFilters] = useState<boolean>(false);
// Загрузка продуктов и категорий при монтировании компонента
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// Загружаем категории
const categoriesData = await categoryService.getCategories();
setCategories(categoriesData);
// Загружаем продукты с учетом фильтров
const filters: any = {
include_variants: true
};
if (searchTerm) {
filters.search = searchTerm;
}
if (selectedCategory) {
filters.category_id = selectedCategory;
}
if (priceRange.min !== null) {
filters.min_price = priceRange.min;
}
if (priceRange.max !== null) {
filters.max_price = priceRange.max;
}
const productsData = await productService.getProducts(filters);
setProducts(productsData);
setError(null);
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchData();
}, [searchTerm, selectedCategory, priceRange]);
// Функция для добавления/удаления товара из избранного
const toggleFavorite = (id: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setFavorites(prev =>
prev.includes(id)
? prev.filter(itemId => itemId !== id)
: [...prev, id]
);
};
// Функция для сброса всех фильтров
const resetFilters = () => {
setSearchTerm('');
setSelectedCategory(null);
setPriceRange({ min: null, max: null });
};
// Функция для форматирования цены
const formatPrice = (price: number): string => {
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
};
// Получаем плоский список категорий для фильтра
const flattenCategories = (categories: Category[], result: Category[] = []): Category[] => {
categories.forEach(category => {
result.push(category);
if (category.subcategories && category.subcategories.length > 0) {
flattenCategories(category.subcategories, result);
}
});
return result;
};
const allCategories = flattenCategories(categories);
return (
<div className="min-h-screen bg-white font-['Arimo']">
<Head>
<title>Все товары | Brand Store</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
<h1 className="text-3xl font-bold mb-8 font-['Playfair_Display']">Все товары</h1>
{/* Фильтры и поиск */}
<div className="mb-8">
<div className="flex flex-col md:flex-row gap-4 mb-4">
<div className="relative flex-grow">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Поиск товаров..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Filter className="h-5 w-5 mr-2" />
Фильтры
{showFilters ? <ChevronUp className="ml-2 h-4 w-4" /> : <ChevronDown className="ml-2 h-4 w-4" />}
</button>
</div>
{/* Расширенные фильтры */}
{showFilters && (
<div className="bg-white p-4 rounded-md shadow-md mb-4 border border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
<select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={selectedCategory || ''}
onChange={(e) => setSelectedCategory(e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Все категории</option>
{allCategories.map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Минимальная цена</label>
<input
type="number"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={priceRange.min || ''}
onChange={(e) => setPriceRange({ ...priceRange, min: e.target.value ? parseInt(e.target.value) : null })}
placeholder="От"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Максимальная цена</label>
<input
type="number"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={priceRange.max || ''}
onChange={(e) => setPriceRange({ ...priceRange, max: e.target.value ? parseInt(e.target.value) : null })}
placeholder="До"
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={resetFilters}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<X className="h-4 w-4 mr-2" />
Сбросить фильтры
</button>
</div>
</div>
)}
{/* Активные фильтры */}
{(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
<div className="flex flex-wrap gap-2 mb-4">
{searchTerm && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
Поиск: {searchTerm}
<button onClick={() => setSearchTerm('')} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
{selectedCategory && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
Категория: {allCategories.find(c => c.id === selectedCategory)?.name}
<button onClick={() => setSelectedCategory(null)} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
{priceRange.min && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
От: {priceRange.min}
<button onClick={() => setPriceRange({ ...priceRange, min: null })} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
{priceRange.max && (
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100">
До: {priceRange.max}
<button onClick={() => setPriceRange({ ...priceRange, max: null })} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
{/* Отображение товаров */}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : error ? (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
<button
onClick={() => window.location.reload()}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Попробовать снова
</button>
</div>
) : products.length === 0 ? (
<div className="text-center py-12">
<p className="text-lg text-gray-600">Товары не найдены. Попробуйте изменить параметры поиска.</p>
{(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
<button
onClick={resetFilters}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Сбросить все фильтры
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map((product) => (
<div key={product.id} className="group">
<Link
href={`/product/${product.slug}`}
className="block"
onMouseEnter={() => setHoveredProduct(product.id)}
onMouseLeave={() => setHoveredProduct(null)}
>
<div className="relative overflow-hidden rounded-xl">
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
{product.images && product.images.length > 0 ? (
<Image
src={
hoveredProduct === product.id && product.images.length > 1
? product.images[1]?.url || product.images[0]?.url
: product.images[0]?.url
}
alt={product.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-all duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">Нет изображения</span>
</div>
)}
<button
onClick={(e) => toggleFavorite(product.id, e)}
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
>
<Heart
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
/>
</button>
</div>
</div>
<div className="mt-4">
<h3 className="text-lg font-medium">{product.name}</h3>
{product.variants && product.variants.length > 0 ? (
<p className="mt-1 text-lg font-bold">
{formatPrice(product.variants[0].price)}
{product.variants[0].discount_price && (
<span className="ml-2 text-sm line-through text-gray-500">
{formatPrice(product.variants[0].discount_price)}
</span>
)}
</p>
) : (
<p className="mt-1 text-lg font-bold">Цена по запросу</p>
)}
</div>
</Link>
</div>
))}
</div>
)}
</main>
<Footer />
</div>
);
}

View File

@ -2,27 +2,69 @@ import { useState } from 'react';
import Head from 'next/head';
import Image from 'next/image';
import Link from 'next/link';
import { GetStaticProps, GetStaticPaths } from 'next';
import { GetServerSideProps } from 'next';
import { useRouter } from 'next/router';
import Header from '../../components/Header';
import Footer from '../../components/Footer';
import { Product, products, getProductBySlug, getSimilarProducts, formatPrice } from '../../data/products';
import { Category, getCategoryById } from '../../data/categories';
import { Heart, ShoppingBag, ChevronLeft, ChevronRight } from 'lucide-react';
import { productService, categoryService, Product as ApiProduct, Category, Collection, ProductVariant } from '../../services/catalog';
import { Heart, ShoppingBag, ChevronLeft, ChevronRight, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Импортируем статические данные и функции из файла data/products.ts
import { getProductBySlug as getStaticProductBySlug, getSimilarProducts as getStaticSimilarProducts, Product as StaticProduct } from '../../data/products';
// Функция для преобразования статического продукта в формат API продукта
const convertStaticToApiProduct = (staticProduct: StaticProduct): ApiProduct => {
return {
id: staticProduct.id,
name: staticProduct.name,
slug: staticProduct.slug,
description: staticProduct.description,
is_active: true,
category_id: staticProduct.categoryId,
collection_id: staticProduct.collectionId || null,
images: staticProduct.images.map((url, index) => ({
id: index + 1,
url,
is_primary: index === 0,
product_id: staticProduct.id
})),
variants: [
{
id: staticProduct.id * 100 + 1,
name: 'Стандартный',
sku: `SKU-${staticProduct.id}`,
price: staticProduct.price,
discount_price: null,
stock: staticProduct.inStock ? 10 : 0,
is_active: true,
product_id: staticProduct.id
}
],
category: {
id: staticProduct.categoryId,
name: 'Категория', // Заглушка, так как в статических данных нет имени категории
slug: `category-${staticProduct.categoryId}`,
parent_id: null,
is_active: true,
order: 1
}
};
};
interface ProductPageProps {
product: Product;
category: Category;
similarProducts: Product[];
product: ApiProduct;
similarProducts: ApiProduct[];
}
export default function ProductPage({ product, category, similarProducts }: ProductPageProps) {
export default function ProductPage({ product, similarProducts }: ProductPageProps) {
const router = useRouter();
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [isFavorite, setIsFavorite] = useState(false);
const [quantity, setQuantity] = useState(1);
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
product.variants && product.variants.length > 0 ? product.variants[0] : null
);
// Если страница еще загружается, показываем заглушку
if (router.isFallback) {
@ -35,11 +77,15 @@ export default function ProductPage({ product, category, similarProducts }: Prod
// Функция для переключения изображений
const nextImage = () => {
setCurrentImageIndex((prev) => (prev === product.images.length - 1 ? 0 : prev + 1));
if (product.images && product.images.length > 0) {
setCurrentImageIndex((prev) => (prev === product.images!.length - 1 ? 0 : prev + 1));
}
};
const prevImage = () => {
setCurrentImageIndex((prev) => (prev === 0 ? product.images.length - 1 : prev - 1));
if (product.images && product.images.length > 0) {
setCurrentImageIndex((prev) => (prev === 0 ? product.images!.length - 1 : prev - 1));
}
};
// Функция для добавления/удаления товара из избранного
@ -58,12 +104,30 @@ export default function ProductPage({ product, category, similarProducts }: Prod
}
};
// Функция для выбора варианта товара
const handleVariantSelect = (variant: ProductVariant) => {
setSelectedVariant(variant);
};
// Функция для добавления товара в корзину
const addToCart = () => {
// Здесь будет логика добавления товара в корзину
alert(`Товар "${product.name}" добавлен в корзину в количестве ${quantity} шт.`);
const variantInfo = selectedVariant ? ` (вариант: ${selectedVariant.name})` : '';
alert(`Товар "${product.name}"${variantInfo} добавлен в корзину в количестве ${quantity} шт.`);
};
// Функция для форматирования цены
const formatPrice = (price: number): string => {
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
};
// Получаем текущую цену на основе выбранного варианта
const currentPrice = selectedVariant ? selectedVariant.price :
(product.variants && product.variants.length > 0 ? product.variants[0].price : 0);
const currentDiscountPrice = selectedVariant ? selectedVariant.discount_price :
(product.variants && product.variants.length > 0 ? product.variants[0].discount_price : null);
return (
<div className="min-h-screen bg-white font-['Arimo']">
<Head>
@ -75,8 +139,8 @@ export default function ProductPage({ product, category, similarProducts }: Prod
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
<div className="mb-8">
<Link href={`/category/${category.slug}`} className="text-gray-600 hover:text-black transition-colors">
{category.name}
<Link href={product.category ? `/category/${product.category.slug}` : "/category"} className="text-gray-600 hover:text-black transition-colors">
{product.category ? product.category.name : 'Назад к категориям'}
</Link>
</div>
@ -93,19 +157,25 @@ export default function ProductPage({ product, category, similarProducts }: Prod
transition={{ duration: 0.3 }}
className="absolute inset-0"
>
<Image
src={product.images[currentImageIndex]}
alt={product.name}
fill
className="object-cover"
priority
quality={95}
/>
{product.images && product.images.length > 0 ? (
<Image
src={product.images[currentImageIndex].url}
alt={product.name}
fill
className="object-cover"
priority
quality={95}
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">Нет изображения</span>
</div>
)}
</motion.div>
</AnimatePresence>
{/* Кнопки навигации по галерее */}
{product.images.length > 1 && (
{product.images && product.images.length > 1 && (
<>
<button
onClick={prevImage}
@ -126,7 +196,7 @@ export default function ProductPage({ product, category, similarProducts }: Prod
</div>
{/* Миниатюры изображений */}
{product.images.length > 1 && (
{product.images && product.images.length > 1 && (
<div className="flex mt-4 space-x-2 overflow-x-auto">
{product.images.map((image, index) => (
<button
@ -137,7 +207,7 @@ export default function ProductPage({ product, category, similarProducts }: Prod
}`}
aria-label={`Изображение ${index + 1}`}
>
<Image src={image} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
<Image src={image.url} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
</button>
))}
</div>
@ -161,13 +231,60 @@ export default function ProductPage({ product, category, similarProducts }: Prod
transition={{ duration: 0.5, delay: 0.1 }}
className="text-2xl font-bold mt-4"
>
{formatPrice(product.price)}
{currentPrice ? (
<>
{formatPrice(currentPrice)}
{currentDiscountPrice && (
<span className="ml-2 text-sm line-through text-gray-500">
{formatPrice(currentDiscountPrice)}
</span>
)}
</>
) : (
'Цена по запросу'
)}
</motion.p>
{/* Варианты товара */}
{product.variants && product.variants.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-6"
>
<h2 className="text-lg font-medium mb-2">Варианты</h2>
<div className="flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.id}
onClick={() => handleVariantSelect(variant)}
className={`px-4 py-2 border rounded-md transition-colors ${
selectedVariant?.id === variant.id
? 'border-black bg-black text-white'
: 'border-gray-300 hover:border-gray-500'
}`}
>
<div className="flex items-center">
{selectedVariant?.id === variant.id && <Check className="w-4 h-4 mr-1" />}
{variant.name}
{variant.stock <= 3 && variant.stock > 0 && (
<span className="ml-2 text-xs text-red-500">Осталось {variant.stock} шт.</span>
)}
{variant.stock === 0 && (
<span className="ml-2 text-xs text-red-500">Нет в наличии</span>
)}
</div>
</button>
))}
</div>
</motion.div>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-6"
>
<h2 className="text-lg font-medium mb-2">Описание</h2>
@ -177,7 +294,7 @@ export default function ProductPage({ product, category, similarProducts }: Prod
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="mt-8"
>
<h2 className="text-lg font-medium mb-2">Количество</h2>
@ -204,15 +321,18 @@ export default function ProductPage({ product, category, similarProducts }: Prod
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="mt-8 flex flex-col sm:flex-row gap-4"
>
<button
onClick={addToCart}
className="flex items-center justify-center gap-2 bg-black text-white px-8 py-3 rounded-md hover:bg-gray-800 transition-colors"
className={`flex items-center justify-center gap-2 bg-black text-white px-8 py-3 rounded-md transition-colors ${
selectedVariant && selectedVariant.stock === 0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-800'
}`}
disabled={selectedVariant && selectedVariant.stock === 0}
>
<ShoppingBag className="w-5 h-5" />
Добавить в корзину
{selectedVariant && selectedVariant.stock === 0 ? 'Нет в наличии' : 'Добавить в корзину'}
</button>
<button
onClick={toggleFavorite}
@ -230,13 +350,27 @@ export default function ProductPage({ product, category, similarProducts }: Prod
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-8 border-t border-gray-200 pt-8"
>
<h2 className="text-lg font-medium mb-2">Категория</h2>
<Link href={`/category/${category.slug}`} className="text-gray-600 hover:text-black transition-colors">
{category.name}
</Link>
{product.category && (
<Link href={`/category/${product.category.slug}`} className="text-gray-600 hover:text-black transition-colors">
{product.category.name}
</Link>
)}
{product.collection && (
<div className="mt-4">
<h2 className="text-lg font-medium mb-2">Коллекция</h2>
<Link href={`/collections/${product.collection.slug}`} className="text-gray-600 hover:text-black transition-colors">
{product.collection.name}
</Link>
{product.collection.description && (
<p className="mt-2 text-sm text-gray-500">{product.collection.description}</p>
)}
</div>
)}
</motion.div>
</div>
</div>
@ -262,27 +396,39 @@ export default function ProductPage({ product, category, similarProducts }: Prod
>
<div className="relative overflow-hidden rounded-xl">
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
<Image
src={
hoveredProduct === similarProduct.id && similarProduct.images.length > 1
? similarProduct.images[1]
: similarProduct.images[0]
}
alt={similarProduct.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-all duration-500 group-hover:scale-105"
/>
{similarProduct.isNew && (
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">
Новинка
</span>
{similarProduct.images && similarProduct.images.length > 0 ? (
<Image
src={
hoveredProduct === similarProduct.id && similarProduct.images.length > 1
? similarProduct.images[1].url
: similarProduct.images[0].url
}
alt={similarProduct.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-all duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">Нет изображения</span>
</div>
)}
</div>
</div>
<div className="mt-4">
<h3 className="text-lg font-medium">{similarProduct.name}</h3>
<p className="mt-1 text-lg font-bold">{formatPrice(similarProduct.price)} </p>
{similarProduct.variants && similarProduct.variants.length > 0 ? (
<p className="mt-1 text-lg font-bold">
{formatPrice(similarProduct.variants[0].price)}
{similarProduct.variants[0].discount_price && (
<span className="ml-2 text-sm line-through text-gray-500">
{formatPrice(similarProduct.variants[0].discount_price)}
</span>
)}
</p>
) : (
<p className="mt-1 text-lg font-bold">Цена по запросу</p>
)}
</div>
</Link>
</motion.div>
@ -297,42 +443,55 @@ export default function ProductPage({ product, category, similarProducts }: Prod
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const paths = products.map((product) => ({
params: { slug: product.slug },
}));
return {
paths,
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = params?.slug as string;
const product = getProductBySlug(slug);
if (!product) {
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
try {
const slug = params?.slug as string;
// Получаем данные о продукте через API
let product;
let similarProducts = [];
try {
// Пытаемся получить продукт через API
product = await productService.getProductBySlug(slug);
// Получаем похожие товары (товары из той же категории)
similarProducts = await productService.getProducts({
category_id: product.category_id,
include_variants: true,
limit: 4
});
// Фильтруем, чтобы исключить текущий товар из похожих
similarProducts = similarProducts.filter(p => p.id !== product.id);
} catch (error) {
console.error('Ошибка при получении данных продукта через API:', error);
// Если не удалось получить данные через API, используем статические данные из файла
const staticProduct = getStaticProductBySlug(slug);
if (!staticProduct) {
return { notFound: true };
}
// Преобразуем статический продукт в формат API продукта
product = convertStaticToApiProduct(staticProduct);
// Получаем похожие товары из статических данных
const staticSimilarProducts = getStaticSimilarProducts(staticProduct.id);
similarProducts = staticSimilarProducts.map(convertStaticToApiProduct);
}
return {
notFound: true,
props: {
product,
similarProducts
}
};
} catch (error) {
console.error('Ошибка при получении данных продукта:', error);
return {
notFound: true
};
}
const category = getCategoryById(product.categoryId);
const similarProducts = getSimilarProducts(product.id);
if (!category) {
return {
notFound: true,
};
}
return {
props: {
product,
category,
similarProducts,
},
revalidate: 600, // Перегенерация страницы каждые 10 минут
};
};

View File

@ -11,9 +11,12 @@ const api = axios.create({
// Перехватчик запросов для добавления токена авторизации
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// Проверяем, что мы в браузере, а не на сервере
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
@ -24,17 +27,20 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Если ошибка 401 (неавторизован) и это не повторный запрос
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// Проверяем, что error.response существует
if (error.response) {
const originalRequest = error.config;
// Здесь можно добавить логику обновления токена
// Например, перенаправление на страницу входа
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
window.location.href = '/login';
// Если ошибка 401 (неавторизован) и это не повторный запрос
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// Здесь можно добавить логику обновления токена
// Например, перенаправление на страницу входа
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
window.location.href = '/login';
}
}
}

View File

@ -9,7 +9,15 @@ export interface Category {
order: number;
is_active: boolean;
products_count?: number;
children?: Category[];
subcategories?: Category[];
}
export interface Collection {
id: number;
name: string;
slug: string;
description: string | null;
is_active: boolean;
}
export interface ProductImage {
@ -17,15 +25,17 @@ export interface ProductImage {
url: string;
is_primary: boolean;
product_id: number;
image_url?: string; // URL изображения с сервера
}
export interface ProductVariant {
id: number;
name: string;
sku: string;
size: string;
color: string;
price: number;
discount_price: number | null;
stock: number;
is_active: boolean;
product_id: number;
}
@ -34,16 +44,41 @@ export interface Product {
name: string;
slug: string;
description: string;
price: number;
sku: string;
stock: number;
is_active: boolean;
category_id: number;
collection_id: number | null;
category?: Category;
collection?: Collection;
images?: ProductImage[];
variants?: ProductVariant[];
}
// Сервисы для работы с коллекциями
export const collectionService = {
// Получить все коллекции
getCollections: async (): Promise<Collection[]> => {
const response = await api.get('/catalog/collections');
return response.data.collections;
},
// Создать новую коллекцию
createCollection: async (collection: Omit<Collection, 'id'>): Promise<Collection> => {
const response = await api.post('/catalog/collections', collection);
return response.data.collection;
},
// Обновить коллекцию
updateCollection: async (id: number, collection: Partial<Collection>): Promise<Collection> => {
const response = await api.put(`/catalog/collections/${id}`, collection);
return response.data.collection;
},
// Удалить коллекцию
deleteCollection: async (id: number): Promise<void> => {
await api.delete(`/catalog/collections/${id}`);
}
};
// Сервисы для работы с категориями
export const categoryService = {
// Получить все категории в виде дерева
@ -77,55 +112,293 @@ export const productService = {
skip?: number;
limit?: number;
category_id?: number;
collection_id?: number;
search?: string;
min_price?: number;
max_price?: number;
is_active?: boolean;
include_variants?: boolean;
}): Promise<Product[]> => {
const response = await api.get('/catalog/products', { params });
return response.data;
try {
console.log('Запрос продуктов с параметрами:', params);
const response = await api.get('/catalog/products', {
params: {
...params,
include_variants: true
}
});
console.log('Ответ API:', response.data);
// Проверяем, что получили массив продуктов
if (!Array.isArray(response.data)) {
console.error('Ответ API не является массивом:', response.data);
return [];
}
// Формируем базовый URL для изображений
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
// Обрабатываем URL изображений для каждого продукта
const productsWithFixedImages = response.data.map((product: Product) => {
if (product.images && Array.isArray(product.images)) {
product.images = product.images.map(img => ({
...img,
url: img.url && img.url.startsWith('http')
? img.url
: img.url
? `${apiBaseUrl}${img.url}`
: img.image_url
? `${apiBaseUrl}${img.image_url}`
: '/placeholder-image.jpg'
}));
}
console.log(`Обработанный продукт ${product.name} (ID: ${product.id}):`, product);
console.log(`Изображения:`, product.images);
return product;
});
return productsWithFixedImages;
} catch (error) {
console.error('Ошибка при получении продуктов:', error);
throw error;
}
},
// Получить детали товара по ID
getProductById: async (id: number): Promise<Product> => {
try {
console.log(`Запрос данных продукта с ID: ${id}`);
const response = await api.get(`/catalog/products/${id}`);
console.log('Ответ API:', response.data);
// Проверяем структуру ответа
if (response.data && response.data.product) {
// Если ответ содержит вложенный объект product, используем его
const product = response.data.product;
if (!response.data) {
throw new Error('Пустой ответ от API');
}
let product: Product;
// Формируем базовый URL для изображений
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
// Если ответ содержит вложенный объект product
if (response.data.product) {
product = response.data.product;
// Добавляем изображения и варианты из ответа
// Добавляем изображения из ответа
if (response.data.images && Array.isArray(response.data.images)) {
product.images = response.data.images;
product.images = response.data.images.map(img => ({
id: img.id,
url: `${apiBaseUrl}${img.image_url}`,
is_primary: img.is_primary,
product_id: img.product_id
}));
}
// Добавляем варианты из ответа
if (response.data.variants && Array.isArray(response.data.variants)) {
product.variants = response.data.variants;
}
return product;
// Добавляем коллекцию из ответа
if (response.data.collection) {
product.collection = response.data.collection;
}
} else {
// Если структура ответа другая, возвращаем как есть
return response.data;
// Если структура ответа другая, используем как есть
product = response.data;
// Преобразуем URL изображений, если они есть
if (product.images && Array.isArray(product.images)) {
product.images = product.images.map(img => ({
...img,
url: img.url.startsWith('http') ? img.url : `${apiBaseUrl}${img.url}`
}));
}
}
// Проверяем, что получили корректный объект продукта
if (!product || !product.id) {
throw new Error('Некорректные данные продукта в ответе API');
}
console.log('Обработанные данные продукта:', product);
return product;
} catch (error) {
console.error('Ошибка при получении продукта:', error);
throw error;
}
},
// Создать новый товар
// Получить детали товара по slug
getProductBySlug: async (slug: string): Promise<Product> => {
try {
console.log(`Запрос данных продукта с slug: ${slug}`);
// Проверяем, что slug не пустой
if (!slug) {
console.error('Ошибка: slug не может быть пустым');
throw new Error('Slug не может быть пустым');
}
try {
const response = await api.get(`/catalog/products/slug/${slug}`);
console.log('Ответ API:', response.data);
// Проверяем структуру ответа
if (!response.data) {
throw new Error('Пустой ответ от API');
}
let product: Product;
// Формируем базовый URL для изображений
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
// Если ответ содержит вложенный объект product
if (response.data.product) {
product = response.data.product;
// Добавляем изображения из ответа
if (response.data.images && Array.isArray(response.data.images)) {
product.images = response.data.images.map(img => ({
id: img.id,
url: `${apiBaseUrl}${img.image_url}`,
is_primary: img.is_primary,
product_id: img.product_id
}));
}
// Добавляем варианты из ответа
if (response.data.variants && Array.isArray(response.data.variants)) {
product.variants = response.data.variants;
}
// Добавляем коллекцию из ответа
if (response.data.collection) {
product.collection = response.data.collection;
}
} else {
// Если структура ответа другая, используем как есть
product = response.data;
// Преобразуем URL изображений, если они есть
if (product.images && Array.isArray(product.images)) {
product.images = product.images.map(img => ({
...img,
url: img.url.startsWith('http') ? img.url : `${apiBaseUrl}${img.url}`
}));
}
}
// Проверяем, что получили корректный объект продукта
if (!product || !product.id) {
throw new Error('Некорректные данные продукта в ответе API');
}
console.log('Обработанные данные продукта:', product);
return product;
} catch (apiError: any) {
console.error('Ошибка API при получении продукта по slug:', apiError);
// Проверяем наличие ответа и статуса
if (apiError.response) {
console.error('Статус ошибки:', apiError.response.status);
console.error('Данные ошибки:', apiError.response.data);
// Если продукт не найден, выбрасываем специальную ошибку
if (apiError.response.status === 404) {
throw new Error(`Продукт со slug "${slug}" не найден`);
}
}
// Пробрасываем ошибку дальше
throw apiError;
}
} catch (error) {
console.error('Ошибка при получении продукта по slug:', error);
throw error;
}
},
// Создать товар
createProduct: async (product: Omit<Product, 'id'>): Promise<Product> => {
const response = await api.post('/catalog/products', product);
return response.data;
try {
console.log('Создание нового продукта:', product);
// Проверяем обязательные поля
if (!product.name || !product.category_id) {
throw new Error('Отсутствуют обязательные поля для создания продукта');
}
// Преобразуем числовые поля
const productData = {
...product,
category_id: Number(product.category_id),
collection_id: product.collection_id ? Number(product.collection_id) : null
};
console.log('Отправляемые данные продукта:', productData);
const response = await api.post('/catalog/products', productData);
console.log('Ответ сервера при создании продукта:', response.data);
// Проверяем структуру ответа
if (!response.data || !response.data.product) {
throw new Error('Неверный формат ответа от сервера при создании продукта');
}
return response.data.product;
} catch (error) {
console.error('Ошибка при создании продукта:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
console.error('Заголовки ответа:', error.response.headers);
}
throw error;
}
},
// Обновить товар
updateProduct: async (id: number, product: Partial<Product>): Promise<Product> => {
const response = await api.put(`/catalog/products/${id}`, product);
return response.data;
updateProduct: async (productId: number, product: Partial<Product>): Promise<Product> => {
try {
console.log(`Обновление продукта ${productId}:`, product);
// Преобразуем числовые поля, если они есть
const productData = {
...product,
category_id: product.category_id !== undefined ? Number(product.category_id) : undefined,
collection_id: product.collection_id ? Number(product.collection_id) : null
};
console.log('Отправляемые данные продукта:', productData);
const response = await api.put(`/catalog/products/${productId}`, productData);
console.log('Ответ сервера при обновлении продукта:', response.data);
// Проверяем структуру ответа
if (!response.data || !response.data.product) {
throw new Error('Неверный формат ответа от сервера при обновлении продукта');
}
return response.data.product;
} catch (error) {
console.error('Ошибка при обновлении продукта:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
console.error('Заголовки ответа:', error.response.headers);
}
throw error;
}
},
// Удалить товар
@ -134,45 +407,165 @@ export const productService = {
},
// Загрузить изображение товара
uploadProductImage: async (productId: number, file: File, isPrimary: boolean): Promise<ProductImage> => {
const formData = new FormData();
formData.append('file', file);
formData.append('is_primary', isPrimary.toString());
const response = await api.post(`/catalog/products/${productId}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
uploadProductImage: async (productId: number, file: File, isPrimary: boolean = false): Promise<ProductImage> => {
try {
console.log(`Загрузка изображения для продукта ${productId}. Основное: ${isPrimary}`);
const formData = new FormData();
formData.append('file', file);
formData.append('is_primary', isPrimary.toString());
console.log('Отправка данных формы:', {
productId,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
isPrimary
});
const response = await api.post(`/catalog/products/${productId}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
console.log('Ответ сервера при загрузке изображения:', response.data);
// Проверяем структуру ответа
if (!response.data || !response.data.image) {
throw new Error('Неверный формат ответа от сервера при загрузке изображения');
}
// Преобразуем ответ в нужный формат
const imageData = response.data.image;
// Формируем полный URL для изображения
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
return {
id: imageData.id,
url: `${apiBaseUrl}${imageData.image_url}`,
is_primary: imageData.is_primary,
product_id: imageData.product_id
};
} catch (error) {
console.error('Ошибка при загрузке изображения:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
}
throw error;
}
},
// Обновить изображение товара
updateProductImage: async (imageId: number, data: { is_primary: boolean }): Promise<ProductImage> => {
const response = await api.put(`/catalog/images/${imageId}`, data);
return response.data;
updateProductImage: async (productId: number, imageId: number, data: { is_primary?: boolean }): Promise<ProductImage> => {
try {
console.log(`Обновление изображения ${imageId} для продукта ${productId}`, data);
const response = await api.put(`/catalog/images/${imageId}`, data);
console.log('Ответ сервера при обновлении изображения:', response.data);
// Проверяем структуру ответа
if (!response.data || !response.data.image) {
throw new Error('Неверный формат ответа от сервера при обновлении изображения');
}
// Преобразуем ответ в нужный формат
const imageData = response.data.image;
// Формируем полный URL для изображения
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
return {
id: imageData.id,
url: `${apiBaseUrl}${imageData.image_url}`,
is_primary: imageData.is_primary,
product_id: imageData.product_id
};
} catch (error) {
console.error('Ошибка при обновлении изображения:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
}
throw error;
}
},
// Удалить изображение товара
deleteProductImage: async (imageId: number): Promise<void> => {
await api.delete(`/catalog/images/${imageId}`);
deleteProductImage: async (productId: number, imageId: number): Promise<void> => {
try {
console.log(`Удаление изображения ${imageId} для продукта ${productId}`);
const response = await api.delete(`/catalog/images/${imageId}`);
console.log('Ответ сервера при удалении изображения:', response.data);
// Проверяем успешность операции
if (!response.data || !response.data.success) {
throw new Error('Не удалось удалить изображение');
}
} catch (error) {
console.error('Ошибка при удалении изображения:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
}
throw error;
}
},
// Добавить вариант товара
addProductVariant: async (productId: number, variant: Omit<ProductVariant, 'id' | 'product_id'>): Promise<ProductVariant> => {
const response = await api.post(`/catalog/products/${productId}/variants`, variant);
return response.data;
try {
console.log(`Добавление варианта для продукта ${productId}:`, variant);
// Явно указываем все поля, чтобы избежать лишних данных
const variantData = {
product_id: productId,
name: variant.name,
sku: variant.sku,
price: Number(variant.price), // Убедимся, что это число
discount_price: variant.discount_price ? Number(variant.discount_price) : null,
stock: Number(variant.stock),
is_active: Boolean(variant.is_active || true)
};
console.log('Отправляемые данные варианта:', variantData);
const response = await api.post(`/catalog/products/${productId}/variants`, variantData);
console.log('Ответ сервера при добавлении варианта:', response.data);
// Проверяем структуру ответа
if (!response.data || !response.data.variant) {
throw new Error('Неверный формат ответа от сервера при добавлении варианта');
}
return response.data.variant;
} catch (error) {
console.error('Ошибка при добавлении варианта:', error);
if (error.response) {
console.error('Данные ответа:', error.response.data);
console.error('Статус ответа:', error.response.status);
console.error('Заголовки ответа:', error.response.headers);
}
throw error;
}
},
// Обновить вариант товара
updateProductVariant: async (variantId: number, variant: Partial<ProductVariant>): Promise<ProductVariant> => {
const response = await api.put(`/catalog/variants/${variantId}`, variant);
return response.data;
return response.data.variant;
},
// Удалить вариант товара
deleteProductVariant: async (variantId: number): Promise<void> => {
await api.delete(`/catalog/variants/${variantId}`);
deleteProductVariant: async (productId: number, variantId: number): Promise<void> => {
await api.delete(`/catalog/products/${productId}/variants/${variantId}`);
}
};