Админка, не доделана

This commit is contained in:
Zikil 2025-03-01 00:29:58 +07:00
parent bfc77be0d6
commit 834d7f59da
61 changed files with 7960 additions and 92 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

BIN
backend/app/.DS_Store vendored

Binary file not shown.

View File

@ -72,14 +72,14 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
try:
payload = verify_token(token)
username: str = payload.get("sub")
if username is None:
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
from app.repositories.user_repo import get_user_by_username
user = get_user_by_username(db, username)
from app.repositories.user_repo import get_user_by_email
user = get_user_by_email(db, email)
if user is None:
raise credentials_exception

View File

@ -205,7 +205,7 @@ def get_review_with_user(db: Session, review_id: int) -> Dict[str, Any]:
"is_approved": review.is_approved,
"created_at": review.created_at,
"updated_at": review.updated_at,
"user_username": user.username if user else "Неизвестный пользователь"
"user_email": user.email if user else "Неизвестный пользователь"
}

View File

@ -17,34 +17,26 @@ def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_user_by_username(db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user: UserCreate) -> User:
# Проверяем, что пользователь с таким email или username не существует
# Проверяем, что пользователь с таким email не существует
if get_user_by_email(db, user.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким email уже существует"
)
if get_user_by_username(db, user.username):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким username уже существует"
)
# Создаем нового пользователя
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
username=user.username,
hashed_password=hashed_password,
password=hashed_password,
phone=user.phone,
first_name=user.first_name,
last_name=user.last_name,
is_active=user.is_active,
is_admin=user.is_admin
)
@ -75,12 +67,12 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
# Если предоставлен новый пароль, хешируем его
if "password" in update_data and update_data["password"]:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
update_data["password"] = get_password_hash(update_data.pop("password"))
# Удаляем поле password_confirm, если оно есть
update_data.pop("password_confirm", None)
# Проверяем уникальность email и username, если они изменяются
# Проверяем уникальность email, если он изменяется
if "email" in update_data and update_data["email"] != db_user.email:
if get_user_by_email(db, update_data["email"]):
raise HTTPException(
@ -88,13 +80,6 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
detail="Пользователь с таким email уже существует"
)
if "username" in update_data and update_data["username"] != db_user.username:
if get_user_by_username(db, update_data["username"]):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Пользователь с таким username уже существует"
)
# Применяем обновления
for key, value in update_data.items():
setattr(db_user, key, value)
@ -131,11 +116,11 @@ def delete_user(db: Session, user_id: int) -> bool:
)
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
user = get_user_by_username(db, username)
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
user = get_user_by_email(db, email)
if not user:
return None
if not verify_password(password, user.hashed_password):
if not verify_password(password, user.password):
return None
return user
@ -241,4 +226,53 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при удалении адреса"
)
)
# Функции для работы с паролями и токенами сброса
def update_password(db: Session, user_id: int, new_password: str) -> bool:
"""Обновляет пароль пользователя"""
db_user = get_user(db, user_id)
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден"
)
hashed_password = get_password_hash(new_password)
db_user.password = hashed_password
try:
db.commit()
return True
except Exception:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ошибка при обновлении пароля"
)
def create_password_reset_token(db: Session, user_id: int) -> str:
"""Создает токен для сброса пароля"""
import secrets
import datetime
# В реальном приложении здесь должна быть модель для токенов сброса пароля
# Для примера просто генерируем случайный токен
token = secrets.token_urlsafe(32)
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
# и временем истечения срока действия
return token
def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
# В реальном приложении проверяем токен в базе данных
# и его срок действия
# Для примера просто возвращаем фиктивный ID пользователя
# В реальном приложении это должна быть проверка в базе данных
return 1 # Фиктивный ID пользователя

View File

