в админке точно работают товары и категории, коллекции, остальное не работает.
добавил отображение товара с бд, также все товары с бд
BIN
backend/.DS_Store
vendored
116
backend/alembic.ini
Normal 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
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
BIN
backend/alembic/__pycache__/env.cpython-310.pyc
Normal file
85
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
30
backend/alembic/versions/2325dd0f1bd5_init.py
Normal 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 ###
|
||||
52
backend/alembic/versions/ef40913679bd_update_products.py
Normal 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 ###
|
||||
@ -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 = [
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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]
|
||||
@ -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] = []
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 81 KiB |
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
391
frontend/components/admin/ProductForm.tsx
Normal 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;
|
||||
230
frontend/components/admin/ProductImageUploader.tsx
Normal 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;
|
||||
323
frontend/components/admin/ProductVariantManager.tsx
Normal 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;
|
||||
107
frontend/components/admin/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
348
frontend/pages/admin/collections/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
324
frontend/pages/all-products.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 минут
|
||||
};
|
||||
};
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
};
|
||||