diff --git a/.DS_Store b/.DS_Store
index ac86e42..b626346 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/backend/.DS_Store b/backend/.DS_Store
index a950caf..7604996 100644
Binary files a/backend/.DS_Store and b/backend/.DS_Store differ
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..1766512
--- /dev/null
+++ b/backend/alembic.ini
@@ -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
diff --git a/backend/alembic/README b/backend/alembic/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/backend/alembic/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/backend/alembic/__pycache__/env.cpython-310.pyc b/backend/alembic/__pycache__/env.cpython-310.pyc
new file mode 100644
index 0000000..d2c8974
Binary files /dev/null and b/backend/alembic/__pycache__/env.cpython-310.pyc differ
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..1993cde
--- /dev/null
+++ b/backend/alembic/env.py
@@ -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()
diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/backend/alembic/script.py.mako
@@ -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"}
diff --git a/backend/alembic/versions/2325dd0f1bd5_init.py b/backend/alembic/versions/2325dd0f1bd5_init.py
new file mode 100644
index 0000000..caa275d
--- /dev/null
+++ b/backend/alembic/versions/2325dd0f1bd5_init.py
@@ -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 ###
diff --git a/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc
new file mode 100644
index 0000000..db598a6
Binary files /dev/null and b/backend/alembic/versions/__pycache__/2325dd0f1bd5_init.cpython-310.pyc differ
diff --git a/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc
new file mode 100644
index 0000000..16b070d
Binary files /dev/null and b/backend/alembic/versions/__pycache__/ef40913679bd_update_products.cpython-310.pyc differ
diff --git a/backend/alembic/versions/ef40913679bd_update_products.py b/backend/alembic/versions/ef40913679bd_update_products.py
new file mode 100644
index 0000000..5b087ca
--- /dev/null
+++ b/backend/alembic/versions/ef40913679bd_update_products.py
@@ -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 ###
diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc
index 041cf73..f449340 100644
Binary files a/backend/app/__pycache__/config.cpython-310.pyc and b/backend/app/__pycache__/config.cpython-310.pyc differ
diff --git a/backend/app/config.py b/backend/app/config.py
index 448a63e..721f798 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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 = [
diff --git a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc
index 8d383f7..f490f20 100644
Binary files a/backend/app/models/__pycache__/catalog_models.cpython-310.pyc and b/backend/app/models/__pycache__/catalog_models.cpython-310.pyc differ
diff --git a/backend/app/models/catalog_models.py b/backend/app/models/catalog_models.py
index ca2c9fc..a204857 100644
--- a/backend/app/models/catalog_models.py
+++ b/backend/app/models/catalog_models.py
@@ -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())
diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc
index 634dca2..e6d1551 100644
Binary files a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc and b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc differ
diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py
index 9b16d4f..e2c0b6b 100644
--- a/backend/app/repositories/catalog_repo.py
+++ b/backend/app/repositories/catalog_repo.py
@@ -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():
diff --git a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc
index dab1384..1dac9db 100644
Binary files a/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc and b/backend/app/routers/__pycache__/catalog_router.cpython-310.pyc differ
diff --git a/backend/app/routers/catalog_router.py b/backend/app/routers/catalog_router.py
index e82e00b..8f69936 100644
--- a/backend/app/routers/catalog_router.py
+++ b/backend/app/routers/catalog_router.py
@@ -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]
\ No newline at end of file
diff --git a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc
index 348937d..1936503 100644
Binary files a/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc and b/backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc differ
diff --git a/backend/app/schemas/catalog_schemas.py b/backend/app/schemas/catalog_schemas.py
index 8d7540d..b604302 100644
--- a/backend/app/schemas/catalog_schemas.py
+++ b/backend/app/schemas/catalog_schemas.py
@@ -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] = []
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
index 8b20b10..53c9f4b 100644
--- a/backend/app/services/__init__.py
+++ b/backend/app/services/__init__.py
@@ -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 (
diff --git a/backend/app/services/__pycache__/__init__.cpython-310.pyc b/backend/app/services/__pycache__/__init__.cpython-310.pyc
index b2ab191..690a72e 100644
Binary files a/backend/app/services/__pycache__/__init__.cpython-310.pyc and b/backend/app/services/__pycache__/__init__.cpython-310.pyc differ
diff --git a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc
index f01d205..891c188 100644
Binary files a/backend/app/services/__pycache__/catalog_service.cpython-310.pyc and b/backend/app/services/__pycache__/catalog_service.cpython-310.pyc differ
diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py
index ca43992..d47fbc4 100644
--- a/backend/app/services/catalog_service.py
+++ b/backend/app/services/catalog_service.py
@@ -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
}
diff --git a/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg b/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg
new file mode 100644
index 0000000..02039d9
Binary files /dev/null and b/backend/uploads/products/2/cbd95632-a794-47d2-a926-3b8fc0ad0f49.jpg differ
diff --git a/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg b/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg
new file mode 100644
index 0000000..545e7e1
Binary files /dev/null and b/backend/uploads/products/4/4a3407a0-ec18-4231-8bf8-718853b82c0b.jpg differ
diff --git a/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg b/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg
new file mode 100644
index 0000000..fbfb28d
Binary files /dev/null and b/backend/uploads/products/4/809315dc-7783-4a64-b683-0f6faea11044.jpg differ
diff --git a/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg b/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg
new file mode 100644
index 0000000..4b6f767
Binary files /dev/null and b/backend/uploads/products/7/4aa5e62e-d202-450e-955e-c1905543461b.jpg differ
diff --git a/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg b/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg
new file mode 100644
index 0000000..1a1d2a2
Binary files /dev/null and b/backend/uploads/products/7/5f82bada-b66d-4ee5-8fae-372aecd63424.jpg differ
diff --git a/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg b/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg
new file mode 100644
index 0000000..4b6f767
Binary files /dev/null and b/backend/uploads/products/7/e6b60a4e-be79-472f-8f67-47849a4b1079.jpg differ
diff --git a/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg b/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg
new file mode 100644
index 0000000..a030751
Binary files /dev/null and b/backend/uploads/products/7/fd1e78dc-b38f-4acf-835e-85c6f60611b7.jpg differ
diff --git a/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg b/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg
new file mode 100644
index 0000000..1a1d2a2
Binary files /dev/null and b/backend/uploads/products/8/9440cf88-43a0-4e09-a4ce-44695f07917e.jpg differ
diff --git a/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg b/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg
new file mode 100644
index 0000000..fbfb28d
Binary files /dev/null and b/backend/uploads/products/8/a46e2215-fd5e-4be9-bf83-9dd916b97e1d.jpg differ
diff --git a/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg b/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg
new file mode 100644
index 0000000..a030751
Binary files /dev/null and b/backend/uploads/products/8/fc5f27d9-11b1-44c6-8a97-126284b4ef97.jpg differ
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx
index 4afe6d4..5468e5f 100644
--- a/frontend/components/Header.tsx
+++ b/frontend/components/Header.tsx
@@ -75,6 +75,9 @@ export default function Header() {
Каталог
+
+ Все товары
+
Коллекции
diff --git a/frontend/components/admin/AdminLayout.tsx b/frontend/components/admin/AdminLayout.tsx
index a680a64..ef36443 100644
--- a/frontend/components/admin/AdminLayout.tsx
+++ b/frontend/components/admin/AdminLayout.tsx
@@ -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: ,
+ text: 'Коллекции',
+ href: '/admin/collections',
+ active: currentPath.startsWith('/admin/collections')
}
];
@@ -202,6 +209,13 @@ export default function AdminLayout({ children, title }: AdminLayoutProps) {
+
+
+ На главную
+
+
+
+
На главную
+
diff --git a/frontend/pages/admin/collections/index.tsx b/frontend/pages/admin/collections/index.tsx
new file mode 100644
index 0000000..569a225
--- /dev/null
+++ b/frontend/pages/admin/collections/index.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [editingId, setEditingId] = useState(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 (
+
+
+ {error && (
+
+ Ошибка!
+ {error}
+
+ )}
+
+
+
Добавить новую коллекцию
+
+
+
+
Список коллекций
+
+ {loading && collections.length === 0 ? (
+
+ ) : collections.length === 0 ? (
+
+ Коллекции не найдены
+
+ ) : (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/pages/admin/products/[id].tsx b/frontend/pages/admin/products/[id].tsx
index 78e5840..f0f571a 100644
--- a/frontend/pages/admin/products/[id].tsx
+++ b/frontend/pages/admin/products/[id].tsx
@@ -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 (
+ {error && (
+
+ {error}
+
+ )}
- | Цвет |
- Размер |
+ Название |
Артикул |
Цена |
+ Скидка |
Наличие |
Действия |
@@ -232,19 +349,32 @@ const VariantManager = ({ variants, setVariants, productId }) => {
{variants.map(variant => (
- | {variant.color} |
- {variant.size} |
+ {variant.name} |
{variant.sku} |
{variant.price} |
+ {variant.discount_price || '-'} |
{variant.stock} |
- handleRemoveVariant(variant.id)}
- className="text-red-600 hover:text-red-900"
- >
-
-
+
+ handleEditVariant(variant)}
+ className="text-indigo-600 hover:text-indigo-900"
+ disabled={loading}
+ >
+
+
+ handleRemoveVariant(variant.id)}
+ className="text-red-600 hover:text-red-900"
+ disabled={loading}
+ >
+
+
+
|
))}
@@ -252,20 +382,10 @@ const VariantManager = ({ variants, setVariants, productId }) => {
- |
-
-
|
@@ -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"
/>
+
+
+ |
{
/>
|
-
-
- Добавить
-
+ {editingVariant ? (
+
+
+ {loading ? 'Сохранение...' : 'Сохранить'}
+
+
+ Отмена
+
+
+ ) : (
+
+ {loading ? (
+
+
+ Добавление...
+
+ ) : (
+ <>
+
+ Добавить
+ >
+ )}
+
+ )}
|
@@ -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(null);
- const [images, setImages] = useState([]);
- const [variants, setVariants] = useState([]);
- const [categories, setCategories] = useState([]);
- 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 (
@@ -490,190 +522,26 @@ export default function EditProductPage() {
);
}
- if (!product && !loading) {
+ if (error) {
return (
-
-
-
+
+
+ Ошибка!
+ {error}
+ 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"
+ >
+ Вернуться к списку товаров
+
- 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"
- >
- Вернуться к списку товаров
-
);
}
return (
-
-
+
+ {product && }
);
}
\ No newline at end of file
diff --git a/frontend/pages/admin/products/create.tsx b/frontend/pages/admin/products/create.tsx
index 31d855d..da4131f 100644
--- a/frontend/pages/admin/products/create.tsx
+++ b/frontend/pages/admin/products/create.tsx
@@ -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 (
-
-
-
- {images.map(image => (
-
-

-
- handleSetPrimary(image.id)}
- className={`p-1 rounded-full ${image.isPrimary ? 'bg-green-500' : 'bg-white'} text-black mr-2`}
- title={image.isPrimary ? "Основное изображение" : "Сделать основным"}
- >
- ★
-
- handleRemoveImage(image.id)}
- className="p-1 rounded-full bg-red-500 text-white"
- title="Удалить"
- >
-
-
-
- {image.isPrimary && (
-
- Основное
-
- )}
-
- ))}
-
-
-
Загрузите до 8 изображений товара. Первое изображение будет использоваться как основное.
-
- );
-};
-
-// Компонент для вариантов товара
-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 (
-
- );
-};
+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([]);
- const [categories, setCategories] = useState([]);
- 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 (
-
-
+
+
);
}
\ No newline at end of file
diff --git a/frontend/pages/admin/products/index.tsx b/frontend/pages/admin/products/index.tsx
index cd08a69..e74cd83 100644
--- a/frontend/pages/admin/products/index.tsx
+++ b/frontend/pages/admin/products/index.tsx
@@ -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(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
+ const [expandedProducts, setExpandedProducts] = useState([]);
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 (
@@ -236,58 +255,130 @@ export default function ProductsPage() {
{products.map((product) => (
-
-
-
-
- 0
- ? product.images.find(img => img.is_primary)?.url || product.images[0].url
- : '/placeholder.jpg'}
- alt={product.name}
- fill
- className="object-cover rounded-md"
- />
+ <>
+
+
+
+
+ 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';
+ }}
+ />
+
+
+ {product.name}
+ {product.variants && product.variants.length > 0 && (
+ toggleProductVariants(product.id)}
+ className="ml-2 text-gray-500 hover:text-gray-700"
+ >
+ {expandedProducts.includes(product.id) ?
+ :
+
+ }
+
+ )}
+
-
-
- |
- {product.sku} |
- {product.price.toLocaleString('ru-RU')} ₽ |
- {product.category?.name || 'Без категории'} |
-
- 20 ? 'bg-green-100 text-green-800' :
- product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
- 'bg-red-100 text-red-800'}`}>
- {product.stock}
-
- |
-
- 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 ? 'Активен' : 'Неактивен'}
-
- |
-
-
-
-
-
- handleDeleteProduct(product.id)}
- className="text-red-600 hover:text-red-900"
+ |
+
+ {product.variants && product.variants.length > 0
+ ? product.variants[0].sku
+ : 'Нет артикула'}
+ |
+
+ {product.variants && product.variants.length > 0
+ ? `${product.variants[0].price.toLocaleString('ru-RU')} ₽${product.variants.length > 1 && !expandedProducts.includes(product.id) ? ' (и другие)' : ''}`
+ : 'Нет цены'}
+ |
+ {categories.find(c => c.id === product.category_id)?.name || 'Без категории'} |
+
+ 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}
+
+ |
+
+ 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 ? 'Активен' : 'Неактивен'}
-
- |
-
+ |
+
+
+
+
+
+ handleDeleteProduct(product.id)}
+ className="text-red-600 hover:text-red-900"
+ >
+
+
+
+ |
+
+
+ {/* Варианты продукта */}
+ {expandedProducts.includes(product.id) && product.variants && product.variants.map((variant) => (
+
+
+
+
+
+ Вариант: {variant.name}
+
+
+
+ |
+
+ {variant.sku}
+ |
+
+ {`${variant.price.toLocaleString('ru-RU')} ₽`}
+ {variant.discount_price && (
+
+ {variant.discount_price.toLocaleString('ru-RU')} ₽
+
+ )}
+ |
+ - |
+
+ 10 ? 'bg-green-100 text-green-800' :
+ variant.stock > 5 ? 'bg-yellow-100 text-yellow-800' :
+ 'bg-red-100 text-red-800'}`}>
+ {variant.stock}
+
+ |
+
+
+ {variant.is_active ? 'Активен' : 'Неактивен'}
+
+ |
+ - |
+
+ ))}
+ >
))}
{products.length === 0 && !loading && (
diff --git a/frontend/pages/all-products.tsx b/frontend/pages/all-products.tsx
new file mode 100644
index 0000000..5337549
--- /dev/null
+++ b/frontend/pages/all-products.tsx
@@ -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([]);
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [favorites, setFavorites] = useState([]);
+ const [hoveredProduct, setHoveredProduct] = useState(null);
+
+ // Состояние для фильтров
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+ const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ min: null, max: null });
+ const [showFilters, setShowFilters] = useState(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 (
+
+
+
Все товары | Brand Store
+
+
+
+
+
+
+ Все товары
+
+ {/* Фильтры и поиск */}
+
+
+
+
+
+
+
setSearchTerm(e.target.value)}
+ />
+
+
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"
+ >
+
+ Фильтры
+ {showFilters ? : }
+
+
+
+ {/* Расширенные фильтры */}
+ {showFilters && (
+
+
+
+
+
+
+
+
+ setPriceRange({ ...priceRange, min: e.target.value ? parseInt(e.target.value) : null })}
+ placeholder="От"
+ />
+
+
+
+ setPriceRange({ ...priceRange, max: e.target.value ? parseInt(e.target.value) : null })}
+ placeholder="До"
+ />
+
+
+
+
+
+ Сбросить фильтры
+
+
+
+ )}
+
+ {/* Активные фильтры */}
+ {(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
+
+ {searchTerm && (
+
+ Поиск: {searchTerm}
+ setSearchTerm('')} className="ml-2">
+
+
+
+ )}
+ {selectedCategory && (
+
+ Категория: {allCategories.find(c => c.id === selectedCategory)?.name}
+ setSelectedCategory(null)} className="ml-2">
+
+
+
+ )}
+ {priceRange.min && (
+
+ От: {priceRange.min} ₽
+ setPriceRange({ ...priceRange, min: null })} className="ml-2">
+
+
+
+ )}
+ {priceRange.max && (
+
+ До: {priceRange.max} ₽
+ setPriceRange({ ...priceRange, max: null })} className="ml-2">
+
+
+
+ )}
+
+ )}
+
+
+ {/* Отображение товаров */}
+ {loading ? (
+
+ ) : error ? (
+
+ Ошибка!
+ {error}
+ 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"
+ >
+ Попробовать снова
+
+
+ ) : products.length === 0 ? (
+
+
Товары не найдены. Попробуйте изменить параметры поиска.
+ {(searchTerm || selectedCategory || priceRange.min || priceRange.max) && (
+
+ Сбросить все фильтры
+
+ )}
+
+ ) : (
+
+ {products.map((product) => (
+
+
setHoveredProduct(product.id)}
+ onMouseLeave={() => setHoveredProduct(null)}
+ >
+
+
+ {product.images && product.images.length > 0 ? (
+
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"
+ />
+ ) : (
+
+ Нет изображения
+
+ )}
+ 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) ? "Удалить из избранного" : "Добавить в избранное"}
+ >
+
+
+
+
+
+
{product.name}
+ {product.variants && product.variants.length > 0 ? (
+
+ {formatPrice(product.variants[0].price)} ₽
+ {product.variants[0].discount_price && (
+
+ {formatPrice(product.variants[0].discount_price)} ₽
+
+ )}
+
+ ) : (
+
Цена по запросу
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/pages/product/[slug].tsx b/frontend/pages/product/[slug].tsx
index 95ef8ff..b6230ab 100644
--- a/frontend/pages/product/[slug].tsx
+++ b/frontend/pages/product/[slug].tsx
@@ -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(null);
+ const [selectedVariant, setSelectedVariant] = useState(
+ 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 (
@@ -75,8 +139,8 @@ export default function ProductPage({ product, category, similarProducts }: Prod
-
- ← {category.name}
+
+ ← {product.category ? product.category.name : 'Назад к категориям'}
@@ -93,19 +157,25 @@ export default function ProductPage({ product, category, similarProducts }: Prod
transition={{ duration: 0.3 }}
className="absolute inset-0"
>
-
+ {product.images && product.images.length > 0 ? (
+
+ ) : (
+
+ Нет изображения
+
+ )}
{/* Кнопки навигации по галерее */}
- {product.images.length > 1 && (
+ {product.images && product.images.length > 1 && (
<>
{/* Миниатюры изображений */}
- {product.images.length > 1 && (
+ {product.images && product.images.length > 1 && (
{product.images.map((image, index) => (
-
+
))}
@@ -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 && (
+
+ {formatPrice(currentDiscountPrice)} ₽
+
+ )}
+ >
+ ) : (
+ 'Цена по запросу'
+ )}
+ {/* Варианты товара */}
+ {product.variants && product.variants.length > 0 && (
+
+ Варианты
+
+ {product.variants.map((variant) => (
+
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'
+ }`}
+ >
+
+ {selectedVariant?.id === variant.id && }
+ {variant.name}
+ {variant.stock <= 3 && variant.stock > 0 && (
+ Осталось {variant.stock} шт.
+ )}
+ {variant.stock === 0 && (
+ Нет в наличии
+ )}
+
+
+ ))}
+
+
+ )}
+
Описание
@@ -177,7 +294,7 @@ export default function ProductPage({ product, category, similarProducts }: Prod
Количество
@@ -204,15 +321,18 @@ export default function ProductPage({ product, category, similarProducts }: Prod
- Добавить в корзину
+ {selectedVariant && selectedVariant.stock === 0 ? 'Нет в наличии' : 'Добавить в корзину'}
Категория
-
- {category.name}
-
+ {product.category && (
+
+ {product.category.name}
+
+ )}
+
+ {product.collection && (
+
+
Коллекция
+
+ {product.collection.name}
+
+ {product.collection.description && (
+
{product.collection.description}
+ )}
+
+ )}
@@ -262,27 +396,39 @@ export default function ProductPage({ product, category, similarProducts }: Prod
>
-
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 && (
-
- Новинка
-
+ {similarProduct.images && similarProduct.images.length > 0 ? (
+ 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"
+ />
+ ) : (
+
+ Нет изображения
+
)}
{similarProduct.name}
-
{formatPrice(similarProduct.price)} ₽
+ {similarProduct.variants && similarProduct.variants.length > 0 ? (
+
+ {formatPrice(similarProduct.variants[0].price)} ₽
+ {similarProduct.variants[0].discount_price && (
+
+ {formatPrice(similarProduct.variants[0].discount_price)} ₽
+
+ )}
+
+ ) : (
+
Цена по запросу
+ )}
@@ -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 минут
- };
};
\ No newline at end of file
diff --git a/frontend/services/api.ts b/frontend/services/api.ts
index 505b50e..57d9e82 100644
--- a/frontend/services/api.ts
+++ b/frontend/services/api.ts
@@ -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';
+ }
}
}
diff --git a/frontend/services/catalog.ts b/frontend/services/catalog.ts
index 1e9f5a3..961968c 100644
--- a/frontend/services/catalog.ts
+++ b/frontend/services/catalog.ts
@@ -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 => {
+ const response = await api.get('/catalog/collections');
+ return response.data.collections;
+ },
+
+ // Создать новую коллекцию
+ createCollection: async (collection: Omit): Promise => {
+ const response = await api.post('/catalog/collections', collection);
+ return response.data.collection;
+ },
+
+ // Обновить коллекцию
+ updateCollection: async (id: number, collection: Partial): Promise => {
+ const response = await api.put(`/catalog/collections/${id}`, collection);
+ return response.data.collection;
+ },
+
+ // Удалить коллекцию
+ deleteCollection: async (id: number): Promise => {
+ 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 => {
- 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 => {
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 => {
+ 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): Promise => {
- 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): Promise => {
- const response = await api.put(`/catalog/products/${id}`, product);
- return response.data;
+ updateProduct: async (productId: number, product: Partial): Promise => {
+ 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 => {
- 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 => {
+ 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 => {
- const response = await api.put(`/catalog/images/${imageId}`, data);
- return response.data;
+ updateProductImage: async (productId: number, imageId: number, data: { is_primary?: boolean }): Promise => {
+ 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 => {
- await api.delete(`/catalog/images/${imageId}`);
+ deleteProductImage: async (productId: number, imageId: number): Promise => {
+ 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): Promise => {
- 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): Promise => {
const response = await api.put(`/catalog/variants/${variantId}`, variant);
- return response.data;
+ return response.data.variant;
},
// Удалить вариант товара
- deleteProductVariant: async (variantId: number): Promise => {
- await api.delete(`/catalog/variants/${variantId}`);
+ deleteProductVariant: async (productId: number, variantId: number): Promise => {
+ await api.delete(`/catalog/products/${productId}/variants/${variantId}`);
}
};
\ No newline at end of file