@ -3,9 +3,9 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import Dict, Any
from app.core import get_db
from app.core import get_db, get_current_user
from app import services
from app.schemas.user_schemas import UserCreate, Token
from app.schemas.user_schemas import UserCreate, Token, PasswordReset, PasswordChange, User
# Роутер для аутентификации
auth_router = APIRouter(prefix="/auth", tags=["Аутентификация"])
@ -15,7 +15,36 @@ async def register(user: UserCreate, db: Session = Depends(get_db)):
return services.register_user(db, user)
@auth_router.post("/token", response_model=Token)
@auth_router.post("/login", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
# Примечание: form_data.username на самом деле содержит email пользователя
# OAuth2PasswordRequestForm использует поле username, но мы используем его для email
result = services.login_user(db, form_data.username, form_data.password)
return result
return result
@auth_router.post("/reset-password", response_model=Dict[str, Any])
async def reset_password(reset_data: PasswordReset, db: Session = Depends(get_db)):
"""Запрос на сброс пароля"""
return services.request_password_reset(db, reset_data.email)
@auth_router.post("/set-new-password", response_model=Dict[str, Any])
async def set_new_password(token: str, password: str, db: Session = Depends(get_db)):
"""Установка нового пароля по токену сброса"""
return services.reset_password(db, token, password)
@auth_router.post("/change-password", response_model=Dict[str, Any])
async def change_password(
password_data: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Изменение пароля авторизованным пользователем"""
return services.change_password(
db,
current_user.id,
password_data.current_password,
password_data.new_password
)

View File

@ -104,4 +104,6 @@ async def get_products_endpoint(
is_active: Optional[bool] = True,
db: Session = Depends(get_db)
):
return get_products(db, skip, limit, category_id, search, min_price, max_price, is_active)
products = get_products(db, skip, limit, category_id, search, min_price, max_price, is_active)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
return [Product.model_validate(product) for product in products]

View File

@ -131,12 +131,18 @@ class ProductImage(ProductImageBase):
class CategoryWithSubcategories(Category):
subcategories: List['CategoryWithSubcategories'] = []
class Config:
from_attributes = True
class ProductWithDetails(Product):
category: Category
variants: List[ProductVariant] = []
images: List[ProductImage] = []
class Config:
from_attributes = True
# Рекурсивное обновление для CategoryWithChildren
CategoryWithSubcategories.update_forward_refs()

View File

@ -34,4 +34,4 @@ class Review(ReviewBase):
class ReviewWithUser(Review):
user_username: str
user_email: str

View File

@ -39,7 +39,9 @@ class Address(AddressBase):
# Базовые схемы для пользователя
class UserBase(BaseModel):
email: EmailStr
username: str
phone: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
is_active: bool = True
is_admin: bool = False
@ -57,7 +59,9 @@ class UserCreate(UserBase):
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
phone: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
password: Optional[str] = Field(None, min_length=8)
@ -81,7 +85,7 @@ class User(UserBase):
class UserInDB(User):
hashed_password: str
password: str
# Схемы для аутентификации
@ -91,5 +95,14 @@ class Token(BaseModel):
class TokenData(BaseModel):
username: Optional[str] = None
user_id: Optional[int] = None
email: Optional[str] = None
user_id: Optional[int] = None
class PasswordReset(BaseModel):
email: EmailStr
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8)

View File

@ -1,6 +1,7 @@
from app.services.user_service import (
register_user, login_user, get_user_profile, update_user_profile,
add_user_address, update_user_address, delete_user_address
add_user_address, update_user_address, delete_user_address,
request_password_reset, reset_password, change_password
)
from app.services.catalog_service import (

View File

@ -18,13 +18,21 @@ from app.schemas.catalog_schemas import (
# Сервисы каталога
def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Category as CategorySchema
new_category = catalog_repo.create_category(db, category)
return {"category": new_category}
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(new_category)
return {"category": category_schema}
def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Category as CategorySchema
updated_category = catalog_repo.update_category(db, category_id, category)
return {"category": updated_category}
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(updated_category)
return {"category": category_schema}
def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
@ -33,51 +41,55 @@ 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
# Получаем все категории верхнего уровня
root_categories = catalog_repo.get_categories(db, parent_id=None)
result = []
for category in root_categories:
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(category)
# Рекурсивно получаем подкатегории
category_dict = {
"id": category.id,
"name": category.name,
"slug": category.slug,
"description": category.description,
"is_active": category.is_active,
"subcategories": _get_subcategories(db, category.id)
}
category_dict = category_schema.model_dump()
category_dict["subcategories"] = _get_subcategories(db, category.id)
result.append(category_dict)
return result
def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]:
from app.schemas.catalog_schemas import Category as CategorySchema
subcategories = catalog_repo.get_categories(db, parent_id=parent_id)
result = []
for category in subcategories:
category_dict = {
"id": category.id,
"name": category.name,
"slug": category.slug,
"description": category.description,
"is_active": category.is_active,
"subcategories": _get_subcategories(db, category.id)
}
# Преобразуем объект SQLAlchemy в схему Pydantic
category_schema = CategorySchema.model_validate(category)
category_dict = category_schema.model_dump()
category_dict["subcategories"] = _get_subcategories(db, category.id)
result.append(category_dict)
return result
def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Product as ProductSchema
new_product = catalog_repo.create_product(db, product)
return {"product": new_product}
# Преобразуем объект SQLAlchemy в схему Pydantic
product_schema = ProductSchema.model_validate(new_product)
return {"product": product_schema}
def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import Product as ProductSchema
updated_product = catalog_repo.update_product(db, product_id, product)
return {"product": updated_product}
# Преобразуем объект SQLAlchemy в схему Pydantic
product_schema = ProductSchema.model_validate(updated_product)
return {"product": product_schema}
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
@ -86,6 +98,10 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
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
product = catalog_repo.get_product(db, product_id)
if not product:
raise HTTPException(
@ -105,23 +121,36 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
# Получаем отзывы продукта
reviews = review_repo.get_product_reviews(db, product_id, limit=5)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
product_schema = ProductSchema.model_validate(product)
variants_schema = [ProductVariantSchema.model_validate(variant) for variant in variants]
images_schema = [ProductImageSchema.model_validate(image) for image in images]
return {
"product": product,
"variants": variants,
"images": images,
"product": product_schema,
"variants": variants_schema,
"images": images_schema,
"rating": rating,
"reviews": reviews
}
def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
new_variant = catalog_repo.create_product_variant(db, variant)
return {"variant": new_variant}
# Преобразуем объект SQLAlchemy в схему Pydantic
variant_schema = ProductVariantSchema.model_validate(new_variant)
return {"variant": variant_schema}
def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> Dict[str, Any]:
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
updated_variant = catalog_repo.update_product_variant(db, variant_id, variant)
return {"variant": updated_variant}
# Преобразуем объект SQLAlchemy в схему Pydantic
variant_schema = ProductVariantSchema.model_validate(updated_variant)
return {"variant": variant_schema}
def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
@ -130,6 +159,8 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
def upload_product_image(db: Session, product_id: int, file: UploadFile, is_primary: bool = False) -> Dict[str, Any]:
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
# Проверяем, что продукт существует
product = catalog_repo.get_product(db, product_id)
if not product:
@ -168,12 +199,18 @@ def upload_product_image(db: Session, product_id: int, file: UploadFile, is_prim
new_image = catalog_repo.create_product_image(db, image_data)
return {"image": new_image}
# Преобразуем объект SQLAlchemy в схему Pydantic
image_schema = ProductImageSchema.model_validate(new_image)
return {"image": image_schema}
def update_product_image(db: Session, image_id: int, is_primary: bool) -> Dict[str, Any]:
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
updated_image = catalog_repo.update_product_image(db, image_id, is_primary)
return {"image": updated_image}
# Преобразуем объект SQLAlchemy в схему Pydantic
image_schema = ProductImageSchema.model_validate(updated_image)
return {"image": image_schema}
def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:

View File

@ -9,6 +9,8 @@ from app.schemas.content_schemas import AnalyticsLogCreate
# Сервисы корзины и заказов
def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]:
from app.schemas.order_schemas import CartItem as CartItemSchema
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
# Логируем событие добавления в корзину
@ -20,12 +22,18 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
)
content_repo.log_analytics_event(db, log_data)
return {"cart_item": new_cart_item}
# Преобразуем объект SQLAlchemy в схему Pydantic
cart_item_schema = CartItemSchema.model_validate(new_cart_item)
return {"cart_item": cart_item_schema}
def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]:
from app.schemas.order_schemas import CartItem as CartItemSchema
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
return {"cart_item": updated_cart_item}
# Преобразуем объект SQLAlchemy в схему Pydantic
cart_item_schema = CartItemSchema.model_validate(updated_cart_item)
return {"cart_item": cart_item_schema}
def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]:
@ -39,11 +47,15 @@ def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
from app.schemas.order_schemas import CartItem as CartItemSchema
# Получаем элементы корзины с деталями продуктов
cart_items = order_repo.get_cart_with_product_details(db, user_id)
# Рассчитываем общую сумму корзины
total_amount = sum(item["total_price"] for item in cart_items)
# Примечание: cart_items уже содержит сериализованные данные из репозитория
return {
"items": cart_items,
"total_amount": total_amount,
@ -52,6 +64,8 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
from app.schemas.order_schemas import Order as OrderSchema
new_order = order_repo.create_order(db, order, user_id)
# Логируем событие создания заказа
@ -62,7 +76,9 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
)
content_repo.log_analytics_event(db, log_data)
return {"order": new_order}
# Преобразуем объект SQLAlchemy в схему Pydantic
order_schema = OrderSchema.model_validate(new_order)
return {"order": order_schema}
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
@ -80,6 +96,8 @@ def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False)
def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]:
from app.schemas.order_schemas import Order as OrderSchema
updated_order = order_repo.update_order(db, order_id, order, is_admin)
# Проверяем права доступа
@ -89,10 +107,14 @@ def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, i
detail="Недостаточно прав для обновления этого заказа"
)
return {"order": updated_order}
# Преобразуем объект SQLAlchemy в схему Pydantic
order_schema = OrderSchema.model_validate(updated_order)
return {"order": order_schema}
def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
from app.schemas.order_schemas import Order as OrderSchema
# Отменяем заказ (обычный пользователь может только отменить заказ)
order_update = OrderUpdate(status="cancelled")
updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False)
@ -104,4 +126,6 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
detail="Недостаточно прав для отмены этого заказа"
)
return {"order": updated_order}
# Преобразуем объект SQLAlchemy в схему Pydantic
order_schema = OrderSchema.model_validate(updated_order)
return {"order": order_schema}

View File

@ -7,13 +7,21 @@ from app.schemas.review_schemas import ReviewCreate, ReviewUpdate
# Сервисы отзывов
def create_review(db: Session, user_id: int, review: ReviewCreate) -> Dict[str, Any]:
from app.schemas.review_schemas import Review as ReviewSchema
new_review = review_repo.create_review(db, review, user_id)
return {"review": new_review}
# Преобразуем объект SQLAlchemy в схему Pydantic
review_schema = ReviewSchema.model_validate(new_review)
return {"review": review_schema}
def update_review(db: Session, user_id: int, review_id: int, review: ReviewUpdate, is_admin: bool = False) -> Dict[str, Any]:
from app.schemas.review_schemas import Review as ReviewSchema
updated_review = review_repo.update_review(db, review_id, review, user_id, is_admin)
return {"review": updated_review}
# Преобразуем объект SQLAlchemy в схему Pydantic
review_schema = ReviewSchema.model_validate(updated_review)
return {"review": review_schema}
def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = False) -> Dict[str, Any]:
@ -22,18 +30,27 @@ def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = Fa
def approve_review(db: Session, review_id: int) -> Dict[str, Any]:
from app.schemas.review_schemas import Review as ReviewSchema
approved_review = review_repo.approve_review(db, review_id)
return {"review": approved_review}
# Преобразуем объект SQLAlchemy в схему Pydantic
review_schema = ReviewSchema.model_validate(approved_review)
return {"review": review_schema}
def get_product_reviews(db: Session, product_id: int, skip: int = 0, limit: int = 10) -> Dict[str, Any]:
from app.schemas.review_schemas import Review as ReviewSchema
reviews = review_repo.get_product_reviews(db, product_id, skip, limit)
# Получаем рейтинг продукта
rating = review_repo.get_product_rating(db, product_id)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
reviews_schema = [ReviewSchema.model_validate(review) for review in reviews]
return {
"reviews": reviews,
"reviews": reviews_schema,
"rating": rating,
"total": rating["total_reviews"],
"skip": skip,

View File

@ -11,9 +11,14 @@ from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, Addr
# Сервисы аутентификации и пользователей
def register_user(db: Session, user: UserCreate) -> Dict[str, Any]:
from app.schemas.user_schemas import User as UserSchema
# Создаем пользователя
db_user = user_repo.create_user(db, user)
# Преобразуем объект SQLAlchemy в схему Pydantic
user_schema = UserSchema.model_validate(db_user)
# Создаем токен доступа
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
@ -21,7 +26,7 @@ def register_user(db: Session, user: UserCreate) -> Dict[str, Any]:
)
return {
"user": db_user,
"user": user_schema,
"access_token": access_token,
"token_type": "bearer"
}
@ -57,6 +62,9 @@ def login_user(db: Session, email: str, password: str) -> Dict[str, Any]:
def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
from app.schemas.user_schemas import User as UserSchema, Address as AddressSchema
from app.schemas.review_schemas import Review as ReviewSchema
user = user_repo.get_user(db, user_id)
if not user:
raise HTTPException(
@ -73,29 +81,121 @@ def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
# Получаем отзывы пользователя
reviews = review_repo.get_user_reviews(db, user_id)
# Преобразуем объекты SQLAlchemy в схемы Pydantic
user_dict = {
"id": user.id,
"email": user.email,
"phone": user.phone,
"first_name": user.first_name,
"last_name": user.last_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"created_at": user.created_at,
"updated_at": user.updated_at,
"addresses": []
}
user_schema = UserSchema.model_validate(user_dict)
addresses_schema = [AddressSchema.model_validate({
"id": address.id,
"user_id": address.user_id,
"address_line1": address.address_line1,
"address_line2": address.address_line2,
"city": address.city,
"state": address.state,
"postal_code": address.postal_code,
"country": address.country,
"is_default": address.is_default,
"created_at": address.created_at,
"updated_at": address.updated_at
}) for address in addresses]
reviews_schema = [ReviewSchema.model_validate(review.__dict__) for review in reviews]
return {
"user": user,
"addresses": addresses,
"orders": orders,
"reviews": reviews
"user": user_schema,
"addresses": addresses_schema,
"orders": orders, # Заказы обрабатываются отдельно в сервисе заказов
"reviews": reviews_schema
}
def update_user_profile(db: Session, user_id: int, user_data: UserUpdate) -> Dict[str, Any]:
from app.schemas.user_schemas import User as UserSchema
updated_user = user_repo.update_user(db, user_id, user_data)
return {"user": updated_user}
# Преобразуем объект SQLAlchemy в схему Pydantic
user_schema = UserSchema.model_validate(updated_user)
return {"user": user_schema}
def add_user_address(db: Session, user_id: int, address: AddressCreate) -> Dict[str, Any]:
from app.schemas.user_schemas import Address as AddressSchema
new_address = user_repo.create_address(db, address, user_id)
return {"address": new_address}
# Преобразуем объект SQLAlchemy в схему Pydantic
address_schema = AddressSchema.model_validate(new_address)
return {"address": address_schema}
def update_user_address(db: Session, user_id: int, address_id: int, address: AddressUpdate) -> Dict[str, Any]:
from app.schemas.user_schemas import Address as AddressSchema
updated_address = user_repo.update_address(db, address_id, address, user_id)
return {"address": updated_address}
# Преобразуем объект SQLAlchemy в схему Pydantic
address_schema = AddressSchema.model_validate(updated_address)
return {"address": address_schema}
def delete_user_address(db: Session, user_id: int, address_id: int) -> Dict[str, Any]:
success = user_repo.delete_address(db, address_id, user_id)
return {"success": success}
return {"success": success}
def request_password_reset(db: Session, email: str) -> Dict[str, Any]:
"""Запрос на сброс пароля"""
# Проверяем, существует ли пользователь с таким email
user = user_repo.get_user_by_email(db, email)
if not user:
# Не сообщаем, что пользователь не существует (безопасность)
return {"message": "Если указанный email зарегистрирован, на него будет отправлена инструкция по сбросу пароля"}
# Генерируем токен для сброса пароля
reset_token = user_repo.create_password_reset_token(db, user.id)
# В реальном приложении здесь должна быть отправка email
# Для примера просто возвращаем токен
return {
"message": "Инструкция по сбросу пароля отправлена на указанный email",
"token": reset_token # В реальном приложении не возвращаем токен
}
def reset_password(db: Session, token: str, new_password: str) -> Dict[str, Any]:
"""Сброс пароля по токену"""
# Проверяем токен и получаем пользователя
user_id = user_repo.verify_password_reset_token(db, token)
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Недействительный или устаревший токен сброса пароля"
)
# Обновляем пароль
user_repo.update_password(db, user_id, new_password)
return {"message": "Пароль успешно изменен"}
def change_password(db: Session, user_id: int, current_password: str, new_password: str) -> Dict[str, Any]:
"""Изменение пароля авторизованным пользователем"""
# Проверяем текущий пароль
user = user_repo.get_user(db, user_id)
if not user or not user_repo.verify_password(current_password, user.password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль"
)
# Обновляем пароль
user_repo.update_password(db, user_id, new_password)
return {"message": "Пароль успешно изменен"}

View File

@ -1,14 +1,24 @@
import Link from "next/link";
import { Search, Heart, User, ShoppingCart, ChevronLeft } from "lucide-react";
import { Search, Heart, User, ShoppingCart, ChevronLeft, LogOut } from "lucide-react";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Image from "next/image";
import { useRouter } from "next/router";
import authService from "../services/auth";
export default function Header() {
const router = useRouter();
// Состояние для отслеживания прокрутки страницы
const [scrolled, setScrolled] = useState(false);
// Состояние для отслеживания аутентификации пользователя
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Состояние для отображения выпадающего меню пользователя
const [showUserMenu, setShowUserMenu] = useState(false);
// Эффект для проверки аутентификации при загрузке компонента
useEffect(() => {
setIsAuthenticated(authService.isAuthenticated());
}, []);
// Эффект для отслеживания прокрутки
useEffect(() => {
@ -25,6 +35,14 @@ export default function Header() {
};
}, [scrolled]);
// Функция для выхода из системы
const handleLogout = () => {
authService.logout();
setIsAuthenticated(false);
setShowUserMenu(false);
router.push('/');
};
// Функция для возврата на предыдущую страницу
const goBack = () => {
router.back();
@ -35,6 +53,11 @@ export default function Header() {
// Проверяем, находимся ли мы на странице категорий или коллекций
const isDetailPage = router.pathname.includes("[slug]");
// Функция для переключения отображения меню пользователя
const toggleUserMenu = () => {
setShowUserMenu(!showUserMenu);
};
return (
<header className="fixed w-full z-50 transition-all duration-300 bg-white shadow-sm">
<nav className="py-4 transition-all duration-300 text-black">
@ -79,9 +102,53 @@ export default function Header() {
0
</span>
</Link>
<Link href="/account" className="hover:opacity-70 transition-opacity">
<User className="w-5 h-5" />
</Link>
<div className="relative">
<button
onClick={toggleUserMenu}
className="hover:opacity-70 transition-opacity focus:outline-none"
>
<User className="w-5 h-5" />
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
{isAuthenticated ? (
<>
<Link href="/account" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Мой профиль
</Link>
<Link href="/account/orders" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Мои заказы
</Link>
{/* Ссылка на админ-панель, если пользователь админ */}
<Link href="/admin" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Админ-панель
</Link>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<div className="flex items-center">
<LogOut className="w-4 h-4 mr-2" />
Выйти
</div>
</button>
</>
) : (
<>
<Link href="/login" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Войти
</Link>
<Link href="/register" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Регистрация
</Link>
</>
)}
</div>
)}
</div>
<Link href="/cart" className="relative hover:opacity-70 transition-opacity">
<ShoppingCart className="w-5 h-5" />
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">

View File

@ -0,0 +1,277 @@
import { useState, useEffect } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
LayoutDashboard,
Package,
Tag,
ShoppingBag,
Users,
FileText,
Settings,
LogOut,
Menu,
X,
Home
} from 'lucide-react';
import Image from 'next/image';
import authService from '../../services/auth';
import { userService } from '../../services/users';
// Компонент элемента бокового меню
interface SidebarItemProps {
icon: React.ReactNode;
text: string;
href: string;
active: boolean;
}
const SidebarItem = ({ icon, text, href, active }: SidebarItemProps) => {
return (
<Link
href={href}
className={`flex items-center px-4 py-3 rounded-lg ${active ? 'bg-indigo-50 text-indigo-600' : 'text-gray-600 hover:bg-gray-50'}`}
>
<span className={`${active ? 'text-indigo-600' : 'text-gray-500'}`}>{icon}</span>
<span className={`ml-3 font-medium ${active ? 'text-indigo-600' : 'text-gray-700'}`}>{text}</span>
</Link>
);
};
interface AdminLayoutProps {
children: React.ReactNode;
title: string;
}
export default function AdminLayout({ children, title }: AdminLayoutProps) {
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loading, setLoading] = useState(true);
// Проверка прав администратора при загрузке компонента
useEffect(() => {
const checkAdminAccess = async () => {
try {
if (!authService.isAuthenticated()) {
// Если пользователь не авторизован, перенаправляем на страницу входа
router.push('/login');
return;
}
const user = await userService.getCurrentUser();
if (!user) {
// Если не удалось получить данные пользователя, перенаправляем на страницу входа
router.push('/login');
return;
}
if (!user.is_admin) {
// Если пользователь не администратор, перенаправляем на главную страницу
router.push('/');
return;
}
setLoading(false);
} catch (error) {
console.error('Ошибка при проверке прав администратора:', error);
router.push('/login');
}
};
checkAdminAccess();
}, [router]);
// Определяем активный пункт меню на основе текущего пути
const currentPath = router.pathname;
const menuItems = [
{
icon: <Home className="w-5 h-5" />,
text: 'Панель управления',
href: '/admin',
active: currentPath === '/admin'
},
{
icon: <Package className="w-5 h-5" />,
text: 'Товары',
href: '/admin/products',
active: currentPath.startsWith('/admin/products')
},
{
icon: <ShoppingBag className="w-5 h-5" />,
text: 'Категории',
href: '/admin/categories',
active: currentPath.startsWith('/admin/categories')
},
{
icon: <ShoppingBag className="w-5 h-5" />,
text: 'Заказы',
href: '/admin/orders',
active: currentPath.startsWith('/admin/orders')
},
{
icon: <Users className="w-5 h-5" />,
text: 'Пользователи',
href: '/admin/users',
active: currentPath.startsWith('/admin/users')
},
{
icon: <Settings className="w-5 h-5" />,
text: 'Настройки',
href: '/admin/settings',
active: currentPath.startsWith('/admin/settings')
}
];
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100">
<Head>
<title>{title} | Админ-панель</title>
</Head>
{/* Мобильная навигация */}
<div className="lg:hidden">
<div className="fixed top-0 left-0 right-0 z-30 bg-white shadow-sm px-4 py-2 flex items-center justify-between">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
>
<Menu className="w-6 h-6" />
</button>
<div className="flex items-center">
<div className="relative h-8 w-24">
<Image
src="/logo.png"
alt="Brand Logo"
fill
className="object-contain"
priority
/>
</div>
<span className="ml-2 font-semibold text-gray-800">Админ</span>
</div>
</div>
</div>
{/* Боковое меню (мобильное) */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)}></div>
<div className="fixed inset-y-0 left-0 flex flex-col w-64 max-w-xs bg-white shadow-xl">
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200">
<div className="flex items-center">
<div className="relative h-8 w-24">
<Image
src="/logo.png"
alt="Brand Logo"
fill
className="object-contain"
priority
/>
</div>
<span className="ml-2 font-semibold text-gray-800">Админ</span>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav className="space-y-1">
{menuItems.map((item, index) => (
<SidebarItem
key={index}
icon={item.icon}
text={item.text}
href={item.href}
active={item.active}
/>
))}
</nav>
</div>
<div className="p-4 border-t border-gray-200">
<button
onClick={() => {
authService.logout();
router.push('/login');
}}
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
>
<LogOut className="w-5 h-5" />
<span className="ml-3 font-medium">Выйти</span>
</button>
</div>
</div>
</div>
)}
{/* Боковое меню (десктоп) */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:flex-col lg:w-64 lg:bg-white lg:border-r lg:border-gray-200">
<div className="h-16 flex items-center px-6 border-b border-gray-200">
<div className="flex items-center">
<div className="relative h-8 w-24">
<Image
src="/logo.png"
alt="Brand Logo"
fill
className="object-contain"
priority
/>
</div>
<span className="ml-2 font-semibold text-gray-800">Админ</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav className="space-y-1">
{menuItems.map((item, index) => (
<SidebarItem
key={index}
icon={item.icon}
text={item.text}
href={item.href}
active={item.active}
/>
))}
</nav>
</div>
<div className="p-4 border-t border-gray-200">
<button
onClick={() => {
authService.logout();
router.push('/login');
}}
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
>
<LogOut className="w-5 h-5" />
<span className="ml-3 font-medium">Выйти</span>
</button>
</div>
</div>
{/* Основной контент */}
<div className="lg:pl-64 flex flex-col min-h-screen">
<header className="hidden lg:flex h-16 bg-white shadow-sm px-6 items-center">
<h1 className="text-2xl font-semibold text-gray-800">{title}</h1>
</header>
<main className="flex-1 p-6 pt-20 lg:pt-6">
{children}
</main>
<footer className="bg-white border-t border-gray-200 p-4 text-center text-sm text-gray-600">
&copy; {new Date().getFullYear()} Dressed for Success. Все права защищены.
</footer>
</div>
</div>
);
}

View File

@ -0,0 +1,106 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
LayoutDashboard,
ShoppingBag,
Tag,
FileText,
Settings,
Users,
ShoppingCart,
BarChart,
MessageSquare
} from 'lucide-react';
// Определение элементов меню
const menuItems = [
{
title: 'Дашборд',
icon: <LayoutDashboard className="h-5 w-5" />,
href: '/admin/dashboard',
active: (path) => path === '/admin/dashboard'
},
{
title: 'Заказы',
icon: <ShoppingCart className="h-5 w-5" />,
href: '/admin/orders',
active: (path) => path.startsWith('/admin/orders')
},
{
title: 'Клиенты',
icon: <Users className="h-5 w-5" />,
href: '/admin/customers',
active: (path) => path.startsWith('/admin/customers')
},
{
title: 'Категории',
icon: <Tag className="h-5 w-5" />,
href: '/admin/categories',
active: (path) => path.startsWith('/admin/categories')
},
{
title: 'Товары',
icon: <ShoppingBag className="h-5 w-5" />,
href: '/admin/products',
active: (path) => path.startsWith('/admin/products')
},
{
title: 'Страницы',
icon: <FileText className="h-5 w-5" />,
href: '/admin/pages',
active: (path) => path.startsWith('/admin/pages')
},
{
title: 'Отзывы',
icon: <MessageSquare className="h-5 w-5" />,
href: '/admin/reviews',
active: (path) => path.startsWith('/admin/reviews')
},
{
title: 'Аналитика',
icon: <BarChart className="h-5 w-5" />,
href: '/admin/analytics',
active: (path) => path.startsWith('/admin/analytics')
},
{
title: 'Настройки',
icon: <Settings className="h-5 w-5" />,
href: '/admin/settings',
active: (path) => path.startsWith('/admin/settings')
}
];
export default function AdminSidebar() {
const router = useRouter();
const currentPath = router.pathname;
return (
<div className="h-full bg-white border-r border-gray-200 w-64 flex-shrink-0">
<div className="p-6">
<Link href="/admin/dashboard" className="flex items-center">
<span className="text-xl font-bold text-indigo-600">DressedForSuccess</span>
</Link>
</div>
<nav className="mt-5 px-3 space-y-1">
{menuItems.map((item) => (
<Link
key={item.title}
href={item.href}
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md ${
item.active(currentPath)
? 'bg-indigo-50 text-indigo-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<span className={`mr-3 ${
item.active(currentPath) ? 'text-indigo-600' : 'text-gray-400 group-hover:text-gray-500'
}`}>
{item.icon}
</span>
{item.title}
</Link>
))}
</nav>
</div>
);
}

View File

@ -10,6 +10,7 @@
"dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/line-clamp": "^0.4.4",
"axios": "^1.8.1",
"framer-motion": "^10.16.4",
"lucide-react": "^0.476.0",
"next": "^13.4.19",
@ -333,6 +334,12 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.13",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz",
@ -367,6 +374,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -435,6 +453,19 @@
"node": ">=10.16.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -512,6 +543,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -540,6 +583,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detective": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
@ -569,6 +621,20 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.103",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
@ -576,6 +642,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -635,6 +746,41 @@
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -696,6 +842,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -714,12 +897,51 @@
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -825,6 +1047,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -847,6 +1078,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@ -1124,6 +1376,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -10,6 +10,7 @@
"dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/line-clamp": "^0.4.4",
"axios": "^1.8.1",
"framer-motion": "^10.16.4",
"lucide-react": "^0.476.0",
"next": "^13.4.19",

View File

@ -0,0 +1,443 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, MapPin, Plus, Edit, Trash, Check } from 'lucide-react';
import authService from '../../services/auth';
import { userService, Address } from '../../services/users';
export default function AddressesPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [addresses, setAddresses] = useState<Address[]>([]);
const [user, setUser] = useState(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(null);
const [formData, setFormData] = useState({
type: 'shipping',
address: '',
city: '',
postal_code: '',
country: 'Россия',
is_default: false
});
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=/account/addresses');
return;
}
// Загружаем данные пользователя и адреса
const fetchData = async () => {
try {
const userData = await userService.getCurrentUser();
setUser(userData);
// Если у пользователя есть адреса, загружаем их
if (userData.addresses) {
setAddresses(userData.addresses);
}
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные');
} finally {
setLoading(false);
}
};
fetchData();
}, [router]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const resetForm = () => {
setFormData({
type: 'shipping',
address: '',
city: '',
postal_code: '',
country: 'Россия',
is_default: false
});
setShowAddForm(false);
setEditingAddressId(null);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
try {
if (editingAddressId) {
// Обновляем существующий адрес
await userService.updateAddress(editingAddressId, formData);
setSuccess('Адрес успешно обновлен');
} else {
// Добавляем новый адрес
await userService.addAddress(formData);
setSuccess('Адрес успешно добавлен');
}
// Обновляем список адресов
const userData = await userService.getCurrentUser();
if (userData.addresses) {
setAddresses(userData.addresses);
}
// Сбрасываем форму
resetForm();
} catch (err) {
console.error('Ошибка при сохранении адреса:', err);
setError('Не удалось сохранить адрес');
}
};
const handleEdit = (address: Address) => {
setFormData({
type: address.type,
address: address.address,
city: address.city,
postal_code: address.postal_code,
country: address.country,
is_default: address.is_default
});
setEditingAddressId(address.id);
setShowAddForm(true);
};
const handleDelete = async (addressId: number) => {
if (!confirm('Вы уверены, что хотите удалить этот адрес?')) {
return;
}
setError('');
setSuccess('');
try {
await userService.deleteAddress(addressId);
setSuccess('Адрес успешно удален');
// Обновляем список адресов
const userData = await userService.getCurrentUser();
if (userData.addresses) {
setAddresses(userData.addresses);
} else {
setAddresses([]);
}
} catch (err) {
console.error('Ошибка при удалении адреса:', err);
setError('Не удалось удалить адрес');
}
};
const handleSetDefault = async (addressId: number) => {
setError('');
setSuccess('');
try {
await userService.updateAddress(addressId, { is_default: true });
setSuccess('Адрес по умолчанию изменен');
// Обновляем список адресов
const userData = await userService.getCurrentUser();
if (userData.addresses) {
setAddresses(userData.addresses);
}
} catch (err) {
console.error('Ошибка при установке адреса по умолчанию:', err);
setError('Не удалось установить адрес по умолчанию');
}
};
const handleLogout = () => {
authService.logout();
router.push('/');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{success && (
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-green-700">{success}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-indigo-600 font-medium">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-3 space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-semibold">Мои адреса</h2>
<p className="text-gray-600 mt-1">Управление адресами доставки</p>
</div>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{showAddForm ? 'Отменить' : (
<>
<Plus className="h-4 w-4 mr-1" />
Добавить адрес
</>
)}
</button>
</div>
{showAddForm && (
<div className="mb-8 border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-medium mb-4">
{editingAddressId ? 'Редактирование адреса' : 'Добавление нового адреса'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
Тип адреса
</label>
<select
id="type"
name="type"
value={formData.type}
onChange={handleChange}
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option value="shipping">Адрес доставки</option>
<option value="billing">Адрес для выставления счета</option>
</select>
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
Адрес
</label>
<textarea
id="address"
name="address"
rows={3}
value={formData.address}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Улица, дом, квартира"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
Город
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700">
Почтовый индекс
</label>
<input
type="text"
id="postal_code"
name="postal_code"
value={formData.postal_code}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700">
Страна
</label>
<input
type="text"
id="country"
name="country"
value={formData.country}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="flex items-center">
<input
id="is_default"
name="is_default"
type="checkbox"
checked={formData.is_default}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="is_default" className="ml-2 block text-sm text-gray-900">
Использовать как адрес по умолчанию
</label>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={resetForm}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Отмена
</button>
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{editingAddressId ? 'Сохранить изменения' : 'Добавить адрес'}
</button>
</div>
</form>
</div>
)}
{addresses.length === 0 ? (
<div className="text-center py-8">
<MapPin className="h-12 w-12 mx-auto text-gray-400" />
<p className="mt-2 text-gray-500">У вас пока нет сохраненных адресов</p>
{!showAddForm && (
<button
onClick={() => setShowAddForm(true)}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
<Plus className="h-4 w-4 mr-1" />
Добавить адрес
</button>
)}
</div>
) : (
<div className="space-y-4">
{addresses.map((address) => (
<div key={address.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center">
<h3 className="text-lg font-medium">
{address.type === 'shipping' ? 'Адрес доставки' : 'Адрес для выставления счета'}
</h3>
{address.is_default && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
По умолчанию
</span>
)}
</div>
<p className="text-gray-600 mt-1">{address.address}</p>
<p className="text-gray-600">{address.city}, {address.postal_code}</p>
<p className="text-gray-600">{address.country}</p>
</div>
<div className="flex space-x-2">
{!address.is_default && (
<button
onClick={() => handleSetDefault(address.id)}
className="text-indigo-600 hover:text-indigo-900"
title="Сделать адресом по умолчанию"
>
<Check className="h-5 w-5" />
</button>
)}
<button
onClick={() => handleEdit(address)}
className="text-blue-600 hover:text-blue-900"
title="Редактировать"
>
<Edit className="h-5 w-5" />
</button>
<button
onClick={() => handleDelete(address.id)}
className="text-red-600 hover:text-red-900"
title="Удалить"
>
<Trash className="h-5 w-5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Lock, AlertCircle, CheckCircle } from 'lucide-react';
import authService from '../../services/auth';
export default function ChangePasswordPage() {
const router = useRouter();
const [formData, setFormData] = useState({
current_password: '',
new_password: '',
confirm_password: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=/account/change-password');
}
}, [router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
// Проверяем совпадение паролей
if (formData.new_password !== formData.confirm_password) {
setError('Новый пароль и подтверждение не совпадают');
setLoading(false);
return;
}
try {
// Вызываем метод изменения пароля
await authService.changePassword(
formData.current_password,
formData.new_password
);
setSuccess(true);
} catch (err) {
console.error('Ошибка при изменении пароля:', err);
setError('Не удалось изменить пароль. Проверьте правильность текущего пароля.');
} finally {
setLoading(false);
}
};
const handleLogout = () => {
authService.logout();
router.push('/');
};
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold">Изменение пароля</h2>
<p className="text-gray-600 mt-1">Обновите свой пароль для повышения безопасности аккаунта</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{success ? (
<div className="bg-green-50 border-l-4 border-green-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircle className="h-5 w-5 text-green-500" />
</div>
<div className="ml-3">
<p className="text-sm text-green-700">Пароль успешно изменен!</p>
</div>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
Текущий пароль
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="current_password"
name="current_password"
type="password"
autoComplete="current-password"
required
value={formData.current_password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
<div>
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
Новый пароль
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="new_password"
name="new_password"
type="password"
autoComplete="new-password"
required
value={formData.new_password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
</div>
<div>
<label htmlFor="confirm_password" className="block text-sm font-medium text-gray-700">
Подтверждение нового пароля
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
required
value={formData.confirm_password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
</div>
<div className="flex items-center justify-between">
<Link href="/account" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Вернуться в профиль
</Link>
<button
type="submit"
disabled={loading}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Сохранение...' : 'Изменить пароль'}
</button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Save, X, MapPin } from 'lucide-react';
import authService from '../../services/auth';
import { userService, UserUpdate } from '../../services/users';
export default function EditProfilePage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
email: '',
phone: ''
});
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=/account/edit');
return;
}
// Загружаем данные пользователя
const fetchUserData = async () => {
try {
const userData = await userService.getCurrentUser();
setUser(userData);
setFormData({
first_name: userData.first_name || '',
last_name: userData.last_name || '',
email: userData.email || '',
phone: userData.phone || ''
});
} catch (err) {
console.error('Ошибка при загрузке данных пользователя:', err);
setError('Не удалось загрузить данные пользователя');
} finally {
setLoading(false);
}
};
fetchUserData();
}, [router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError('');
setSuccess(false);
try {
const updateData: UserUpdate = {
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone
};
// Email обновляем только если он изменился
if (formData.email !== user.email) {
updateData.email = formData.email;
}
await userService.updateCurrentUser(updateData);
setSuccess(true);
// Обновляем данные пользователя
const updatedUser = await userService.getCurrentUser();
setUser(updatedUser);
} catch (err) {
console.error('Ошибка при обновлении профиля:', err);
setError('Не удалось обновить профиль. Пожалуйста, проверьте введенные данные.');
} finally {
setSaving(false);
}
};
const handleLogout = () => {
authService.logout();
router.push('/');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold">Редактирование профиля</h2>
<p className="text-gray-600 mt-1">Обновите свои персональные данные</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{success && (
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-green-700">Профиль успешно обновлен!</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Имя
</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
Фамилия
</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Телефон
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="+7 (___) ___-__-__"
/>
</div>
<div className="flex items-center justify-between">
<Link href="/account" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<X className="h-5 w-5 mr-2" />
Отмена
</Link>
<button
type="submit"
disabled={saving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-5 w-5 mr-2" />
{saving ? 'Сохранение...' : 'Сохранить изменения'}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, Edit, MapPin } from 'lucide-react';
import authService from '../../services/auth';
import { userService } from '../../services/users';
export default function AccountPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=/account');
return;
}
// Загружаем данные пользователя
const fetchUserData = async () => {
try {
const userData = await userService.getCurrentUser();
setUser(userData);
} catch (err) {
console.error('Ошибка при загрузке данных пользователя:', err);
setError('Не удалось загрузить данные пользователя');
} finally {
setLoading(false);
}
};
fetchUserData();
}, [router]);
const handleLogout = () => {
authService.logout();
router.push('/');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<MapPin className="h-5 w-5" />
<span>Мои адреса</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Личные данные</h2>
<Link href="/account/edit" className="flex items-center text-sm text-indigo-600 hover:text-indigo-800">
<Edit className="h-4 w-4 mr-1" />
Редактировать
</Link>
</div>
{user && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">Имя</p>
<p className="font-medium">{user.first_name}</p>
</div>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">Фамилия</p>
<p className="font-medium">{user.last_name}</p>
</div>
</div>
<div>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">Email</p>
<p className="font-medium">{user.email}</p>
</div>
<div className="mb-4">
<p className="text-sm text-gray-500 mb-1">Телефон</p>
<p className="font-medium">{user.phone || 'Не указан'}</p>
</div>
</div>
</div>
)}
<div className="mt-8 border-t pt-6">
<h3 className="text-lg font-semibold mb-4">Безопасность</h3>
<Link href="/account/change-password" className="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Изменить пароль
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, ExternalLink, Clock, CheckCircle, XCircle } from 'lucide-react';
import authService from '../../services/auth';
import { orderService } from '../../services/orders';
// Вспомогательная функция для форматирования даты
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
// Функция для получения статуса заказа
const getOrderStatusInfo = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Ожидает оплаты', color: 'text-yellow-600', icon: <Clock className="h-5 w-5" /> };
case 'processing':
return { label: 'В обработке', color: 'text-blue-600', icon: <Clock className="h-5 w-5" /> };
case 'shipped':
return { label: 'Отправлен', color: 'text-indigo-600', icon: <Package className="h-5 w-5" /> };
case 'delivered':
return { label: 'Доставлен', color: 'text-green-600', icon: <CheckCircle className="h-5 w-5" /> };
case 'cancelled':
return { label: 'Отменен', color: 'text-red-600', icon: <XCircle className="h-5 w-5" /> };
default:
return { label: 'Неизвестно', color: 'text-gray-600', icon: <Clock className="h-5 w-5" /> };
}
};
export default function OrdersPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [orders, setOrders] = useState([]);
const [error, setError] = useState('');
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=/account/orders');
return;
}
// Загружаем заказы пользователя
const fetchOrders = async () => {
try {
const ordersData = await orderService.getUserOrders();
setOrders(ordersData);
} catch (err) {
console.error('Ошибка при загрузке заказов:', err);
setError('Не удалось загрузить заказы');
} finally {
setLoading(false);
}
};
fetchOrders();
}, [router]);
const handleLogout = () => {
authService.logout();
router.push('/');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold">Мои заказы</h2>
<p className="text-gray-600 mt-1">История ваших заказов</p>
</div>
{orders.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 mx-auto text-gray-400" />
<p className="mt-2 text-gray-500">У вас пока нет заказов</p>
<Link href="/" className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700">
Перейти к покупкам
</Link>
</div>
) : (
<div className="space-y-6">
{orders.map((order) => {
const statusInfo = getOrderStatusInfo(order.status);
return (
<div key={order.id} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<div>
<span className="text-sm text-gray-500">Заказ </span>
<span className="font-medium ml-1">{order.id}</span>
<span className="text-sm text-gray-500 ml-4">от</span>
<span className="ml-1">{formatDate(order.created_at)}</span>
</div>
<div className={`flex items-center ${statusInfo.color}`}>
{statusInfo.icon}
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
</div>
</div>
<div className="p-4">
<div className="space-y-3">
{order.items.map((item) => (
<div key={item.id} className="flex items-center justify-between">
<div className="flex items-center">
<div className="w-16 h-16 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
{item.product.image && (
<img
src={item.product.image}
alt={item.product.name}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="ml-4">
<h3 className="text-sm font-medium">{item.product.name}</h3>
<p className="text-sm text-gray-500">
{item.variant_name && `Вариант: ${item.variant_name}`}
</p>
<p className="text-sm text-gray-500">
Количество: {item.quantity}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium">{item.price.toLocaleString('ru-RU')} </p>
</div>
</div>
))}
</div>
<div className="mt-6 border-t border-gray-200 pt-4 flex justify-between items-center">
<div>
<p className="text-sm text-gray-500">Итого:</p>
<p className="text-lg font-bold">{order.total_amount.toLocaleString('ru-RU')} </p>
</div>
<Link
href={`/account/orders/${order.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50"
>
Подробнее
<ExternalLink className="ml-1 h-4 w-4" />
</Link>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,257 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { User, Package, Heart, LogOut, ArrowLeft, Clock, CheckCircle, XCircle, Truck, CreditCard, MapPin } from 'lucide-react';
import authService from '../../../services/auth';
import { orderService, Order } from '../../../services/orders';
// Вспомогательная функция для форматирования даты
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
// Функция для получения статуса заказа
const getOrderStatusInfo = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Ожидает оплаты', color: 'text-yellow-600 bg-yellow-50', icon: <Clock className="h-5 w-5" /> };
case 'processing':
return { label: 'В обработке', color: 'text-blue-600 bg-blue-50', icon: <Clock className="h-5 w-5" /> };
case 'shipped':
return { label: 'Отправлен', color: 'text-indigo-600 bg-indigo-50', icon: <Truck className="h-5 w-5" /> };
case 'delivered':
return { label: 'Доставлен', color: 'text-green-600 bg-green-50', icon: <CheckCircle className="h-5 w-5" /> };
case 'cancelled':
return { label: 'Отменен', color: 'text-red-600 bg-red-50', icon: <XCircle className="h-5 w-5" /> };
default:
return { label: 'Неизвестно', color: 'text-gray-600 bg-gray-50', icon: <Clock className="h-5 w-5" /> };
}
};
export default function OrderDetailsPage() {
const router = useRouter();
const { id } = router.query;
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState<Order | null>(null);
const [error, setError] = useState('');
useEffect(() => {
// Проверяем, авторизован ли пользователь
if (!authService.isAuthenticated()) {
router.push('/login?redirect=' + router.asPath);
return;
}
// Загружаем данные заказа
const fetchOrderDetails = async () => {
if (!id) return;
try {
const orderData = await orderService.getOrderById(Number(id));
setOrder(orderData);
} catch (err) {
console.error('Ошибка при загрузке данных заказа:', err);
setError('Не удалось загрузить данные заказа');
} finally {
setLoading(false);
}
};
if (id) {
fetchOrderDetails();
}
}, [router, id]);
const handleLogout = () => {
authService.logout();
router.push('/');
};
const handleCancelOrder = async () => {
if (!order) return;
try {
await orderService.cancelOrder(order.id);
// Обновляем данные заказа после отмены
const updatedOrder = await orderService.getOrderById(order.id);
setOrder(updatedOrder);
} catch (err) {
console.error('Ошибка при отмене заказа:', err);
setError('Не удалось отменить заказ');
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
</div>
</div>
);
}
if (!order) {
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow p-6">
<h1 className="text-2xl font-bold mb-4">Заказ не найден</h1>
<p className="text-gray-600 mb-4">Запрашиваемый заказ не существует или у вас нет к нему доступа.</p>
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
<ArrowLeft className="h-4 w-4 mr-1" />
Вернуться к списку заказов
</Link>
</div>
</div>
</div>
);
}
const statusInfo = getOrderStatusInfo(order.status);
return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
<ArrowLeft className="h-4 w-4 mr-1" />
Вернуться к списку заказов
</Link>
</div>
<h1 className="text-2xl font-bold mb-6">Заказ {order.id}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Боковая панель навигации */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-col space-y-4">
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<User className="h-5 w-5" />
<span>Профиль</span>
</Link>
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
<Package className="h-5 w-5" />
<span>Мои заказы</span>
</Link>
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
<Heart className="h-5 w-5" />
<span>Избранное</span>
</Link>
<button
onClick={handleLogout}
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
>
<LogOut className="h-5 w-5" />
<span>Выйти</span>
</button>
</div>
</div>
{/* Основное содержимое */}
<div className="md:col-span-2 space-y-6">
{/* Информация о заказе */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-xl font-semibold">Информация о заказе</h2>
<p className="text-gray-500 mt-1">Создан: {formatDate(order.created_at)}</p>
</div>
<div className={`px-3 py-1 rounded-full ${statusInfo.color} flex items-center`}>
{statusInfo.icon}
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="border border-gray-200 rounded-md p-4">
<div className="flex items-center mb-2">
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
<h3 className="font-medium">Адрес доставки</h3>
</div>
<p className="text-gray-600 whitespace-pre-line">{order.shipping_address}</p>
</div>
<div className="border border-gray-200 rounded-md p-4">
<div className="flex items-center mb-2">
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
<h3 className="font-medium">Способ оплаты</h3>
</div>
<p className="text-gray-600">{order.payment_method}</p>
</div>
</div>
{order.status === 'pending' && (
<button
onClick={handleCancelOrder}
className="w-full md:w-auto px-4 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Отменить заказ
</button>
)}
</div>
{/* Товары в заказе */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold">Товары в заказе</h2>
</div>
<div className="divide-y divide-gray-200">
{order.items.map((item) => (
<div key={item.id} className="p-6 flex items-start">
<div className="w-20 h-20 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
{item.product.image && (
<img
src={item.product.image}
alt={item.product.name}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="ml-4 flex-1">
<h3 className="text-lg font-medium">{item.product.name}</h3>
{item.variant_name && (
<p className="text-gray-500">Вариант: {item.variant_name}</p>
)}
<div className="mt-1 flex justify-between">
<p className="text-gray-500">Количество: {item.quantity}</p>
<p className="font-medium">{item.price.toLocaleString('ru-RU')} </p>
</div>
</div>
</div>
))}
</div>
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="font-medium">Итого:</span>
<span className="text-xl font-bold">{order.total_amount.toLocaleString('ru-RU')} </span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,309 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Save, X, ArrowLeft } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { categoryService, Category } from '../../../services/catalog';
export default function EditCategoryPage() {
const router = useRouter();
const { id } = router.query;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [category, setCategory] = useState<Category | null>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
parent_id: null as number | null,
is_active: true
});
const [error, setError] = useState('');
// Загрузка категории и списка категорий при монтировании компонента
useEffect(() => {
const fetchData = async () => {
if (!id) return;
try {
setLoading(true);
// Загружаем список всех категорий для выбора родительской
const categoriesData = await categoryService.getCategories();
setCategories(categoriesData);
// Находим текущую категорию по ID
const currentCategory = categoriesData.find(cat => cat.id === Number(id));
if (currentCategory) {
setCategory(currentCategory);
setFormData({
name: currentCategory.name,
slug: currentCategory.slug,
parent_id: currentCategory.parent_id,
is_active: currentCategory.is_active
});
} else {
setError('Категория не найдена');
}
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные категории. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchData();
}, [id]);
// Автоматическое создание slug из названия
const generateSlug = (name: 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, ''); // Удаляем дефисы в начале и конце
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
// Обновляем форму
setFormData(prev => {
const newData = {
...prev,
[name]: type === 'checkbox' ? checked : value
};
// Если изменилось название, автоматически обновляем slug
if (name === 'name') {
newData.slug = generateSlug(value);
}
// Если выбрана родительская категория, преобразуем строку в число или null
if (name === 'parent_id') {
newData.parent_id = value === '' ? null : Number(value);
}
return newData;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
setError('');
try {
if (!category) return;
// Проверяем, не выбрана ли текущая категория в качестве родительской
if (formData.parent_id === category.id) {
setError('Категория не может быть родительской для самой себя');
setSaving(false);
return;
}
// Проверяем, не выбран ли потомок в качестве родительской категории
const isDescendant = (parentId: number | null, childId: number): boolean => {
if (parentId === childId) return true;
const children = categories.filter(cat => cat.parent_id === childId);
return children.some(child => isDescendant(parentId, child.id));
};
if (formData.parent_id !== null && isDescendant(formData.parent_id, category.id)) {
setError('Нельзя выбрать потомка в качестве родительской категории');
setSaving(false);
return;
}
// Обновляем категорию через API
await categoryService.updateCategory(category.id, formData);
// Перенаправляем на страницу категорий
router.push('/admin/categories');
} catch (err) {
console.error('Ошибка при обновлении категории:', err);
setError('Не удалось обновить категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout title="Редактирование категории">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
</AdminLayout>
);
}
if (!category && !loading) {
return (
<AdminLayout title="Категория не найдена">
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">Категория не найдена</p>
</div>
</div>
</div>
<button
onClick={() => router.push('/admin/categories')}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Вернуться к списку категорий
</button>
</AdminLayout>
);
}
return (
<AdminLayout title={`Редактирование: ${category?.name}`}>
<div className="mb-6 flex items-center">
<button
onClick={() => router.push('/admin/categories')}
className="mr-4 text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h2 className="text-xl font-semibold text-gray-800">Редактирование категории</h2>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<form onSubmit={handleSubmit}>
<div className="p-6">
<div className="grid grid-cols-1 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Название категории *
</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
Slug *
</label>
<input
type="text"
id="slug"
name="slug"
required
value={formData.slug}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
<p className="mt-1 text-sm text-gray-500">
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
</p>
</div>
<div>
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
Родительская категория
</label>
<select
id="parent_id"
name="parent_id"
value={formData.parent_id === null ? '' : formData.parent_id}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Нет (корневая категория)</option>
{categories
.filter(cat => cat.id !== category?.id) // Исключаем текущую категорию
.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
Активна
</label>
</div>
<p className="mt-1 text-sm text-gray-500">
Неактивные категории не будут отображаться на сайте.
</p>
</div>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 flex justify-end">
<button
type="button"
onClick={() => router.push('/admin/categories')}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
>
<X className="h-5 w-5 mr-2" />
Отмена
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Save className="h-5 w-5 mr-2" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Save, X, ArrowLeft } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { categoryService, Category } from '../../../services/catalog';
export default function CreateCategoryPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [formData, setFormData] = useState({
name: '',
slug: '',
parent_id: null as number | null,
order: 0,
is_active: true
});
const [error, setError] = useState('');
// Загрузка списка категорий для выбора родительской категории
useEffect(() => {
const fetchCategories = async () => {
try {
const data = await categoryService.getCategories();
setCategories(data);
} catch (err) {
console.error('Ошибка при загрузке категорий:', err);
setError('Не удалось загрузить список категорий');
}
};
fetchCategories();
}, []);
// Автоматическое создание slug из названия
const generateSlug = (name: 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, ''); // Удаляем дефисы в начале и конце
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
// Обновляем форму
setFormData(prev => {
const newData = {
...prev,
[name]: type === 'checkbox' ? checked : value
};
// Если изменилось название, автоматически обновляем slug
if (name === 'name') {
newData.slug = generateSlug(value);
}
// Если выбрана родительская категория, преобразуем строку в число или null
if (name === 'parent_id') {
newData.parent_id = value === '' ? null : Number(value);
}
return newData;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// Определяем порядок для новой категории
const categoriesWithSameParent = categories.filter(
cat => cat.parent_id === formData.parent_id
);
const newOrder = categoriesWithSameParent.length > 0
? Math.max(...categoriesWithSameParent.map(cat => cat.order)) + 1
: 1;
// Создаем категорию через API
await categoryService.createCategory({
...formData,
order: newOrder
});
// Перенаправляем на страницу категорий
router.push('/admin/categories');
} catch (err) {
console.error('Ошибка при создании категории:', err);
setError('Не удалось создать категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
} finally {
setLoading(false);
}
};
return (
<AdminLayout title="Создание категории">
<div className="mb-6 flex items-center">
<button
onClick={() => router.push('/admin/categories')}
className="mr-4 text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h2 className="text-xl font-semibold text-gray-800">Создание новой категории</h2>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<form onSubmit={handleSubmit}>
<div className="p-6">
<div className="grid grid-cols-1 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Название категории *
</label>
<input
type="text"
id="name"
name="name"
required
value={formData.name}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
Slug *
</label>
<input
type="text"
id="slug"
name="slug"
required
value={formData.slug}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
<p className="mt-1 text-sm text-gray-500">
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
</p>
</div>
<div>
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
Родительская категория
</label>
<select
id="parent_id"
name="parent_id"
value={formData.parent_id === null ? '' : formData.parent_id}
onChange={handleChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Нет (корневая категория)</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center">
<input
type="checkbox"
id="is_active"
name="is_active"
checked={formData.is_active}
onChange={handleChange}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
Активна
</label>
</div>
<p className="mt-1 text-sm text-gray-500">
Неактивные категории не будут отображаться на сайте.
</p>
</div>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 flex justify-end">
<button
type="button"
onClick={() => router.push('/admin/categories')}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
>
<X className="h-5 w-5 mr-2" />
Отмена
</button>
<button
type="submit"
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Save className="h-5 w-5 mr-2" />
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,284 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Plus, Edit, Trash, ChevronLeft, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { categoryService, Category } from '../../../services/catalog';
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Загрузка категорий при монтировании компонента
useEffect(() => {
const fetchCategories = async () => {
try {
setLoading(true);
const data = await categoryService.getCategories();
setCategories(data);
setError(null);
} catch (err) {
console.error('Ошибка при загрузке категорий:', err);
setError('Не удалось загрузить категории. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchCategories();
}, []);
// Функция для изменения порядка категорий
const handleReorder = async (id: number, direction: 'up' | 'down') => {
const index = categories.findIndex(cat => cat.id === id);
if (index === -1) return;
// Если двигаем вверх и это не первый элемент
if (direction === 'up' && index > 0) {
try {
const currentCategory = categories[index];
const prevCategory = categories[index - 1];
// Обновляем порядок в API
await categoryService.updateCategory(currentCategory.id, { order: prevCategory.order });
await categoryService.updateCategory(prevCategory.id, { order: currentCategory.order });
// Обновляем локальное состояние
const newCategories = [...categories];
newCategories[index] = { ...newCategories[index], order: prevCategory.order };
newCategories[index - 1] = { ...newCategories[index - 1], order: currentCategory.order };
// Сортируем по порядку
newCategories.sort((a, b) => a.order - b.order);
setCategories(newCategories);
} catch (err) {
console.error('Ошибка при изменении порядка:', err);
setError('Не удалось изменить порядок категории.');
}
}
// Если двигаем вниз и это не последний элемент
else if (direction === 'down' && index < categories.length - 1) {
try {
const currentCategory = categories[index];
const nextCategory = categories[index + 1];
// Обновляем порядок в API
await categoryService.updateCategory(currentCategory.id, { order: nextCategory.order });
await categoryService.updateCategory(nextCategory.id, { order: currentCategory.order });
// Обновляем локальное состояние
const newCategories = [...categories];
newCategories[index] = { ...newCategories[index], order: nextCategory.order };
newCategories[index + 1] = { ...newCategories[index + 1], order: currentCategory.order };
// Сортируем по порядку
newCategories.sort((a, b) => a.order - b.order);
setCategories(newCategories);
} catch (err) {
console.error('Ошибка при изменении порядка:', err);
setError('Не удалось изменить порядок категории.');
}
}
};
// Функция для удаления категории
const handleDelete = async (id: number) => {
if (window.confirm('Вы уверены, что хотите удалить эту категорию?')) {
try {
await categoryService.deleteCategory(id);
setCategories(categories.filter(cat => cat.id !== id));
} catch (err) {
console.error('Ошибка при удалении категории:', err);
setError('Не удалось удалить категорию.');
}
}
};
// Функция для изменения статуса активности
const handleToggleActive = async (id: number, currentStatus: boolean) => {
try {
await categoryService.updateCategory(id, { is_active: !currentStatus });
setCategories(categories.map(cat =>
cat.id === id ? { ...cat, is_active: !cat.is_active } : cat
));
} catch (err) {
console.error('Ошибка при изменении статуса категории:', err);
setError('Не удалось изменить статус категории.');
}
};
// Получаем корневые категории и подкатегории
const rootCategories = categories.filter(cat => cat.parent_id === null);
const getSubcategories = (parentId: number) => categories.filter(cat => cat.parent_id === parentId);
if (loading) {
return (
<AdminLayout title="Управление категориями">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
</AdminLayout>
);
}
if (error) {
return (
<AdminLayout title="Управление категориями">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
</div>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Попробовать снова
</button>
</AdminLayout>
);
}
return (
<AdminLayout title="Управление категориями">
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-800">Список категорий</h2>
<Link href="/admin/categories/create" 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">
<Plus className="h-5 w-5 mr-2" />
Добавить категорию
</Link>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Родительская категория</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товаров</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Порядок</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rootCategories.map((category) => (
<>
<tr key={category.id} className="bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.slug}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">-</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.products_count || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(category.id, category.is_active)}
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${category.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{category.is_active ? 'Активна' : 'Неактивна'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={() => handleReorder(category.id, 'up')}
disabled={category.order === 1}
className={`p-1 rounded-full ${category.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleReorder(category.id, 'down')}
disabled={category.order === rootCategories.length}
className={`p-1 rounded-full ${category.order === rootCategories.length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowDown className="h-4 w-4" />
</button>
<span>{category.order}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/categories/${category.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDelete(category.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
{/* Подкатегории */}
{getSubcategories(category.id).map(subcat => (
<tr key={subcat.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 pl-12">
{subcat.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.slug}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.products_count || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(subcat.id, subcat.is_active)}
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
${subcat.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{subcat.is_active ? 'Активна' : 'Неактивна'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center space-x-2">
<button
onClick={() => handleReorder(subcat.id, 'up')}
disabled={subcat.order === 1}
className={`p-1 rounded-full ${subcat.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
onClick={() => handleReorder(subcat.id, 'down')}
disabled={subcat.order === getSubcategories(category.id).length}
className={`p-1 rounded-full ${subcat.order === getSubcategories(category.id).length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
>
<ArrowDown className="h-4 w-4" />
</button>
<span>{subcat.order}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/categories/${subcat.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDelete(subcat.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-500">
Показано {categories.length} категорий
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,330 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, ShoppingBag, CreditCard, Clock, Check, X } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { userService, User, Address } from '../../../services/users';
import { orderService, Order } from '../../../services/orders';
// Компонент для отображения статуса заказа
const OrderStatus = ({ status }) => {
let bgColor = 'bg-gray-100';
let textColor = 'text-gray-800';
switch (status) {
case 'pending':
bgColor = 'bg-yellow-100';
textColor = 'text-yellow-800';
break;
case 'paid':
bgColor = 'bg-blue-100';
textColor = 'text-blue-800';
break;
case 'processing':
bgColor = 'bg-purple-100';
textColor = 'text-purple-800';
break;
case 'shipped':
bgColor = 'bg-indigo-100';
textColor = 'text-indigo-800';
break;
case 'delivered':
bgColor = 'bg-green-100';
textColor = 'text-green-800';
break;
case 'cancelled':
bgColor = 'bg-red-100';
textColor = 'text-red-800';
break;
}
const statusText = {
pending: 'Ожидает оплаты',
paid: 'Оплачен',
processing: 'В обработке',
shipped: 'Отправлен',
delivered: 'Доставлен',
cancelled: 'Отменен'
};
return (
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
{statusText[status] || status}
</span>
);
};
export default function CustomerDetailPage() {
const router = useRouter();
const { id } = router.query;
const [user, setUser] = useState<User | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stats, setStats] = useState({
ordersCount: 0,
totalSpent: 0,
lastOrderDate: ''
});
// Загрузка данных пользователя
useEffect(() => {
const fetchUserData = async () => {
if (!id) return;
try {
setLoading(true);
const userData = await userService.getUserById(Number(id));
setUser(userData);
// Загрузка заказов пользователя
try {
// Предполагаем, что в API есть эндпоинт для получения заказов пользователя
const response = await fetch(`/api/users/${id}/orders`);
if (!response.ok) {
throw new Error('Ошибка при загрузке заказов');
}
const ordersData = await response.json();
setOrders(ordersData);
// Расчет статистики
if (ordersData.length > 0) {
const totalSpent = ordersData.reduce((sum, order) => sum + order.total, 0);
const lastOrder = ordersData.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)[0];
setStats({
ordersCount: ordersData.length,
totalSpent: totalSpent,
lastOrderDate: lastOrder.created_at
});
}
} catch (err) {
console.error('Ошибка при загрузке заказов:', err);
// Продолжаем работу даже если не удалось загрузить заказы
}
setError('');
} catch (err) {
console.error('Ошибка при загрузке данных пользователя:', err);
setError('Не удалось загрузить данные клиента. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchUserData();
}, [id]);
// Форматирование даты для отображения
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Форматирование суммы для отображения
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount);
};
// Получение полного имени пользователя
const getFullName = (user: User) => {
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
};
return (
<AdminLayout title={loading ? 'Загрузка...' : `Клиент: ${user ? getFullName(user) : ''}`}>
<div className="mb-6 flex items-center">
<Link href="/admin/customers" className="inline-flex items-center mr-4 text-indigo-600 hover:text-indigo-800">
<ArrowLeft className="h-4 w-4 mr-1" />
Назад к списку
</Link>
<h2 className="text-xl font-semibold text-gray-800">
{loading ? 'Загрузка...' : `Информация о клиенте: ${user ? getFullName(user) : ''}`}
</h2>
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<X className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : user ? (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* Основная информация */}
<div className="bg-white rounded-lg shadow p-6 col-span-2">
<h3 className="text-lg font-medium text-gray-900 mb-4">Основная информация</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-500">Имя</h4>
<p className="mt-1 text-sm text-gray-900">{getFullName(user)}</p>
</div>
<div className="flex items-center">
<Mail className="h-4 w-4 text-gray-400 mr-2" />
<h4 className="text-sm font-medium text-gray-500 mr-2">Email:</h4>
<a href={`mailto:${user.email}`} className="text-sm text-indigo-600 hover:text-indigo-800">
{user.email}
</a>
</div>
{user.phone && (
<div className="flex items-center">
<Phone className="h-4 w-4 text-gray-400 mr-2" />
<h4 className="text-sm font-medium text-gray-500 mr-2">Телефон:</h4>
<a href={`tel:${user.phone}`} className="text-sm text-indigo-600 hover:text-indigo-800">
{user.phone}
</a>
</div>
)}
<div className="flex items-center">
<Calendar className="h-4 w-4 text-gray-400 mr-2" />
<h4 className="text-sm font-medium text-gray-500 mr-2">Дата регистрации:</h4>
<p className="text-sm text-gray-900">{formatDate(user.created_at)}</p>
</div>
</div>
</div>
{/* Статистика */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Статистика</h3>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-500">Всего заказов</h4>
<p className="mt-1 text-lg font-semibold text-gray-900">{stats.ordersCount}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Общая сумма покупок</h4>
<p className="mt-1 text-lg font-semibold text-indigo-600">{formatCurrency(stats.totalSpent)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Последний заказ</h4>
<p className="mt-1 text-sm text-gray-900">{formatDate(stats.lastOrderDate)}</p>
</div>
</div>
</div>
</div>
{/* Адреса */}
{user.addresses && user.addresses.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Адреса</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{user.addresses.map((address) => (
<div key={address.id} className="border rounded-lg p-4 relative">
{address.is_default && (
<span className="absolute top-2 right-2 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
По умолчанию
</span>
)}
<div className="flex items-start mb-2">
<MapPin className="h-5 w-5 text-gray-400 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-gray-900">{address.type}</h4>
<p className="text-sm text-gray-600">
{address.address}, {address.city}, {address.postal_code}, {address.country}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* История заказов */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">История заказов</h3>
</div>
<div className="overflow-x-auto">
{orders.length === 0 ? (
<div className="p-6 text-center text-gray-500">
У клиента пока нет заказов
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> заказа</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Дата</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Способ оплаты</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товаров</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Clock className="h-4 w-4 text-gray-400 mr-1" />
{formatDate(order.created_at)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<OrderStatus status={order.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<CreditCard className="h-4 w-4 text-gray-400 mr-1" />
{order.payment_method}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<ShoppingBag className="h-4 w-4 text-gray-400 mr-1" />
{order.items.length}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
Подробнее
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</>
) : (
<div className="bg-white rounded-lg shadow p-6 text-center">
<p className="text-gray-500">Клиент не найден</p>
</div>
)}
</AdminLayout>
);
}

View File

@ -0,0 +1,236 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Search, ChevronLeft, ChevronRight, Mail, Phone } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { userService, User } from '../../../services/users';
// Компонент для фильтрации и поиска
const FilterBar = ({ onSearch }) => {
const [searchTerm, setSearchTerm] = useState('');
const handleSearch = (e) => {
e.preventDefault();
onSearch(searchTerm);
};
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Поиск по имени, email или телефону..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Поиск
</button>
</form>
</div>
);
};
export default function CustomersPage() {
const [searchTerm, setSearchTerm] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pagination, setPagination] = useState({
skip: 0,
limit: 10,
total: 0
});
// Загрузка пользователей при монтировании компонента
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
// Используем метод из userService для получения списка пользователей
const data = await userService.getUsers({
skip: pagination.skip,
limit: pagination.limit,
search: searchTerm
});
setUsers(data.users || []);
setPagination(prev => ({ ...prev, total: data.total || 0 }));
setError('');
} catch (err) {
console.error('Ошибка при загрузке пользователей:', err);
setError('Не удалось загрузить список клиентов. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchUsers();
}, [pagination.skip, pagination.limit, searchTerm]);
const handleSearch = (term) => {
setSearchTerm(term);
// Сбрасываем пагинацию при новом поиске
setPagination(prev => ({ ...prev, skip: 0 }));
};
const handleNextPage = () => {
if (pagination.skip + pagination.limit < pagination.total) {
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
}
};
const handlePrevPage = () => {
if (pagination.skip > 0) {
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
}
};
// Форматирование даты для отображения
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU');
};
// Получение полного имени пользователя
const getFullName = (user: User) => {
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
};
return (
<AdminLayout title="Управление клиентами">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-800">Список клиентов</h2>
</div>
<FilterBar onSearch={handleSearch} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Клиент</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Контакты</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Заказов</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма покупок</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Регистрация</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Последний заказ</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center">
<div className="flex justify-center">
<svg className="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
Клиенты не найдены
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{getFullName(user)}</div>
<div className="text-sm text-gray-500">ID: {user.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-500 mb-1">
<Mail className="h-4 w-4 mr-1" />
<a href={`mailto:${user.email}`} className="hover:text-indigo-600">{user.email}</a>
</div>
{user.phone && (
<div className="flex items-center text-sm text-gray-500">
<Phone className="h-4 w-4 mr-1" />
<a href={`tel:${user.phone}`} className="hover:text-indigo-600">{user.phone}</a>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{/* Эти данные должны приходить с бекенда */}
-
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{/* Эти данные должны приходить с бекенда */}
-
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(user.created_at)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{/* Эти данные должны приходить с бекенда */}
-
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/admin/customers/${user.id}`} className="text-indigo-600 hover:text-indigo-900">
Подробнее
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-500">
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} клиентов
</div>
<div className="flex space-x-2">
<button
onClick={handlePrevPage}
disabled={pagination.skip === 0}
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Назад
</button>
<button
onClick={handleNextPage}
disabled={pagination.skip + pagination.limit >= pagination.total}
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Вперед
<ChevronRight className="h-4 w-4 ml-1" />
</button>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,320 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings } from 'lucide-react';
import AdminLayout from '../../components/admin/AdminLayout';
import { analyticsService } from '../../services/analytics';
import { orderService, Order } from '../../services/orders';
import { productService, Product } from '../../services/catalog';
// Компонент статистической карточки
const StatCard = ({ title, value, icon, color }) => {
return (
<div className="bg-white rounded-lg shadow p-6 flex items-center">
<div className={`rounded-full p-3 mr-4 ${color}`}>
{icon}
</div>
<div>
<h3 className="text-gray-500 text-sm font-medium">{title}</h3>
<p className="text-2xl font-bold">{value}</p>
</div>
</div>
);
};
// Компонент последних заказов
const RecentOrders = ({ orders, loading, error }) => {
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="text-red-500">Ошибка при загрузке заказов: {error}</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium">Последние заказы</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Клиент</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Дата</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{orders.map((order) => (
<tr key={order.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.user_name || `Пользователь #${order.user_id}`}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(order.created_at).toLocaleDateString('ru-RU')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${order.status === 'delivered' ? 'bg-green-100 text-green-800' :
order.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
order.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'}`}>
{order.status === 'delivered' ? 'Доставлен' :
order.status === 'processing' ? 'В обработке' :
order.status === 'shipped' ? 'Отправлен' :
order.status === 'paid' ? 'Оплачен' :
order.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{order.total.toLocaleString('ru-RU')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
Подробнее
</Link>
</td>
</tr>
))}
{orders.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-4 text-center text-sm text-gray-500">
Заказы не найдены
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200">
<Link href="/admin/orders" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Посмотреть все заказы
</Link>
</div>
</div>
);
};
// Компонент популярных товаров
const PopularProducts = ({ products, loading, error }) => {
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="text-red-500">Ошибка при загрузке товаров: {error}</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium">Популярные товары</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товар</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Категория</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Продажи</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Остаток</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{product.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.category?.name || 'Без категории'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.sales || 0}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.stock > 20 ? 'bg-green-100 text-green-800' :
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
{product.stock}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
Редактировать
</Link>
</td>
</tr>
))}
{products.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
Товары не найдены
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200">
<Link href="/admin/products" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Посмотреть все товары
</Link>
</div>
</div>
);
};
export default function AdminDashboard() {
const [stats, setStats] = useState({
ordersCount: 0,
totalSales: 0,
customersCount: 0,
productsCount: 0
});
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
const [popularProducts, setPopularProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState({
stats: true,
orders: true,
products: true
});
const [error, setError] = useState({
stats: null,
orders: null,
products: null
});
// Загрузка данных при монтировании компонента
useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(prev => ({ ...prev, stats: true }));
// В реальном приложении здесь будет запрос к API для получения статистики
// Например: const statsData = await analyticsService.getReport('dashboard');
// Для демонстрации используем моковые данные
setTimeout(() => {
setStats({
ordersCount: 1248,
totalSales: 2456789,
customersCount: 3456,
productsCount: 867
});
setLoading(prev => ({ ...prev, stats: false }));
}, 1000);
} catch (err) {
console.error('Ошибка при загрузке статистики:', err);
setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' }));
setLoading(prev => ({ ...prev, stats: false }));
}
try {
setLoading(prev => ({ ...prev, orders: true }));
// Загружаем последние заказы
const orders = await orderService.getOrders({ limit: 4 });
setRecentOrders(orders);
setError(prev => ({ ...prev, orders: null }));
} catch (err) {
console.error('Ошибка при загрузке заказов:', err);
setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' }));
} finally {
setLoading(prev => ({ ...prev, orders: false }));
}
try {
setLoading(prev => ({ ...prev, products: true }));
// Загружаем популярные товары
// В реальном API должен быть эндпоинт для получения популярных товаров
// Здесь просто получаем первые 4 товара
const products = await productService.getProducts({ limit: 4 });
setPopularProducts(products);
setError(prev => ({ ...prev, products: null }));
} catch (err) {
console.error('Ошибка при загрузке товаров:', err);
setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' }));
} finally {
setLoading(prev => ({ ...prev, products: false }));
}
};
fetchDashboardData();
}, []);
return (
<AdminLayout title="Панель управления">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Всего заказов"
value={loading.stats ? "..." : stats.ordersCount.toLocaleString('ru-RU')}
icon={<ShoppingBag className="h-6 w-6 text-white" />}
color="bg-blue-500"
/>
<StatCard
title="Всего продаж"
value={loading.stats ? "..." : `${stats.totalSales.toLocaleString('ru-RU')}`}
icon={<BarChart3 className="h-6 w-6 text-white" />}
color="bg-green-500"
/>
<StatCard
title="Клиентов"
value={loading.stats ? "..." : stats.customersCount.toLocaleString('ru-RU')}
icon={<Users className="h-6 w-6 text-white" />}
color="bg-purple-500"
/>
<StatCard
title="Товаров"
value={loading.stats ? "..." : stats.productsCount.toLocaleString('ru-RU')}
icon={<Package className="h-6 w-6 text-white" />}
color="bg-orange-500"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<RecentOrders
orders={recentOrders}
loading={loading.orders}
error={error.orders}
/>
<PopularProducts
products={popularProducts}
loading={loading.products}
error={error.products}
/>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,389 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { ArrowLeft, Package, Truck, CreditCard, Calendar, User, MapPin, Check, X } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { orderService, Order, OrderUpdate } from '../../../services/orders';
// Компонент для отображения статуса заказа
const OrderStatus = ({ status }) => {
let bgColor = 'bg-gray-100';
let textColor = 'text-gray-800';
switch (status) {
case 'pending':
bgColor = 'bg-yellow-100';
textColor = 'text-yellow-800';
break;
case 'paid':
bgColor = 'bg-blue-100';
textColor = 'text-blue-800';
break;
case 'processing':
bgColor = 'bg-purple-100';
textColor = 'text-purple-800';
break;
case 'shipped':
bgColor = 'bg-indigo-100';
textColor = 'text-indigo-800';
break;
case 'delivered':
bgColor = 'bg-green-100';
textColor = 'text-green-800';
break;
case 'cancelled':
bgColor = 'bg-red-100';
textColor = 'text-red-800';
break;
}
const statusText = {
pending: 'Ожидает оплаты',
paid: 'Оплачен',
processing: 'В обработке',
shipped: 'Отправлен',
delivered: 'Доставлен',
cancelled: 'Отменен'
};
return (
<span className={`px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
{statusText[status] || status}
</span>
);
};
export default function OrderDetailsPage() {
const router = useRouter();
const { id } = router.query;
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [updating, setUpdating] = useState(false);
const [updateStatus, setUpdateStatus] = useState('');
// Загрузка данных заказа
useEffect(() => {
const fetchOrder = async () => {
if (!id) return;
try {
setLoading(true);
const data = await orderService.getOrderById(Number(id));
setOrder(data);
setError('');
} catch (err) {
console.error('Ошибка при загрузке заказа:', err);
setError('Не удалось загрузить данные заказа. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchOrder();
}, [id]);
// Обработчик изменения статуса заказа
const handleStatusChange = async (newStatus: string) => {
if (!order) return;
try {
setUpdating(true);
setUpdateStatus('');
const updatedOrder = await orderService.updateOrder(order.id, { status: newStatus });
setOrder(updatedOrder);
setUpdateStatus('Статус заказа успешно обновлен');
// Скрываем сообщение об успешном обновлении через 3 секунды
setTimeout(() => {
setUpdateStatus('');
}, 3000);
} catch (err) {
console.error('Ошибка при обновлении статуса заказа:', err);
setUpdateStatus('Не удалось обновить статус заказа. Пожалуйста, попробуйте позже.');
} finally {
setUpdating(false);
}
};
// Обработчик отмены заказа
const handleCancelOrder = async () => {
if (!order) return;
if (!confirm('Вы уверены, что хотите отменить заказ?')) {
return;
}
try {
setUpdating(true);
setUpdateStatus('');
const updatedOrder = await orderService.cancelOrder(order.id);
setOrder(updatedOrder);
setUpdateStatus('Заказ успешно отменен');
// Скрываем сообщение об успешном обновлении через 3 секунды
setTimeout(() => {
setUpdateStatus('');
}, 3000);
} catch (err) {
console.error('Ошибка при отмене заказа:', err);
setUpdateStatus('Не удалось отменить заказ. Пожалуйста, попробуйте позже.');
} finally {
setUpdating(false);
}
};
// Форматирование даты для отображения
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Форматирование суммы для отображения
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount);
};
return (
<AdminLayout title={`Заказ #${id || ''}`}>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center">
<Link href="/admin/orders" className="mr-4 text-gray-500 hover:text-gray-700">
<ArrowLeft className="h-5 w-5" />
</Link>
<h2 className="text-xl font-semibold text-gray-800">
{loading ? 'Загрузка...' : `Заказ #${order?.id || ''}`}
</h2>
</div>
{order && order.status !== 'cancelled' && (
<button
onClick={handleCancelOrder}
disabled={updating}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<X className="h-4 w-4 mr-1" />
Отменить заказ
</button>
)}
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{updateStatus && (
<div className={`p-4 mb-6 rounded-md ${updateStatus.includes('успешно') ? 'bg-green-50 border-l-4 border-green-500' : 'bg-red-50 border-l-4 border-red-500'}`}>
<div className="flex">
<div className="flex-shrink-0">
{updateStatus.includes('успешно') ? (
<Check className="h-5 w-5 text-green-500" />
) : (
<X className="h-5 w-5 text-red-500" />
)}
</div>
<div className="ml-3">
<p className={`text-sm ${updateStatus.includes('успешно') ? 'text-green-700' : 'text-red-700'}`}>{updateStatus}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : order ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Основная информация о заказе */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Информация о заказе</h3>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="flex items-center mb-4">
<Calendar className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Дата заказа</p>
<p className="text-sm font-medium">{formatDate(order.created_at)}</p>
</div>
</div>
<div className="flex items-center mb-4">
<User className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Клиент</p>
<p className="text-sm font-medium">ID: {order.user_id}</p>
</div>
</div>
<div className="flex items-center">
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Адрес доставки</p>
<p className="text-sm font-medium">{order.shipping_address}</p>
</div>
</div>
</div>
<div>
<div className="flex items-center mb-4">
<Package className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Статус</p>
<div className="mt-1">
<OrderStatus status={order.status} />
</div>
</div>
</div>
<div className="flex items-center mb-4">
<Truck className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Способ доставки</p>
<p className="text-sm font-medium">{order.shipping_method}</p>
</div>
</div>
<div className="flex items-center">
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm text-gray-500">Способ оплаты</p>
<p className="text-sm font-medium">{order.payment_method}</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Товары в заказе */}
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Товары в заказе</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товар</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Артикул</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цена</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Кол-во</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{order.items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{item.product_name}</div>
{(item.variant_size || item.variant_color) && (
<div className="text-sm text-gray-500">
{item.variant_size && `Размер: ${item.variant_size}`}
{item.variant_size && item.variant_color && ', '}
{item.variant_color && `Цвет: ${item.variant_color}`}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.product_sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatCurrency(item.price)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.quantity}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">{formatCurrency(item.price * item.quantity)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Боковая панель с действиями и итогами */}
<div>
{/* Изменение статуса заказа */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Изменить статус</h3>
</div>
<div className="px-6 py-4">
<div className="space-y-3">
{['pending', 'paid', 'processing', 'shipped', 'delivered'].map((status) => (
<button
key={status}
onClick={() => handleStatusChange(status)}
disabled={updating || order.status === status || order.status === 'cancelled'}
className={`w-full py-2 px-4 rounded-md text-sm font-medium ${
order.status === status
? 'bg-indigo-100 text-indigo-800 cursor-default'
: order.status === 'cancelled'
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{status === 'pending' && 'Ожидает оплаты'}
{status === 'paid' && 'Оплачен'}
{status === 'processing' && 'В обработке'}
{status === 'shipped' && 'Отправлен'}
{status === 'delivered' && 'Доставлен'}
</button>
))}
</div>
</div>
</div>
{/* Итоги заказа */}
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Итого</h3>
</div>
<div className="px-6 py-4">
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Сумма товаров</span>
<span className="text-sm font-medium">{formatCurrency(order.total)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Доставка</span>
<span className="text-sm font-medium">0 </span>
</div>
<div className="pt-3 border-t border-gray-200 flex justify-between">
<span className="text-base font-medium">Итого</span>
<span className="text-base font-bold">{formatCurrency(order.total)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow p-6 text-center">
<p className="text-gray-500">Заказ не найден</p>
</div>
)}
</AdminLayout>
);
}

View File

@ -0,0 +1,361 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Search, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import { orderService, Order } from '../../../services/orders';
// Компонент для фильтрации и поиска
const FilterBar = ({ onSearch, onFilter }) => {
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState({
status: '',
dateFrom: '',
dateTo: ''
});
const handleSearch = (e) => {
e.preventDefault();
onSearch(searchTerm);
};
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters({
...filters,
[name]: value
});
};
const handleFilter = (e) => {
e.preventDefault();
onFilter(filters);
};
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 mb-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Поиск по номеру заказа или имени клиента..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
type="submit"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Поиск
</button>
</form>
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-500 mb-3 flex items-center">
<Filter className="h-4 w-4 mr-1" />
Фильтры
</h3>
<form onSubmit={handleFilter} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">Статус</label>
<select
id="status"
name="status"
value={filters.status}
onChange={handleFilterChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Все статусы</option>
<option value="pending">Ожидает оплаты</option>
<option value="paid">Оплачен</option>
<option value="processing">В обработке</option>
<option value="shipped">Отправлен</option>
<option value="delivered">Доставлен</option>
<option value="cancelled">Отменен</option>
</select>
</div>
<div>
<label htmlFor="dateFrom" className="block text-sm font-medium text-gray-700 mb-1">Дата с</label>
<input
type="date"
id="dateFrom"
name="dateFrom"
value={filters.dateFrom}
onChange={handleFilterChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="dateTo" className="block text-sm font-medium text-gray-700 mb-1">Дата по</label>
<input
type="date"
id="dateTo"
name="dateTo"
value={filters.dateTo}
onChange={handleFilterChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="flex items-end">
<button
type="submit"
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 w-full"
>
Применить фильтры
</button>
</div>
</form>
</div>
</div>
);
};
// Компонент для отображения статуса заказа
const OrderStatus = ({ status }) => {
let bgColor = 'bg-gray-100';
let textColor = 'text-gray-800';
switch (status) {
case 'pending':
bgColor = 'bg-yellow-100';
textColor = 'text-yellow-800';
break;
case 'paid':
bgColor = 'bg-blue-100';
textColor = 'text-blue-800';
break;
case 'processing':
bgColor = 'bg-purple-100';
textColor = 'text-purple-800';
break;
case 'shipped':
bgColor = 'bg-indigo-100';
textColor = 'text-indigo-800';
break;
case 'delivered':
bgColor = 'bg-green-100';
textColor = 'text-green-800';
break;
case 'cancelled':
bgColor = 'bg-red-100';
textColor = 'text-red-800';
break;
}
const statusText = {
pending: 'Ожидает оплаты',
paid: 'Оплачен',
processing: 'В обработке',
shipped: 'Отправлен',
delivered: 'Доставлен',
cancelled: 'Отменен'
};
return (
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
{statusText[status] || status}
</span>
);
};
export default function OrdersPage() {
const [searchTerm, setSearchTerm] = useState('');
const [activeFilters, setActiveFilters] = useState<{
status?: string;
dateFrom?: string;
dateTo?: string;
}>({});
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pagination, setPagination] = useState({
skip: 0,
limit: 10,
total: 0
});
// Загрузка заказов при монтировании компонента и при изменении фильтров
useEffect(() => {
const fetchOrders = async () => {
try {
setLoading(true);
const params = {
skip: pagination.skip,
limit: pagination.limit,
...activeFilters
};
const data = await orderService.getOrders(params);
setOrders(data);
// Предполагаем, что общее количество заказов будет возвращаться с бекенда
// В реальном API это может быть в заголовках или в отдельном поле ответа
setPagination(prev => ({ ...prev, total: data.length > 0 ? 50 : 0 })); // Временное решение
setError('');
} catch (err) {
console.error('Ошибка при загрузке заказов:', err);
setError('Не удалось загрузить заказы. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchOrders();
}, [activeFilters, pagination.skip, pagination.limit]);
const handleSearch = (term) => {
setSearchTerm(term);
// Сбрасываем пагинацию при новом поиске
setPagination(prev => ({ ...prev, skip: 0 }));
// Здесь можно добавить логику для поиска по заказам
// Например, отправить запрос к API с параметром поиска
};
const handleFilter = (filters) => {
setActiveFilters(filters);
// Сбрасываем пагинацию при новых фильтрах
setPagination(prev => ({ ...prev, skip: 0 }));
};
const handleNextPage = () => {
if (pagination.skip + pagination.limit < pagination.total) {
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
}
};
const handlePrevPage = () => {
if (pagination.skip > 0) {
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
}
};
// Форматирование даты для отображения
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU');
};
// Форматирование суммы для отображения
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(amount);
};
return (
<AdminLayout title="Управление заказами">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-800">Список заказов</h2>
</div>
<FilterBar onSearch={handleSearch} onFilter={handleFilter} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID заказа</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Клиент</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Дата</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Способ оплаты</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товаров</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-4 text-center">
<div className="flex justify-center">
<svg className="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</td>
</tr>
) : orders.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-4 text-center text-sm text-gray-500">
Заказы не найдены
</td>
</tr>
) : (
orders.map((order) => (
<tr key={order.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{/* Здесь будет имя клиента, когда API вернет эти данные */}
Клиент #{order.user_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(order.created_at)}</td>
<td className="px-6 py-4 whitespace-nowrap">
<OrderStatus status={order.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.payment_method}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.items.length}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
Подробнее
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-500">
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} заказов
</div>
<div className="flex space-x-2">
<button
onClick={handlePrevPage}
disabled={pagination.skip === 0}
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Назад
</button>
<button
onClick={handleNextPage}
disabled={pagination.skip + pagination.limit >= pagination.total}
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
Вперед
<ChevronRight className="h-4 w-4 ml-1" />
</button>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

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

View File

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

View File

@ -0,0 +1,328 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Plus, Search, Edit, Trash, ChevronLeft, ChevronRight } from 'lucide-react';
import AdminLayout from '../../../components/admin/AdminLayout';
import Image from 'next/image';
import { productService, categoryService, Product, Category } from '../../../services/catalog';
// Компонент для фильтрации и поиска
const FilterBar = ({ onSearch, categories, onFilter }) => {
const [searchTerm, setSearchTerm] = useState('');
const [categoryId, setCategoryId] = useState('');
const [status, setStatus] = useState('');
const handleSearch = (e) => {
e.preventDefault();
onSearch(searchTerm);
};
const handleFilter = () => {
onFilter({
category_id: categoryId ? parseInt(categoryId) : undefined,
is_active: status === 'active' ? true : status === 'inactive' ? false : undefined
});
};
return (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Поиск товаров..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value="">Все категории</option>
{categories.map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
<select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="">Статус</option>
<option value="active">Активные</option>
<option value="inactive">Неактивные</option>
</select>
<button
type="button"
onClick={handleFilter}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Применить
</button>
</div>
</form>
</div>
);
};
export default function ProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState<string>('');
const [filters, setFilters] = useState<{
category_id?: number;
is_active?: boolean;
}>({});
const [pagination, setPagination] = useState({
skip: 0,
limit: 10,
total: 0
});
// Загрузка товаров и категорий при монтировании компонента
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
// Загружаем категории
const categoriesData = await categoryService.getCategories();
setCategories(categoriesData);
// Загружаем товары с учетом фильтров и пагинации
const productsData = await productService.getProducts({
skip: pagination.skip,
limit: pagination.limit,
search: searchTerm,
...filters
});
setProducts(productsData);
// В реальном API должно возвращаться общее количество товаров
// Здесь просто устанавливаем примерное значение
setPagination(prev => ({ ...prev, total: 50 }));
setError(null);
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchData();
}, [searchTerm, filters, pagination.skip, pagination.limit]);
// Обработчик поиска
const handleSearch = (term: string) => {
setSearchTerm(term);
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при поиске
};
// Обработчик фильтрации
const handleFilter = (newFilters: { category_id?: number; is_active?: boolean }) => {
setFilters(newFilters);
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при фильтрации
};
// Обработчик удаления товара
const handleDeleteProduct = async (id: number) => {
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
try {
await productService.deleteProduct(id);
setProducts(products.filter(product => product.id !== id));
} catch (err) {
console.error('Ошибка при удалении товара:', err);
setError('Не удалось удалить товар.');
}
}
};
// Обработчик изменения статуса товара
const handleToggleStatus = async (id: number, currentStatus: boolean) => {
try {
await productService.updateProduct(id, { is_active: !currentStatus });
setProducts(products.map(product =>
product.id === id ? { ...product, is_active: !product.is_active } : product
));
} catch (err) {
console.error('Ошибка при изменении статуса товара:', err);
setError('Не удалось изменить статус товара.');
}
};
// Обработчики пагинации
const handlePrevPage = () => {
if (pagination.skip > 0) {
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
}
};
const handleNextPage = () => {
if (pagination.skip + pagination.limit < pagination.total) {
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
}
};
// Вычисляем текущую страницу и общее количество страниц
const currentPage = Math.floor(pagination.skip / pagination.limit) + 1;
const totalPages = Math.ceil(pagination.total / pagination.limit);
if (loading && products.length === 0) {
return (
<AdminLayout title="Управление товарами">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
</AdminLayout>
);
}
if (error) {
return (
<AdminLayout title="Управление товарами">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
<strong className="font-bold">Ошибка!</strong>
<span className="block sm:inline"> {error}</span>
</div>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Попробовать снова
</button>
</AdminLayout>
);
}
return (
<AdminLayout title="Управление товарами">
<div className="mb-6 flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-800">Список товаров</h2>
<Link href="/admin/products/create" 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">
<Plus className="h-5 w-5 mr-2" />
Добавить товар
</Link>
</div>
<FilterBar
onSearch={handleSearch}
categories={categories}
onFilter={handleFilter}
/>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товар</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Артикул</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Цена</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Категория</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Остаток</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 relative">
<Image
src={product.images && product.images.length > 0
? product.images.find(img => img.is_primary)?.url || product.images[0].url
: '/placeholder.jpg'}
alt={product.name}
fill
className="object-cover rounded-md"
/>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{product.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.sku}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.price.toLocaleString('ru-RU')} </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.category?.name || 'Без категории'}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.stock > 20 ? 'bg-green-100 text-green-800' :
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'}`}>
{product.stock}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleStatus(product.id, product.is_active)}
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${product.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{product.is_active ? 'Активен' : 'Неактивен'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
<Edit className="h-5 w-5" />
</Link>
<button
onClick={() => handleDeleteProduct(product.id)}
className="text-red-600 hover:text-red-900"
>
<Trash className="h-5 w-5" />
</button>
</div>
</td>
</tr>
))}
{products.length === 0 && !loading && (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
Товары не найдены
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-500">
Показано {pagination.skip + 1}-{Math.min(pagination.skip + products.length, pagination.total)} из {pagination.total} товаров
</div>
<div className="flex space-x-2">
<button
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handlePrevPage}
disabled={pagination.skip === 0}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Назад
</button>
<button
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleNextPage}
disabled={pagination.skip + pagination.limit >= pagination.total}
>
Вперед
<ChevronRight className="h-4 w-4 ml-1" />
</button>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,130 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Mail, AlertCircle, CheckCircle } from 'lucide-react';
import authService from '../services/auth';
export default function ForgotPasswordPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleChange = (e) => {
setEmail(e.target.value);
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccess(false);
try {
// Предполагаем, что в authService есть метод resetPassword
// Если его нет, нужно будет добавить
await authService.resetPassword(email);
setSuccess(true);
} catch (err) {
console.error('Ошибка при запросе сброса пароля:', err);
setError('Не удалось отправить запрос на сброс пароля. Пожалуйста, проверьте введенный email.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Восстановление пароля
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Введите email, указанный при регистрации, и мы отправим вам инструкции по сбросу пароля.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{success ? (
<div className="text-center">
<div className="mb-4 flex justify-center">
<CheckCircle className="h-12 w-12 text-green-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Проверьте вашу почту</h3>
<p className="text-sm text-gray-600 mb-6">
Мы отправили инструкции по сбросу пароля на адрес {email}.
Если вы не получили письмо в течение нескольких минут, проверьте папку "Спам".
</p>
<div className="flex flex-col space-y-4">
<button
onClick={() => setSuccess(false)}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Отправить еще раз
</button>
<Link href="/login" className="w-full flex justify-center py-2 px-4 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">
Вернуться на страницу входа
</Link>
</div>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="example@example.com"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Отправка...' : 'Отправить инструкции'}
</button>
</div>
<div className="text-center">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Вернуться на страницу входа
</Link>
</div>
</form>
)}
</div>
</div>
</div>
);
}

200
frontend/pages/login.tsx Normal file
View File

@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Mail, Lock, AlertCircle } from 'lucide-react';
import authService from '../services/auth';
export default function LoginPage() {
const router = useRouter();
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [redirectTo, setRedirectTo] = useState<string | null>(null);
// Проверяем, есть ли параметр redirect в URL
useEffect(() => {
if (router.query.redirect) {
setRedirectTo(router.query.redirect as string);
}
// Если пользователь уже авторизован, перенаправляем его
if (authService.isAuthenticated()) {
router.push(redirectTo || '/');
}
}, [router.query.redirect, redirectTo, router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await authService.login(formData);
// Перенаправляем пользователя после успешного входа
router.push(redirectTo || '/');
} catch (err) {
console.error('Ошибка при входе:', err);
setError('Неверный email или пароль. Пожалуйста, проверьте введенные данные.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Вход в аккаунт
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Или{' '}
<Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
зарегистрируйтесь, если у вас еще нет аккаунта
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Имя пользователя или Email
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={formData.username}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="username или example@example.com"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Пароль
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={formData.password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember_me"
name="remember_me"
type="checkbox"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="remember_me" className="ml-2 block text-sm text-gray-900">
Запомнить меня
</label>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-indigo-600 hover:text-indigo-500">
Забыли пароль?
</Link>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Или войдите через</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<div>
<a
href="#"
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Войти через Google</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
</a>
</div>
<div>
<a
href="#"
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<span className="sr-only">Войти через Facebook</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

260
frontend/pages/register.tsx Normal file
View File

@ -0,0 +1,260 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Mail, Lock, User, Phone, AlertCircle } from 'lucide-react';
import authService from '../services/auth';
export default function RegisterPage() {
const router = useRouter();
const [formData, setFormData] = useState({
email: '',
password: '',
password_confirm: '',
first_name: '',
last_name: '',
phone: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [redirectTo, setRedirectTo] = useState<string | null>(null);
// Проверяем, есть ли параметр redirect в URL
useEffect(() => {
if (router.query.redirect) {
setRedirectTo(router.query.redirect as string);
}
// Если пользователь уже авторизован, перенаправляем его
if (authService.isAuthenticated()) {
router.push(redirectTo || '/');
}
}, [router.query.redirect, redirectTo, router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
// Проверяем совпадение паролей
if (formData.password !== formData.password_confirm) {
setError('Пароли не совпадают');
setLoading(false);
return;
}
try {
// Отправляем данные для регистрации (включая поле password_confirm)
await authService.register(formData);
// Перенаправляем пользователя после успешной регистрации
router.push(redirectTo || '/');
} catch (err) {
console.error('Ошибка при регистрации:', err);
setError('Не удалось зарегистрироваться. Возможно, пользователь с таким email уже существует.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Регистрация
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Уже есть аккаунт?{' '}
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Войдите
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Имя
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="first_name"
name="first_name"
type="text"
autoComplete="given-name"
required
value={formData.first_name}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Иван"
/>
</div>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
Фамилия
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="last_name"
name="last_name"
type="text"
autoComplete="family-name"
required
value={formData.last_name}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Иванов"
/>
</div>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="example@example.com"
/>
</div>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Телефон (необязательно)
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Phone className="h-5 w-5 text-gray-400" />
</div>
<input
id="phone"
name="phone"
type="tel"
autoComplete="tel"
value={formData.phone}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="+7 (999) 123-45-67"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Пароль
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={formData.password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
</div>
<div>
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
Подтверждение пароля
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password_confirm"
name="password_confirm"
type="password"
autoComplete="new-password"
required
value={formData.password_confirm}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
</div>
<div className="flex items-center">
<input
id="terms"
name="terms"
type="checkbox"
required
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label htmlFor="terms" className="ml-2 block text-sm text-gray-900">
Я согласен с <a href="#" className="text-indigo-600 hover:text-indigo-500">условиями использования</a> и <a href="#" className="text-indigo-600 hover:text-indigo-500">политикой конфиденциальности</a>
</label>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,210 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
import authService from '../../services/auth';
export default function ResetPasswordPage() {
const router = useRouter();
const { token } = router.query;
const [formData, setFormData] = useState({
password: '',
password_confirm: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [tokenValid, setTokenValid] = useState(true);
// Проверка валидности токена
useEffect(() => {
if (token) {
// В реальном приложении здесь можно сделать запрос к API для проверки токена
// Для примера просто проверяем, что токен не пустой
setTokenValid(typeof token === 'string' && token.length > 0);
}
}, [token]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
// Проверяем совпадение паролей
if (formData.password !== formData.password_confirm) {
setError('Пароли не совпадают');
setLoading(false);
return;
}
try {
if (typeof token !== 'string') {
throw new Error('Недействительный токен');
}
await authService.setNewPassword(token, formData.password);
setSuccess(true);
} catch (err) {
console.error('Ошибка при сбросе пароля:', err);
setError('Не удалось установить новый пароль. Возможно, ссылка устарела или недействительна.');
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
<p className="text-gray-600 mb-4">Загрузка...</p>
</div>
</div>
</div>
);
}
if (!tokenValid) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Недействительная ссылка
</h2>
<div className="mt-8 bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 text-left">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
Ссылка для сброса пароля недействительна или устарела.
</p>
</div>
</div>
</div>
<p className="text-gray-600 mb-6">
Пожалуйста, запросите новую ссылку для сброса пароля.
</p>
<Link href="/forgot-password" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Запросить новую ссылку
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Установка нового пароля
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Введите новый пароль для вашей учетной записи.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{success ? (
<div className="text-center">
<div className="mb-4 flex justify-center">
<CheckCircle className="h-12 w-12 text-green-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Пароль успешно изменен</h3>
<p className="text-sm text-gray-600 mb-6">
Ваш пароль был успешно изменен. Теперь вы можете войти в систему, используя новый пароль.
</p>
<Link href="/login" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Перейти на страницу входа
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Новый пароль
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={formData.password}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
</div>
<div>
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
Подтверждение пароля
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password_confirm"
name="password_confirm"
type="password"
autoComplete="new-password"
required
value={formData.password_confirm}
onChange={handleChange}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="••••••••"
minLength={8}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Сохранение...' : 'Установить новый пароль'}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,100 @@
import api from './api';
// Типы данных
export interface AnalyticsLog {
id: number;
event_type: string;
user_id?: number;
session_id: string;
data: Record<string, any>;
created_at: string;
}
export interface AnalyticsLogCreate {
event_type: string;
session_id: string;
data: Record<string, any>;
}
export interface AnalyticsReport {
report_type: string;
data: Record<string, any>;
start_date?: string;
end_date?: string;
}
// Сервис для работы с аналитикой
export const analyticsService = {
// Логирование события аналитики
logEvent: async (event: AnalyticsLogCreate): Promise<AnalyticsLog> => {
const response = await api.post('/analytics/log', event);
return response.data;
},
// Получить логи аналитики (только для админов)
getLogs: async (params?: {
event_type?: string;
start_date?: string;
end_date?: string;
skip?: number;
limit?: number;
}): Promise<{ logs: AnalyticsLog[], total: number }> => {
const response = await api.get('/analytics/logs', { params });
return response.data;
},
// Получить отчет по аналитике (только для админов)
getReport: async (reportType: string, params?: {
start_date?: string;
end_date?: string;
}): Promise<AnalyticsReport> => {
const response = await api.get(`/analytics/report`, {
params: {
report_type: reportType,
...params
}
});
return response.data;
},
// Инициализация отслеживания сессии
initTracking: (): string => {
// Генерируем ID сессии, если его нет
let sessionId = localStorage.getItem('session_id');
if (!sessionId) {
sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
localStorage.setItem('session_id', sessionId);
}
return sessionId;
},
// Отслеживание просмотра страницы
trackPageView: async (path: string): Promise<void> => {
const sessionId = analyticsService.initTracking();
await analyticsService.logEvent({
event_type: 'page_view',
session_id: sessionId,
data: { path }
});
},
// Отслеживание просмотра товара
trackProductView: async (productId: number, productName: string): Promise<void> => {
const sessionId = analyticsService.initTracking();
await analyticsService.logEvent({
event_type: 'product_view',
session_id: sessionId,
data: { product_id: productId, product_name: productName }
});
},
// Отслеживание добавления товара в корзину
trackAddToCart: async (productId: number, productName: string, quantity: number): Promise<void> => {
const sessionId = analyticsService.initTracking();
await analyticsService.logEvent({
event_type: 'add_to_cart',
session_id: sessionId,
data: { product_id: productId, product_name: productName, quantity }
});
}
};

45
frontend/services/api.ts Normal file
View File

@ -0,0 +1,45 @@
import axios from 'axios';
// Создаем экземпляр axios с базовыми настройками
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api',
headers: {
'Content-Type': 'application/json',
},
});
// Перехватчик запросов для добавления токена авторизации
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Перехватчик ответов для обработки ошибок
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Если ошибка 401 (неавторизован) и это не повторный запрос
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// Здесь можно добавить логику обновления токена
// Например, перенаправление на страницу входа
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;

94
frontend/services/auth.ts Normal file
View File

@ -0,0 +1,94 @@
import api from './api';
import { User } from './users';
// Типы данных
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
password_confirm: string;
first_name?: string;
last_name?: string;
phone?: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
user: User;
}
// Сервис для аутентификации
const authService = {
// Вход в систему
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
// Создаем FormData для отправки данных в формате x-www-form-urlencoded
const formData = new URLSearchParams();
formData.append('username', credentials.username);
formData.append('password', credentials.password);
const response = await api.post<AuthResponse>('/auth/login', formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
// Сохраняем токен в localStorage
if (response.data.access_token) {
localStorage.setItem('token', response.data.access_token);
}
return response.data;
},
// Регистрация нового пользователя
register: async (data: RegisterData): Promise<AuthResponse> => {
const response = await api.post<AuthResponse>('/auth/register', data);
// Сохраняем токен в localStorage
if (response.data.access_token) {
localStorage.setItem('token', response.data.access_token);
}
return response.data;
},
// Выход из системы
logout: (): void => {
localStorage.removeItem('token');
},
// Проверка, авторизован ли пользователь
isAuthenticated: (): boolean => {
return !!localStorage.getItem('token');
},
// Получить текущий токен
getToken: (): string | null => {
return localStorage.getItem('token');
},
// Запрос на сброс пароля
resetPassword: async (email: string): Promise<void> => {
await api.post('/auth/reset-password', { email });
},
// Установка нового пароля после сброса
setNewPassword: async (token: string, password: string): Promise<void> => {
await api.post('/auth/set-new-password', { token, password });
},
// Изменение пароля авторизованным пользователем
changePassword: async (currentPassword: string, newPassword: string): Promise<void> => {
await api.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword
});
}
};
export default authService;

View File

@ -0,0 +1,178 @@
import api from './api';
// Типы данных
export interface Category {
id: number;
name: string;
slug: string;
parent_id: number | null;
order: number;
is_active: boolean;
products_count?: number;
children?: Category[];
}
export interface ProductImage {
id: number;
url: string;
is_primary: boolean;
product_id: number;
}
export interface ProductVariant {
id: number;
sku: string;
size: string;
color: string;
price: number;
stock: number;
product_id: number;
}
export interface Product {
id: number;
name: string;
slug: string;
description: string;
price: number;
sku: string;
stock: number;
is_active: boolean;
category_id: number;
category?: Category;
images?: ProductImage[];
variants?: ProductVariant[];
}
// Сервисы для работы с категориями
export const categoryService = {
// Получить все категории в виде дерева
getCategories: async (): Promise<Category[]> => {
const response = await api.get('/catalog/categories');
return response.data;
},
// Создать новую категорию
createCategory: async (category: Omit<Category, 'id'>): Promise<Category> => {
const response = await api.post('/catalog/categories', category);
return response.data;
},
// Обновить категорию
updateCategory: async (id: number, category: Partial<Category>): Promise<Category> => {
const response = await api.put(`/catalog/categories/${id}`, category);
return response.data;
},
// Удалить категорию
deleteCategory: async (id: number): Promise<void> => {
await api.delete(`/catalog/categories/${id}`);
}
};
// Сервисы для работы с товарами
export const productService = {
// Получить список товаров с фильтрацией
getProducts: async (params?: {
skip?: number;
limit?: number;
category_id?: number;
search?: string;
min_price?: number;
max_price?: number;
is_active?: boolean;
}): Promise<Product[]> => {
const response = await api.get('/catalog/products', { params });
return response.data;
},
// Получить детали товара по ID
getProductById: async (id: number): Promise<Product> => {
try {
const response = await api.get(`/catalog/products/${id}`);
// Проверяем структуру ответа
if (response.data && response.data.product) {
// Если ответ содержит вложенный объект product, используем его
const product = response.data.product;
// Добавляем изображения и варианты из ответа
if (response.data.images && Array.isArray(response.data.images)) {
product.images = response.data.images;
}
if (response.data.variants && Array.isArray(response.data.variants)) {
product.variants = response.data.variants;
}
return product;
} else {
// Если структура ответа другая, возвращаем как есть
return response.data;
}
} catch (error) {
console.error('Ошибка при получении продукта:', error);
throw error;
}
},
// Создать новый товар
createProduct: async (product: Omit<Product, 'id'>): Promise<Product> => {
const response = await api.post('/catalog/products', product);
return response.data;
},
// Обновить товар
updateProduct: async (id: number, product: Partial<Product>): Promise<Product> => {
const response = await api.put(`/catalog/products/${id}`, product);
return response.data;
},
// Удалить товар
deleteProduct: async (id: number): Promise<void> => {
await api.delete(`/catalog/products/${id}`);
},
// Загрузить изображение товара
uploadProductImage: async (productId: number, file: File, isPrimary: boolean): Promise<ProductImage> => {
const formData = new FormData();
formData.append('file', file);
formData.append('is_primary', isPrimary.toString());
const response = await api.post(`/catalog/products/${productId}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
// Обновить изображение товара
updateProductImage: async (imageId: number, data: { is_primary: boolean }): Promise<ProductImage> => {
const response = await api.put(`/catalog/images/${imageId}`, data);
return response.data;
},
// Удалить изображение товара
deleteProductImage: async (imageId: number): Promise<void> => {
await api.delete(`/catalog/images/${imageId}`);
},
// Добавить вариант товара
addProductVariant: async (productId: number, variant: Omit<ProductVariant, 'id' | 'product_id'>): Promise<ProductVariant> => {
const response = await api.post(`/catalog/products/${productId}/variants`, variant);
return response.data;
},
// Обновить вариант товара
updateProductVariant: async (variantId: number, variant: Partial<ProductVariant>): Promise<ProductVariant> => {
const response = await api.put(`/catalog/variants/${variantId}`, variant);
return response.data;
},
// Удалить вариант товара
deleteProductVariant: async (variantId: number): Promise<void> => {
await api.delete(`/catalog/variants/${variantId}`);
}
};

View File

@ -0,0 +1,78 @@
import api from './api';
// Типы данных
export interface OrderItem {
id: number;
order_id: number;
product_id: number;
variant_id?: number;
quantity: number;
price: number;
product: {
id: number;
name: string;
image?: string;
};
variant_name?: string;
}
export interface Order {
id: number;
user_id: number;
status: string;
total_amount: number;
shipping_address: string;
payment_method: string;
created_at: string;
updated_at?: string;
items: OrderItem[];
}
export interface OrderCreate {
shipping_address_id: number;
payment_method: string;
items?: {
product_id: number;
variant_id?: number;
quantity: number;
}[];
}
export interface OrderUpdate {
status?: string;
shipping_address_id?: number;
payment_method?: string;
}
// Сервис для работы с заказами
export const orderService = {
// Получить список заказов пользователя
getUserOrders: async (): Promise<Order[]> => {
const response = await api.get('/orders');
return response.data.orders;
},
// Получить заказ по ID
getOrderById: async (id: number): Promise<Order> => {
const response = await api.get(`/orders/${id}`);
return response.data;
},
// Создать новый заказ
createOrder: async (order: OrderCreate): Promise<Order> => {
const response = await api.post('/orders', order);
return response.data;
},
// Обновить заказ
updateOrder: async (id: number, order: OrderUpdate): Promise<Order> => {
const response = await api.put(`/orders/${id}`, order);
return response.data;
},
// Отменить заказ
cancelOrder: async (id: number): Promise<Order> => {
const response = await api.post(`/orders/${id}/cancel`);
return response.data;
}
};

View File

@ -0,0 +1,99 @@
import api from './api';
// Типы данных
export interface Address {
id: number;
user_id: number;
type: string;
address: string;
city: string;
postal_code: string;
country: string;
is_default: boolean;
}
export interface User {
id: number;
email: string;
first_name: string;
last_name: string;
phone?: string;
is_active: boolean;
is_admin: boolean;
created_at: string;
addresses?: Address[];
}
export interface UserUpdate {
first_name?: string;
last_name?: string;
phone?: string;
password?: string;
email?: string;
}
export interface AddressCreate {
type: string;
address: string;
city: string;
postal_code: string;
country: string;
is_default?: boolean;
}
export interface AddressUpdate {
type?: string;
address?: string;
city?: string;
postal_code?: string;
country?: string;
is_default?: boolean;
}
// Сервис для работы с пользователями
export const userService = {
// Получить профиль текущего пользователя
getCurrentUser: async (): Promise<User> => {
const response = await api.get('/users/me');
return response.data.user;
},
// Обновить профиль текущего пользователя
updateCurrentUser: async (userData: UserUpdate): Promise<User> => {
const response = await api.put('/users/me', userData);
return response.data;
},
// Добавить адрес для текущего пользователя
addAddress: async (address: AddressCreate): Promise<Address> => {
const response = await api.post('/users/me/addresses', address);
return response.data;
},
// Обновить адрес
updateAddress: async (addressId: number, address: AddressUpdate): Promise<Address> => {
const response = await api.put(`/users/me/addresses/${addressId}`, address);
return response.data;
},
// Удалить адрес
deleteAddress: async (addressId: number): Promise<void> => {
await api.delete(`/users/me/addresses/${addressId}`);
},
// Получить информацию о пользователе по ID (только для админов)
getUserById: async (userId: number): Promise<User> => {
const response = await api.get(`/users/${userId}`);
return response.data;
},
// Получить список всех пользователей (только для админов)
getUsers: async (params?: {
skip?: number;
limit?: number;
search?: string;
}): Promise<{ users: User[], total: number }> => {
const response = await api.get('/users', { params });
return response.data;
}
};