добавлена корзина, заказ
This commit is contained in:
parent
695643a874
commit
33492bb239
Binary file not shown.
Binary file not shown.
@ -172,160 +172,120 @@ def get_all_orders(
|
||||
|
||||
def create_order(db: Session, order: OrderCreate, user_id: int) -> Order:
|
||||
# Проверяем, что адрес доставки существует и принадлежит пользователю, если указан
|
||||
print(f"Начало создания заказа для пользователя {user_id}")
|
||||
print(f"Данные заказа: {order}")
|
||||
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(
|
||||
UserAddress.id == order.shipping_address_id,
|
||||
UserAddress.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not address:
|
||||
print(f"Адрес доставки {order.shipping_address_id} не найден для пользователя {user_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Адрес доставки не найден или не принадлежит пользователю"
|
||||
detail="Указанный адрес доставки не найден"
|
||||
)
|
||||
print(f"Адрес доставки найден: {address}")
|
||||
|
||||
# Получаем элементы заказа
|
||||
order_items = []
|
||||
total_amount = 0
|
||||
# Создаем новый заказ
|
||||
new_order = Order(
|
||||
user_id=user_id,
|
||||
status=OrderStatus.PENDING,
|
||||
shipping_address_id=order.shipping_address_id,
|
||||
payment_method=order.payment_method,
|
||||
notes=order.notes,
|
||||
total_amount=0 # Будет обновлено после добавления товаров
|
||||
)
|
||||
|
||||
# Если указаны элементы корзины, используем их
|
||||
db.add(new_order)
|
||||
db.flush() # Получаем ID заказа
|
||||
|
||||
print(f"Создан заказ с ID: {new_order.id}")
|
||||
|
||||
# Получаем элементы корзины пользователя
|
||||
cart_items = []
|
||||
|
||||
# Если указаны конкретные элементы корзины
|
||||
if order.cart_items:
|
||||
print(f"Используем указанные элементы корзины: {order.cart_items}")
|
||||
cart_items = db.query(CartItem).filter(
|
||||
CartItem.id.in_(order.cart_items),
|
||||
CartItem.user_id == user_id
|
||||
).all()
|
||||
|
||||
if len(cart_items) != len(order.cart_items):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Некоторые элементы корзины не найдены или не принадлежат пользователю"
|
||||
)
|
||||
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант и продукт
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {cart_item.variant_id} не найден"
|
||||
)
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Рассчитываем цену
|
||||
price = product.discount_price if product.discount_price else product.price
|
||||
price += variant.price_adjustment
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
variant_id=cart_item.variant_id,
|
||||
quantity=cart_item.quantity,
|
||||
price=price
|
||||
)
|
||||
order_items.append(order_item)
|
||||
|
||||
# Обновляем общую сумму
|
||||
total_amount += price * cart_item.quantity
|
||||
|
||||
# Если указаны прямые элементы заказа, используем их
|
||||
# Если указаны прямые элементы заказа
|
||||
elif order.items:
|
||||
for item in order.items:
|
||||
print(f"Используем прямые элементы заказа: {order.items}")
|
||||
# Создаем элементы заказа напрямую
|
||||
for item_data in order.items:
|
||||
# Проверяем, что вариант существует
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item_data.variant_id).first()
|
||||
if not variant:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {item.variant_id} не найден"
|
||||
detail=f"Вариант товара с ID {item_data.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
variant_id=item.variant_id,
|
||||
quantity=item.quantity,
|
||||
price=item.price
|
||||
order_id=new_order.id,
|
||||
product_id=variant.product_id,
|
||||
variant_id=variant.id,
|
||||
quantity=item_data.quantity,
|
||||
price=item_data.price
|
||||
)
|
||||
order_items.append(order_item)
|
||||
|
||||
# Обновляем общую сумму
|
||||
total_amount += item.price * item.quantity
|
||||
|
||||
db.add(order_item)
|
||||
new_order.total_amount += order_item.price * order_item.quantity
|
||||
# Иначе используем все элементы корзины пользователя
|
||||
else:
|
||||
# Если не указаны ни элементы корзины, ни прямые элементы заказа, используем всю корзину пользователя
|
||||
cart_items = get_user_cart(db, user_id)
|
||||
|
||||
if not cart_items:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Корзина пуста"
|
||||
)
|
||||
|
||||
print(f"Используем все элементы корзины пользователя")
|
||||
cart_items = db.query(CartItem).filter(CartItem.user_id == user_id).all()
|
||||
|
||||
# Если используем элементы корзины
|
||||
if cart_items:
|
||||
print(f"Найдено {len(cart_items)} элементов корзины")
|
||||
# Создаем элементы заказа из элементов корзины
|
||||
for cart_item in cart_items:
|
||||
# Получаем вариант и продукт
|
||||
# Получаем вариант товара и его цену
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first()
|
||||
if not variant:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {cart_item.variant_id} не найден"
|
||||
detail=f"Вариант товара с ID {cart_item.variant_id} не найден"
|
||||
)
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден"
|
||||
)
|
||||
|
||||
# Рассчитываем цену
|
||||
price = product.discount_price if product.discount_price else product.price
|
||||
price += variant.price_adjustment
|
||||
# Определяем цену (используем скидочную цену, если она есть)
|
||||
price = variant.discount_price if variant.discount_price else variant.price
|
||||
|
||||
# Создаем элемент заказа
|
||||
order_item = OrderItem(
|
||||
variant_id=cart_item.variant_id,
|
||||
order_id=new_order.id,
|
||||
variant_id=variant.id,
|
||||
quantity=cart_item.quantity,
|
||||
price=price
|
||||
)
|
||||
order_items.append(order_item)
|
||||
db.add(order_item)
|
||||
|
||||
# Обновляем общую сумму
|
||||
total_amount += price * cart_item.quantity
|
||||
|
||||
# Создаем заказ
|
||||
db_order = Order(
|
||||
user_id=user_id,
|
||||
status=OrderStatus.PENDING,
|
||||
total_amount=total_amount,
|
||||
shipping_address_id=order.shipping_address_id,
|
||||
payment_method=order.payment_method,
|
||||
notes=order.notes
|
||||
)
|
||||
# Обновляем общую сумму заказа
|
||||
new_order.total_amount += price * cart_item.quantity
|
||||
|
||||
# Удаляем элемент из корзины
|
||||
db.delete(cart_item)
|
||||
|
||||
try:
|
||||
# Добавляем заказ
|
||||
db.add(db_order)
|
||||
db.flush() # Получаем ID заказа, не фиксируя транзакцию
|
||||
|
||||
# Добавляем элементы заказа
|
||||
for item in order_items:
|
||||
item.order_id = db_order.id
|
||||
db.add(item)
|
||||
|
||||
# Очищаем корзину, если заказ создан из корзины
|
||||
if not order.items:
|
||||
db.query(CartItem).filter(CartItem.user_id == user_id).delete()
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_order)
|
||||
return db_order
|
||||
except IntegrityError:
|
||||
db.refresh(new_order)
|
||||
print(f"Заказ успешно создан: {new_order.id}, общая сумма: {new_order.total_amount}")
|
||||
return new_order
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при создании заказа"
|
||||
detail=f"Ошибка при создании заказа: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -437,14 +397,14 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
).first()
|
||||
|
||||
# Рассчитываем цену
|
||||
price = product.discount_price if product.discount_price else product.price
|
||||
price += variant.price_adjustment
|
||||
price = variant.discount_price if variant.discount_price else variant.price
|
||||
|
||||
# Формируем результат
|
||||
result.append({
|
||||
"id": item.id,
|
||||
"user_id": item.user_id,
|
||||
"variant_id": item.variant_id,
|
||||
"slug": product.slug,
|
||||
"quantity": item.quantity,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
@ -453,7 +413,6 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
"product_price": price,
|
||||
"product_image": image.image_url if image else None,
|
||||
"variant_name": variant.name,
|
||||
"variant_price_adjustment": variant.price_adjustment,
|
||||
"total_price": price * item.quantity
|
||||
})
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ def get_user_addresses(db: Session, user_id: int) -> List[UserAddress]:
|
||||
return db.query(UserAddress).filter(UserAddress.user_id == user_id).all()
|
||||
|
||||
|
||||
def create_address(db: Session, address: AddressCreate, user_id: int) -> UserAddress:
|
||||
def create_address(db: Session, address: AddressCreate, user_id: int):
|
||||
# Если новый адрес помечен как дефолтный, сбрасываем дефолтный статус у других адресов пользователя
|
||||
if address.is_default:
|
||||
db.query(UserAddress).filter(
|
||||
@ -157,6 +157,8 @@ def create_address(db: Session, address: AddressCreate, user_id: int) -> UserAdd
|
||||
db.add(db_address)
|
||||
db.commit()
|
||||
db.refresh(db_address)
|
||||
print(f"Адрес успешно создан: {db_address}")
|
||||
db_address.user_id = user_id
|
||||
return db_address
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
Binary file not shown.
@ -11,12 +11,21 @@ from app.models.user_models import User as UserModel
|
||||
cart_router = APIRouter(prefix="/cart", tags=["Корзина"])
|
||||
|
||||
@cart_router.post("/items", response_model=Dict[str, Any])
|
||||
async def add_to_cart_endpoint(cart_item: CartItemCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def add_to_cart_endpoint(
|
||||
cart_item: CartItemCreate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.add_to_cart(db, current_user.id, cart_item)
|
||||
|
||||
|
||||
@cart_router.put("/items/{cart_item_id}", response_model=Dict[str, Any])
|
||||
async def update_cart_item_endpoint(cart_item_id: int, cart_item: CartItemUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)):
|
||||
async def update_cart_item_endpoint(
|
||||
cart_item_id: int,
|
||||
cart_item: CartItemUpdate,
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.update_cart_item(db, current_user.id, cart_item_id, cart_item)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -34,7 +34,6 @@ class CartItemWithProduct(CartItem):
|
||||
product_price: float
|
||||
product_image: Optional[str] = None
|
||||
variant_name: str
|
||||
variant_price_adjustment: float
|
||||
total_price: float
|
||||
|
||||
|
||||
@ -112,7 +111,6 @@ class CartItemWithDetails(BaseModel):
|
||||
product_price: float
|
||||
product_image: Optional[str] = None
|
||||
variant_name: str
|
||||
variant_price_adjustment: float
|
||||
total_price: float
|
||||
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ class Address(AddressBase):
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Базовые схемы для пользователя
|
||||
@ -81,7 +81,7 @@ class User(UserBase):
|
||||
addresses: List[Address] = []
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -66,19 +66,28 @@ 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)
|
||||
print(f"Создание заказа для пользователя {user_id}: {order}")
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
event_type="order_created",
|
||||
additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(new_order)
|
||||
return {"order": order_schema}
|
||||
try:
|
||||
new_order = order_repo.create_order(db, order, user_id)
|
||||
|
||||
print(f"Заказ успешно создан: {new_order.id}")
|
||||
|
||||
# Логируем событие создания заказа
|
||||
log_data = AnalyticsLogCreate(
|
||||
user_id=user_id,
|
||||
event_type="order_created",
|
||||
additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount}
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
# Получаем заказ с деталями
|
||||
order_details = order_repo.get_order_with_details(db, new_order.id)
|
||||
|
||||
return {"order": order_details}
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании заказа: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
|
||||
@ -63,7 +63,6 @@ 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:
|
||||
@ -72,16 +71,7 @@ def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
# Получаем адреса пользователя
|
||||
addresses = user_repo.get_user_addresses(db, user_id)
|
||||
|
||||
# Получаем заказы пользователя
|
||||
orders = order_repo.get_user_orders(db, user_id)
|
||||
|
||||
# Получаем отзывы пользователя
|
||||
reviews = review_repo.get_user_reviews(db, user_id)
|
||||
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
# Преобразуем объект SQLAlchemy в словарь, а затем в схему Pydantic
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
@ -94,28 +84,27 @@ def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
"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_schema,
|
||||
"addresses": addresses_schema,
|
||||
"orders": orders, # Заказы обрабатываются отдельно в сервисе заказов
|
||||
"reviews": reviews_schema
|
||||
}
|
||||
# Преобразуем адреса пользователя
|
||||
if user.addresses:
|
||||
for address in user.addresses:
|
||||
address_dict = {
|
||||
"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
|
||||
}
|
||||
user_dict["addresses"].append(address_dict)
|
||||
|
||||
user_schema = UserSchema.model_validate(user_dict)
|
||||
return {"user": user_schema}
|
||||
|
||||
|
||||
def update_user_profile(db: Session, user_id: int, user_data: UserUpdate) -> Dict[str, Any]:
|
||||
@ -131,8 +120,24 @@ def add_user_address(db: Session, user_id: int, address: AddressCreate) -> Dict[
|
||||
from app.schemas.user_schemas import Address as AddressSchema
|
||||
|
||||
new_address = user_repo.create_address(db, address, user_id)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
address_schema = AddressSchema.model_validate(new_address)
|
||||
print(f"Адрес успешно создан: {new_address}")
|
||||
|
||||
# Преобразуем объект SQLAlchemy в словарь, а затем в схему Pydantic
|
||||
address_dict = {
|
||||
"id": new_address.id,
|
||||
"user_id": new_address.user_id,
|
||||
"address_line1": new_address.address_line1,
|
||||
"address_line2": new_address.address_line2,
|
||||
"city": new_address.city,
|
||||
"state": new_address.state,
|
||||
"postal_code": new_address.postal_code,
|
||||
"country": new_address.country,
|
||||
"is_default": new_address.is_default,
|
||||
"created_at": new_address.created_at,
|
||||
"updated_at": new_address.updated_at
|
||||
}
|
||||
|
||||
address_schema = AddressSchema.model_validate(address_dict)
|
||||
return {"address": address_schema}
|
||||
|
||||
|
||||
@ -140,8 +145,23 @@ def update_user_address(db: Session, user_id: int, address_id: int, address: Add
|
||||
from app.schemas.user_schemas import Address as AddressSchema
|
||||
|
||||
updated_address = user_repo.update_address(db, address_id, address, user_id)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
address_schema = AddressSchema.model_validate(updated_address)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в словарь, а затем в схему Pydantic
|
||||
address_dict = {
|
||||
"id": updated_address.id,
|
||||
"user_id": updated_address.user_id,
|
||||
"address_line1": updated_address.address_line1,
|
||||
"address_line2": updated_address.address_line2,
|
||||
"city": updated_address.city,
|
||||
"state": updated_address.state,
|
||||
"postal_code": updated_address.postal_code,
|
||||
"country": updated_address.country,
|
||||
"is_default": updated_address.is_default,
|
||||
"created_at": updated_address.created_at,
|
||||
"updated_at": updated_address.updated_at
|
||||
}
|
||||
|
||||
address_schema = AddressSchema.model_validate(address_dict)
|
||||
return {"address": address_schema}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
92
frontend/components/AddToCartButton.tsx
Normal file
92
frontend/components/AddToCartButton.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ShoppingCart, Check, AlertCircle } from 'lucide-react';
|
||||
import cartService from '../services/cart';
|
||||
import authService from '../services/auth';
|
||||
|
||||
interface AddToCartButtonProps {
|
||||
variantId: number;
|
||||
quantity?: number;
|
||||
className?: string;
|
||||
onAddToCart?: () => void;
|
||||
}
|
||||
|
||||
export default function AddToCartButton({ variantId, quantity = 1, className = '', onAddToCart }: AddToCartButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Сохраняем текущий URL для редиректа после авторизации
|
||||
const currentPath = router.asPath;
|
||||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await cartService.addToCart({
|
||||
variant_id: variantId,
|
||||
quantity: quantity
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Вызываем колбэк, если он передан
|
||||
if (onAddToCart) {
|
||||
onAddToCart();
|
||||
}
|
||||
|
||||
// Сбрасываем состояние успеха через 2 секунды
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении в корзину:', err);
|
||||
setError('Не удалось добавить товар в корзину');
|
||||
|
||||
// Сбрасываем состояние ошибки через 3 секунды
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
}, 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={loading || variantId === 0}
|
||||
className={`flex items-center justify-center px-6 py-3 bg-black text-white rounded-md hover:bg-gray-800 transition-colors ${loading ? 'opacity-70 cursor-not-allowed' : ''} ${variantId === 0 ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : success ? (
|
||||
<>
|
||||
<Check className="w-5 h-5 mr-2" />
|
||||
Добавлено
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
{variantId === 0 ? 'Нет в наличии' : 'В корзину'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-red-100 text-red-700 p-2 rounded-md text-sm flex items-center">
|
||||
<AlertCircle className="w-4 h-4 mr-1 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Facebook, Instagram, Twitter, Youtube } from "lucide-react";
|
||||
import { Facebook, Instagram, Twitter, Youtube, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Footer() {
|
||||
// Состояния для отображения/скрытия разделов на мобильных устройствах
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [shopOpen, setShopOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#2B5F47] text-white py-12 border-t border-[#63823B]">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium mb-4">Помощь</h4>
|
||||
<ul className="space-y-2">
|
||||
<footer className="bg-[#2B5F47] text-white py-8 md:py-12 border-t border-[#63823B]">
|
||||
<div className="container mx-auto px-4 md:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-8">
|
||||
{/* Помощь - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setHelpOpen(!helpOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">Помощь</h4>
|
||||
<div className="md:hidden">
|
||||
{helpOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${helpOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/contact" className="text-sm hover:text-[#E2E2C1] transition-colors">Связаться с нами</Link></li>
|
||||
<li><Link href="/faq" className="text-sm hover:text-[#E2E2C1] transition-colors">Часто задаваемые вопросы</Link></li>
|
||||
<li><Link href="/shipping" className="text-sm hover:text-[#E2E2C1] transition-colors">Доставка и возврат</Link></li>
|
||||
@ -17,9 +32,18 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-medium mb-4">Магазин</h4>
|
||||
<ul className="space-y-2">
|
||||
{/* Магазин - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setShopOpen(!shopOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">Магазин</h4>
|
||||
<div className="md:hidden">
|
||||
{shopOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${shopOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/women" className="text-sm hover:text-[#E2E2C1] transition-colors">Женщинам</Link></li>
|
||||
<li><Link href="/men" className="text-sm hover:text-[#E2E2C1] transition-colors">Мужчинам</Link></li>
|
||||
<li><Link href="/accessories" className="text-sm hover:text-[#E2E2C1] transition-colors">Аксессуары</Link></li>
|
||||
@ -28,9 +52,18 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-medium mb-4">О компании</h4>
|
||||
<ul className="space-y-2">
|
||||
{/* О компании - с аккордеоном на мобильных */}
|
||||
<div className="border-b border-[#63823B]/30 pb-4 md:border-b-0 md:pb-0">
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 cursor-pointer md:cursor-default"
|
||||
onClick={() => setAboutOpen(!aboutOpen)}
|
||||
>
|
||||
<h4 className="text-lg font-medium">О компании</h4>
|
||||
<div className="md:hidden">
|
||||
{aboutOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={`space-y-2 overflow-hidden transition-all duration-300 ${aboutOpen ? 'max-h-60' : 'max-h-0 md:max-h-60'}`}>
|
||||
<li><Link href="/about" className="text-sm hover:text-[#E2E2C1] transition-colors">О нас</Link></li>
|
||||
<li><Link href="/careers" className="text-sm hover:text-[#E2E2C1] transition-colors">Карьера</Link></li>
|
||||
<li><Link href="/sustainability" className="text-sm hover:text-[#E2E2C1] transition-colors">Устойчивое развитие</Link></li>
|
||||
@ -39,7 +72,8 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Подписка - всегда видима */}
|
||||
<div className="mt-6 md:mt-0">
|
||||
<h4 className="text-lg font-medium mb-4">Подписаться</h4>
|
||||
<p className="text-sm mb-4">Подпишитесь на нашу рассылку, чтобы получать новости о новых коллекциях и эксклюзивных предложениях.</p>
|
||||
<div className="flex mb-6">
|
||||
@ -70,8 +104,8 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#63823B] mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-xs text-[#E2E2C1]/80 mb-4 md:mb-0">© {new Date().getFullYear()} Brand Store. Все права защищены.</p>
|
||||
<div className="flex space-x-4">
|
||||
<p className="text-xs text-[#E2E2C1]/80 mb-4 md:mb-0 text-center md:text-left">© {new Date().getFullYear()} Brand Store. Все права защищены.</p>
|
||||
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-4 items-center">
|
||||
<Link href="/privacy" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Политика конфиденциальности</Link>
|
||||
<Link href="/terms" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Условия использования</Link>
|
||||
<Link href="/cookies" className="text-xs text-[#E2E2C1]/80 hover:text-[#E2E2C1] transition-colors">Политика использования файлов cookie</Link>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft, LogOut } from "lucide-react";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft, LogOut, Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import authService from "../services/auth";
|
||||
import cartService from "../services/cart";
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
@ -14,12 +15,45 @@ export default function Header() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// Состояние для отображения выпадающего меню пользователя
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
// Состояние для отображения мобильного меню
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
// Состояние для хранения количества товаров в корзине
|
||||
const [cartItemsCount, setCartItemsCount] = useState(0);
|
||||
|
||||
// Эффект для проверки аутентификации при загрузке компонента
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(authService.isAuthenticated());
|
||||
}, []);
|
||||
|
||||
// Эффект для загрузки количества товаров в корзине
|
||||
useEffect(() => {
|
||||
const fetchCartItemsCount = async () => {
|
||||
if (authService.isAuthenticated()) {
|
||||
try {
|
||||
const cart = await cartService.getCart();
|
||||
setCartItemsCount(cart.items_count);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке корзины:', error);
|
||||
setCartItemsCount(0);
|
||||
}
|
||||
} else {
|
||||
setCartItemsCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCartItemsCount();
|
||||
|
||||
// Обновляем количество товаров в корзине при изменении маршрута
|
||||
const handleRouteChange = () => {
|
||||
fetchCartItemsCount();
|
||||
};
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [isAuthenticated, router.events]);
|
||||
|
||||
// Эффект для отслеживания прокрутки
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@ -40,6 +74,7 @@ export default function Header() {
|
||||
authService.logout();
|
||||
setIsAuthenticated(false);
|
||||
setShowUserMenu(false);
|
||||
setCartItemsCount(0);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
@ -58,20 +93,46 @@ export default function Header() {
|
||||
setShowUserMenu(!showUserMenu);
|
||||
};
|
||||
|
||||
// Функция для переключения мобильного меню
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
};
|
||||
|
||||
// Закрыть мобильное меню при переходе на другую страницу
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [router.pathname]);
|
||||
|
||||
// Закрыть меню пользователя при клике вне его
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (showUserMenu && !target.closest('.user-menu-container')) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<header className="fixed w-full z-50 transition-all duration-300 bg-white shadow-sm">
|
||||
<header className={`fixed w-full z-50 transition-all duration-300 bg-white ${scrolled ? 'shadow-md' : 'shadow-sm'}`}>
|
||||
<nav className="py-4 transition-all duration-300 text-black">
|
||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* {isDetailPage && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center text-sm font-medium hover:opacity-70 transition-opacity mr-4"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
)} */}
|
||||
{/* Мобильная кнопка меню */}
|
||||
<button
|
||||
className="lg:hidden flex items-center justify-center"
|
||||
onClick={toggleMobileMenu}
|
||||
aria-label="Меню"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* Десктопное меню */}
|
||||
<div className="hidden lg:flex items-center space-x-6">
|
||||
<Link href="/category" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Каталог
|
||||
</Link>
|
||||
@ -84,10 +145,14 @@ export default function Header() {
|
||||
<Link href="/new-arrivals" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Новинки
|
||||
</Link>
|
||||
<Link href="/order-tracking" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Отследить заказ
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="absolute left-1/2 transform -translate-x-1/2">
|
||||
<div className="relative h-10 w-32">
|
||||
{/* Логотип - центрирован на десктопе, слева на мобильных */}
|
||||
<Link href="/" className="lg:absolute lg:left-1/2 lg:transform lg:-translate-x-1/2">
|
||||
<div className="relative h-8 w-24 md:h-10 md:w-32">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
@ -98,7 +163,8 @@ export default function Header() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center space-x-5">
|
||||
{/* Иконки справа */}
|
||||
<div className="flex items-center space-x-3 md:space-x-5">
|
||||
<Link href="/favorites" className="relative hover:opacity-70 transition-opacity">
|
||||
<Heart 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">
|
||||
@ -106,7 +172,7 @@ export default function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative user-menu-container">
|
||||
<button
|
||||
onClick={toggleUserMenu}
|
||||
className="hover:opacity-70 transition-opacity focus:outline-none"
|
||||
@ -155,12 +221,67 @@ export default function Header() {
|
||||
<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">
|
||||
0
|
||||
{cartItemsCount}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Мобильное меню */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="lg:hidden bg-white border-t border-gray-100 shadow-md"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link
|
||||
href="/category"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Каталог
|
||||
</Link>
|
||||
<Link
|
||||
href="/all-products"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Все товары
|
||||
</Link>
|
||||
<Link
|
||||
href="/collections"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Коллекции
|
||||
</Link>
|
||||
<Link
|
||||
href="/new-arrivals"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Новинки
|
||||
</Link>
|
||||
<Link
|
||||
href="/order-tracking"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100"
|
||||
>
|
||||
Отследить заказ
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className="text-sm font-medium py-2 hover:opacity-70 transition-opacity border-b border-gray-100 flex items-center"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Поиск
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -204,8 +204,21 @@ const ProductVariantManager: React.FC<ProductVariantManagerProps> = ({
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.discount_price || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{variant.discount_price}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{variant.price}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
variant.price
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
|
||||
@ -52,10 +52,17 @@ export default function OrdersPage() {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const ordersData = await orderService.getUserOrders();
|
||||
setOrders(ordersData);
|
||||
console.log('Полученные заказы:', ordersData);
|
||||
|
||||
if (Array.isArray(ordersData)) {
|
||||
setOrders(ordersData);
|
||||
} else {
|
||||
console.error('Ошибка: полученные данные не являются массивом:', ordersData);
|
||||
setError('Не удалось загрузить заказы: неверный формат данных');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы');
|
||||
setError('Не удалось загрузить заказы: ' + (err.message || 'неизвестная ошибка'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -148,16 +155,19 @@ export default function OrdersPage() {
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{orders.map((order) => {
|
||||
const statusInfo = getOrderStatusInfo(order.status);
|
||||
const statusInfo = getOrderStatusInfo(order.status || 'pending');
|
||||
const orderId = order.id || 'unknown';
|
||||
|
||||
return (
|
||||
<div key={order.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div key={orderId} 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="font-medium ml-1">{orderId}</span>
|
||||
<span className="text-sm text-gray-500 ml-4">от</span>
|
||||
<span className="ml-1">{formatDate(order.created_at)}</span>
|
||||
<span className="ml-1">
|
||||
{order.created_at ? formatDate(order.created_at) : 'Дата не указана'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`flex items-center ${statusInfo.color}`}>
|
||||
{statusInfo.icon}
|
||||
@ -167,42 +177,60 @@ export default function OrdersPage() {
|
||||
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
{order.items && Array.isArray(order.items) ? (
|
||||
order.items.map((item, index) => (
|
||||
<div key={item.id || `item-${index}`} 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 && item.product.image ? (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name || 'Товар'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Package className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-sm font-medium">{item.product ? 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="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}
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{typeof item.price === 'number'
|
||||
? `${item.price.toLocaleString('ru-RU')} ₽`
|
||||
: 'Цена не указана'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{item.price.toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-3">
|
||||
<p className="text-gray-500">Информация о товарах недоступна</p>
|
||||
</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>
|
||||
<p className="text-lg font-bold">
|
||||
{typeof order.total_amount === 'number'
|
||||
? `${order.total_amount.toLocaleString('ru-RU')} ₽`
|
||||
: 'Сумма не указана'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/account/orders/${order.id}`}
|
||||
href={`/account/orders/${orderId}`}
|
||||
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"
|
||||
>
|
||||
Подробнее
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { User, Package, Heart, LogOut, Home, MapPin, ArrowLeft, ExternalLink, Clock, CheckCircle, XCircle, Truck, CreditCard } from 'lucide-react';
|
||||
import authService from '../../../services/auth';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
import { orderService, Order, Address } from '../../../services/orders';
|
||||
|
||||
// Вспомогательная функция для форматирования даты
|
||||
const formatDate = (dateString: string) => {
|
||||
@ -197,7 +197,22 @@ export default function OrderDetailsPage() {
|
||||
<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>
|
||||
{typeof order.shipping_address === 'string' ? (
|
||||
<p className="text-gray-600 whitespace-pre-line">{order.shipping_address}</p>
|
||||
) : (
|
||||
<p className="text-gray-600 whitespace-pre-line">
|
||||
{order.shipping_address && typeof order.shipping_address === 'object' ? (
|
||||
<>
|
||||
{(order.shipping_address as Address).address_line1}<br />
|
||||
{(order.shipping_address as Address).address_line2 && <>{(order.shipping_address as Address).address_line2}<br /></>}
|
||||
{(order.shipping_address as Address).city}, {(order.shipping_address as Address).state}, {(order.shipping_address as Address).postal_code}<br />
|
||||
{(order.shipping_address as Address).country}
|
||||
</>
|
||||
) : (
|
||||
'Адрес не указан'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-4">
|
||||
@ -226,25 +241,29 @@ export default function OrderDetailsPage() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="p-6 flex items-start">
|
||||
{order.items && order.items.map((item) => (
|
||||
<div key={item.id || `item-${Math.random()}`} 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 && (
|
||||
{item.product && item.product.image ? (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name}
|
||||
alt={item.product.name || 'Товар'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium">{item.product.name}</h3>
|
||||
<h3 className="text-lg font-medium">{item.product ? 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>
|
||||
<p className="text-gray-500">Количество: {item.quantity || 1}</p>
|
||||
<p className="font-medium">{(item.price || 0).toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -254,7 +273,7 @@ export default function OrderDetailsPage() {
|
||||
<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>
|
||||
<span className="text-xl font-bold">{(order.total_amount || 0).toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
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 { ArrowLeft, Package, Truck, CreditCard, Calendar, User, MapPin, Check, X, Edit, Save } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { orderService, Order, OrderUpdate } from '../../../services/orders';
|
||||
import { orderService, Order, OrderUpdate, Address } from '../../../services/orders';
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
@ -61,6 +61,10 @@ export default function OrderDetailsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState('');
|
||||
const [editingTracking, setEditingTracking] = useState(false);
|
||||
const [trackingNumber, setTrackingNumber] = useState('');
|
||||
const [editingShipping, setEditingShipping] = useState(false);
|
||||
const [shippingMethod, setShippingMethod] = useState('');
|
||||
|
||||
// Загрузка данных заказа
|
||||
useEffect(() => {
|
||||
@ -135,6 +139,60 @@ export default function OrderDetailsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения трекинг-номера
|
||||
const handleSaveTracking = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, {
|
||||
tracking_number: trackingNumber
|
||||
});
|
||||
setOrder(updatedOrder);
|
||||
setEditingTracking(false);
|
||||
setUpdateStatus('Трекинг-номер успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении трекинг-номера:', err);
|
||||
setUpdateStatus('Не удалось обновить трекинг-номер. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения способа доставки
|
||||
const handleSaveShipping = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, {
|
||||
shipping_method: shippingMethod
|
||||
});
|
||||
setOrder(updatedOrder);
|
||||
setEditingShipping(false);
|
||||
setUpdateStatus('Способ доставки успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении способа доставки:', err);
|
||||
setUpdateStatus('Не удалось обновить способ доставки. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
@ -241,14 +299,36 @@ export default function OrderDetailsPage() {
|
||||
<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>
|
||||
{order.user_email || order.user_name ? (
|
||||
<div>
|
||||
{order.user_name && <p className="text-sm font-medium">{order.user_name}</p>}
|
||||
{order.user_email && <p className="text-sm text-gray-500">{order.user_email}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
{typeof order.shipping_address === 'string' ? (
|
||||
<p className="text-sm font-medium">{order.shipping_address}</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium">
|
||||
{order.shipping_address && typeof order.shipping_address === 'object' ? (
|
||||
<>
|
||||
{(order.shipping_address as Address).address_line1}<br />
|
||||
{(order.shipping_address as Address).address_line2 && <>{(order.shipping_address as Address).address_line2}<br /></>}
|
||||
{(order.shipping_address as Address).city}, {(order.shipping_address as Address).state}, {(order.shipping_address as Address).postal_code}<br />
|
||||
{(order.shipping_address as Address).country}
|
||||
</>
|
||||
) : (
|
||||
'Адрес не указан'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -266,7 +346,46 @@ export default function OrderDetailsPage() {
|
||||
<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>
|
||||
{editingShipping ? (
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={shippingMethod}
|
||||
onChange={(e) => setShippingMethod(e.target.value)}
|
||||
className="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Введите способ доставки"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveShipping}
|
||||
disabled={updating}
|
||||
className="ml-2 p-1 text-indigo-600 hover:text-indigo-900"
|
||||
title="Сохранить"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingShipping(false)}
|
||||
className="ml-1 p-1 text-gray-500 hover:text-gray-700"
|
||||
title="Отменить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium">{order.shipping_method || 'Не указан'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShippingMethod(order.shipping_method || '');
|
||||
setEditingShipping(true);
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@ -301,19 +420,27 @@ export default function OrderDetailsPage() {
|
||||
{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 font-medium text-gray-900">
|
||||
{item.product_name || (item.product && item.product.name) || 'Товар'}
|
||||
</div>
|
||||
{(item.variant_size || item.variant_color || item.variant_name) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.variant_name && `Вариант: ${item.variant_name}`}
|
||||
{item.variant_name && (item.variant_size || item.variant_color) && ', '}
|
||||
{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">
|
||||
{item.product_sku || (item.product && 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>
|
||||
<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>
|
||||
@ -364,7 +491,7 @@ export default function OrderDetailsPage() {
|
||||
<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>
|
||||
<span className="text-sm font-medium">{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Доставка</span>
|
||||
@ -372,11 +499,59 @@ export default function OrderDetailsPage() {
|
||||
</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>
|
||||
<span className="text-base font-bold">{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Трекинг-номер */}
|
||||
<div className="flex items-center mt-4">
|
||||
<Truck className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Трекинг-номер</p>
|
||||
{editingTracking ? (
|
||||
<div className="mt-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
className="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Введите трекинг-номер"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveTracking}
|
||||
disabled={updating}
|
||||
className="ml-2 p-1 text-indigo-600 hover:text-indigo-900"
|
||||
title="Сохранить"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTracking(false)}
|
||||
className="ml-1 p-1 text-gray-500 hover:text-gray-700"
|
||||
title="Отменить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium">{order.tracking_number || 'Не указан'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTrackingNumber(order.tracking_number || '');
|
||||
setEditingTracking(true);
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -307,14 +307,20 @@ export default function OrdersPage() {
|
||||
<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}
|
||||
{order.user_email || order.user_name ? (
|
||||
<div>
|
||||
{order.user_name && <div className="font-medium">{order.user_name}</div>}
|
||||
{order.user_email && <div className="text-xs text-gray-500">{order.user_email}</div>}
|
||||
</div>
|
||||
) : (
|
||||
`Клиент #${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 font-medium text-gray-900">{formatCurrency(order.total_amount)}</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">
|
||||
|
||||
@ -351,8 +351,21 @@ const VariantManager = ({ variants, setVariants, productId }) => {
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.discount_price || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{variant.discount_price}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{variant.price}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
variant.price
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{variant.discount_price ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
|
||||
@ -352,11 +352,15 @@ export default function ProductsPage() {
|
||||
{variant.sku}
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">
|
||||
{`${variant.price.toLocaleString('ru-RU')} ₽`}
|
||||
{variant.discount_price && (
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{variant.discount_price.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
{variant.discount_price ? (
|
||||
<>
|
||||
{`${variant.discount_price.toLocaleString('ru-RU')} ₽`}
|
||||
<span className="ml-1 line-through text-gray-400">
|
||||
{`${variant.price.toLocaleString('ru-RU')} ₽`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
`${variant.price.toLocaleString('ru-RU')} ₽`
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-xs text-gray-500">-</td>
|
||||
|
||||
@ -300,11 +300,15 @@ export default function AllProductsPage() {
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
{product.variants && product.variants.length > 0 ? (
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{formatPrice(product.variants[0].price)} ₽
|
||||
{product.variants[0].discount_price && (
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{product.variants[0].discount_price ? (
|
||||
<>
|
||||
{formatPrice(product.variants[0].discount_price)} ₽
|
||||
</span>
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{formatPrice(product.variants[0].price)} ₽
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>{formatPrice(product.variants[0].price)} ₽</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
301
frontend/pages/cart.tsx
Normal file
301
frontend/pages/cart.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Trash2, Plus, Minus, ShoppingBag } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import cartService, { Cart, CartItem } from '../services/cart';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function CartPage() {
|
||||
const router = useRouter();
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка корзины при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchCart = async () => {
|
||||
try {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/cart');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const cartData = await cartService.getCart();
|
||||
setCart(cartData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке корзины:', err);
|
||||
setError('Не удалось загрузить корзину. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCart();
|
||||
}, [router]);
|
||||
|
||||
// Обработчик изменения количества товара
|
||||
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
|
||||
try {
|
||||
// Сохраняем текущий порядок элементов
|
||||
const currentItems = cart?.items || [];
|
||||
|
||||
await cartService.updateCartItem(itemId, { quantity: newQuantity });
|
||||
|
||||
// Обновляем корзину после изменения, но сохраняем порядок
|
||||
const updatedCart = await cartService.getCart();
|
||||
|
||||
// Сортируем элементы в том же порядке, что и были
|
||||
if (updatedCart && currentItems.length > 0) {
|
||||
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
||||
const sortedItems = updatedCart.items.sort((a, b) => {
|
||||
const indexA = currentItems.findIndex(item => item.id === a.id);
|
||||
const indexB = currentItems.findIndex(item => item.id === b.id);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
updatedCart.items = sortedItems;
|
||||
}
|
||||
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении количества:', err);
|
||||
setError('Не удалось обновить количество товара. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления товара из корзины
|
||||
const handleRemoveItem = async (itemId: number) => {
|
||||
try {
|
||||
// Сохраняем текущий порядок элементов
|
||||
const currentItems = cart?.items.filter(item => item.id !== itemId) || [];
|
||||
|
||||
await cartService.removeFromCart(itemId);
|
||||
|
||||
// Обновляем корзину после удаления, но сохраняем порядок
|
||||
const updatedCart = await cartService.getCart();
|
||||
|
||||
// Сортируем элементы в том же порядке, что и были
|
||||
if (updatedCart && currentItems.length > 0) {
|
||||
const sortedItems = updatedCart.items.sort((a, b) => {
|
||||
const indexA = currentItems.findIndex(item => item.id === a.id);
|
||||
const indexB = currentItems.findIndex(item => item.id === b.id);
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
updatedCart.items = sortedItems;
|
||||
}
|
||||
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар из корзины. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик очистки корзины
|
||||
const handleClearCart = async () => {
|
||||
try {
|
||||
await cartService.clearCart();
|
||||
// Обновляем корзину после очистки
|
||||
const updatedCart = await cartService.getCart();
|
||||
setCart(updatedCart);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при очистке корзины:', err);
|
||||
setError('Не удалось очистить корзину. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
// Переход к оформлению заказа
|
||||
const handleCheckout = () => {
|
||||
router.push('/checkout');
|
||||
};
|
||||
|
||||
// Функция для корректного отображения URL изображения
|
||||
const getImageUrl = (imageUrl: string | undefined): string => {
|
||||
if (!imageUrl) return '/placeholder-image.jpg';
|
||||
|
||||
// Проверяем, начинается ли URL с http или https
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Формируем базовый URL для изображений
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
|
||||
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
|
||||
|
||||
// Если URL начинается с /, добавляем базовый URL API
|
||||
if (imageUrl.startsWith('/')) {
|
||||
return `${apiBaseUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
// В остальных случаях добавляем базовый URL API и /
|
||||
return `${apiBaseUrl}/${imageUrl}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Корзина</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
) : cart && cart.items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Список товаров в корзине */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold">Товары в корзине ({cart.items_count})</h2>
|
||||
<button
|
||||
onClick={handleClearCart}
|
||||
className="text-sm text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
Очистить корзину
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="py-6 flex flex-col md:flex-row">
|
||||
{/* Изображение товара */}
|
||||
<div className="flex-shrink-0 w-full md:w-24 h-24 mb-4 md:mb-0 relative">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 rounded-md flex items-center justify-center">
|
||||
<ShoppingBag className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="md:ml-6 flex-grow">
|
||||
<div className="flex flex-col md:flex-row md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">
|
||||
<Link href={`/product/${item.slug}`} className="hover:text-gray-600 transition-colors">
|
||||
{item.product_name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Вариант: {item.variant_name}</p>
|
||||
</div>
|
||||
<div className="mt-2 md:mt-0 text-right">
|
||||
<p className="text-base font-medium">
|
||||
{item.total_price.toLocaleString('ru-RU')} ₽
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{item.product_price.toLocaleString('ru-RU')} ₽ за шт.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Управление количеством и удаление */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="flex items-center border border-gray-300 rounded-md">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
|
||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
disabled={item.quantity <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 py-1 text-center w-10">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
|
||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сводка заказа */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-24">
|
||||
<h2 className="text-lg font-semibold mb-6">Сводка заказа</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cart.items_count})</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Доставка</span>
|
||||
<span>Бесплатно</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 flex justify-between font-semibold">
|
||||
<span>Итого</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Оформить заказ
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link href="/all-products" className="text-center block text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Продолжить покупки
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="flex justify-center mb-6">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-600 mb-8">Добавьте товары в корзину, чтобы оформить заказ</p>
|
||||
<Link href="/all-products" className="bg-black text-white px-6 py-3 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
822
frontend/pages/checkout.tsx
Normal file
822
frontend/pages/checkout.tsx
Normal file
@ -0,0 +1,822 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ShoppingBag, CreditCard, Truck, Check, Plus, X } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import cartService, { Cart } from '../services/cart';
|
||||
import { orderService } from '../services/orders';
|
||||
import authService from '../services/auth';
|
||||
import { userService, Address, AddressCreate } from '../services/users';
|
||||
|
||||
// Типы для формы оформления заказа
|
||||
interface CheckoutForm {
|
||||
shipping_address_id: number;
|
||||
payment_method: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Типы для формы нового адреса
|
||||
interface AddressForm {
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const router = useRouter();
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [orderId, setOrderId] = useState<number | null>(null);
|
||||
const [showAddressForm, setShowAddressForm] = useState(false);
|
||||
const [addressFormSubmitting, setAddressFormSubmitting] = useState(false);
|
||||
const [addressFormError, setAddressFormError] = useState<string | null>(null);
|
||||
const [formValidated, setFormValidated] = useState(false);
|
||||
|
||||
// Состояние формы заказа
|
||||
const [form, setForm] = useState<CheckoutForm>({
|
||||
shipping_address_id: 0,
|
||||
payment_method: 'credit_card',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// Состояние формы нового адреса
|
||||
const [addressForm, setAddressForm] = useState<AddressForm>({
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
|
||||
// Проверка валидности формы
|
||||
useEffect(() => {
|
||||
// Форма валидна, если выбран адрес доставки и способ оплаты
|
||||
const isValid = form.shipping_address_id > 0 && !!form.payment_method;
|
||||
setFormValidated(isValid);
|
||||
}, [form.shipping_address_id, form.payment_method]);
|
||||
|
||||
// Загрузка корзины и адресов при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/checkout');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем корзину
|
||||
const cartData = await cartService.getCart();
|
||||
setCart(cartData);
|
||||
|
||||
// Если корзина пуста, перенаправляем на страницу корзины
|
||||
if (cartData.items.length === 0) {
|
||||
router.push('/cart');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Загружаем адреса пользователя
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses && userData.addresses.length > 0) {
|
||||
setAddresses(userData.addresses);
|
||||
|
||||
// Устанавливаем адрес по умолчанию, если он есть
|
||||
const defaultAddress = userData.addresses.find(addr => addr.is_default);
|
||||
if (defaultAddress) {
|
||||
setForm(prev => ({ ...prev, shipping_address_id: defaultAddress.id }));
|
||||
} else {
|
||||
setForm(prev => ({ ...prev, shipping_address_id: userData.addresses[0].id }));
|
||||
}
|
||||
} else {
|
||||
// Если у пользователя нет адресов, показываем форму добавления адреса
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
} catch (addressErr) {
|
||||
console.error('Ошибка при загрузке адресов:', addressErr);
|
||||
// Не показываем ошибку пользователю, просто предлагаем добавить адрес
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
// Обработчик изменения полей формы заказа
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Обработчик изменения полей формы адреса
|
||||
const handleAddressFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// Для чекбокса используем checked, для остальных полей - value
|
||||
if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox') {
|
||||
const checked = e.target.checked;
|
||||
setAddressForm(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
} else {
|
||||
setAddressForm(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отправки формы заказа
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formValidated) {
|
||||
setError('Пожалуйста, заполните все обязательные поля');
|
||||
// Если нет адресов, показываем форму добавления адреса
|
||||
if (addresses.length === 0) {
|
||||
setShowAddressForm(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что в корзине есть товары
|
||||
if (!cart || cart.items.length === 0) {
|
||||
setError('Ваша корзина пуста. Добавьте товары в корзину, чтобы оформить заказ.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Отправка заказа на сервер:', {
|
||||
shipping_address_id: form.shipping_address_id,
|
||||
payment_method: form.payment_method,
|
||||
notes: form.notes
|
||||
});
|
||||
|
||||
// Создаем заказ
|
||||
const orderResponse = await orderService.createOrder({
|
||||
shipping_address_id: form.shipping_address_id,
|
||||
payment_method: form.payment_method,
|
||||
notes: form.notes
|
||||
});
|
||||
|
||||
console.log('Получен ответ от сервера:', orderResponse);
|
||||
|
||||
// Извлекаем ID заказа из ответа
|
||||
let orderId = null;
|
||||
|
||||
// Проверяем разные форматы ответа
|
||||
const responseObj = orderResponse as any;
|
||||
if (responseObj && responseObj.id) {
|
||||
orderId = responseObj.id;
|
||||
} else if (responseObj && responseObj.order && responseObj.order.id) {
|
||||
orderId = responseObj.order.id;
|
||||
}
|
||||
|
||||
// Устанавливаем флаг успешного создания заказа
|
||||
setSuccess(true);
|
||||
setOrderId(orderId);
|
||||
|
||||
// Очищаем корзину после успешного оформления заказа
|
||||
try {
|
||||
await cartService.clearCart();
|
||||
} catch (clearErr) {
|
||||
console.error('Ошибка при очистке корзины:', clearErr);
|
||||
// Не показываем эту ошибку пользователю, так как заказ уже создан
|
||||
}
|
||||
|
||||
// Проверяем, что ID заказа существует и является числом
|
||||
if (orderId && !isNaN(orderId)) {
|
||||
// Перенаправляем на страницу успешного оформления заказа
|
||||
router.push(`/order-success?id=${orderId}`);
|
||||
} else {
|
||||
console.error('Ошибка: ID заказа отсутствует или некорректен', orderResponse);
|
||||
// Перенаправляем на страницу заказов без указания ID
|
||||
router.push('/account/orders');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при оформлении заказа:', err);
|
||||
|
||||
// Выводим подробную информацию об ошибке в консоль
|
||||
if (err.response) {
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
console.error('Данные ответа:', err.response.data);
|
||||
}
|
||||
|
||||
// Проверяем, есть ли в ответе сервера сообщение об ошибке
|
||||
let errorMessage = 'Не удалось оформить заказ. Пожалуйста, попробуйте позже.';
|
||||
if (err.response && err.response.data) {
|
||||
if (typeof err.response.data === 'string') {
|
||||
errorMessage = err.response.data;
|
||||
} else if (err.response.data.detail) {
|
||||
if (Array.isArray(err.response.data.detail)) {
|
||||
// Если detail - это массив ошибок валидации
|
||||
const validationErrors = err.response.data.detail.map(error =>
|
||||
`${error.loc.join('.')}: ${error.msg}`
|
||||
).join('; ');
|
||||
errorMessage = `Ошибка валидации: ${validationErrors}`;
|
||||
} else {
|
||||
errorMessage = err.response.data.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// Прокручиваем страницу вверх, чтобы показать сообщение об ошибке
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отправки формы нового адреса
|
||||
const handleAddressSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); // Предотвращаем стандартное поведение формы
|
||||
|
||||
// Валидация формы
|
||||
if (!addressForm.address_line1 || !addressForm.city || !addressForm.state || !addressForm.postal_code) {
|
||||
setAddressFormError('Пожалуйста, заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAddressFormSubmitting(true);
|
||||
setAddressFormError(null);
|
||||
|
||||
console.log('Отправка адреса на сервер:', addressForm);
|
||||
|
||||
// Создаем новый адрес
|
||||
const newAddress = await userService.addAddress(addressForm);
|
||||
|
||||
console.log('Получен ответ от сервера:', newAddress);
|
||||
|
||||
// Добавляем новый адрес в список и выбираем его
|
||||
setAddresses(prev => [...prev, newAddress]);
|
||||
setForm(prev => ({ ...prev, shipping_address_id: newAddress.id }));
|
||||
|
||||
// Скрываем форму добавления адреса
|
||||
setShowAddressForm(false);
|
||||
|
||||
// Сбрасываем форму
|
||||
setAddressForm({
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при добавлении адреса:', err);
|
||||
|
||||
// Выводим подробную информацию об ошибке в консоль
|
||||
if (err.response) {
|
||||
console.error('Ответ сервера:', err.response.status, err.response.data);
|
||||
}
|
||||
|
||||
// Проверяем, есть ли в ответе сервера сообщение об ошибке
|
||||
let errorMessage = 'Не удалось добавить адрес. Пожалуйста, попробуйте позже.';
|
||||
if (err.response && err.response.data) {
|
||||
if (typeof err.response.data === 'string') {
|
||||
errorMessage = err.response.data;
|
||||
} else if (err.response.data.detail) {
|
||||
if (Array.isArray(err.response.data.detail)) {
|
||||
// Если detail - это массив ошибок валидации
|
||||
const validationErrors = err.response.data.detail.map(error =>
|
||||
`${error.loc.join('.')}: ${error.msg}`
|
||||
).join('; ');
|
||||
errorMessage = `Ошибка валидации: ${validationErrors}`;
|
||||
} else {
|
||||
errorMessage = err.response.data.detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAddressFormError(errorMessage);
|
||||
} finally {
|
||||
setAddressFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование цены
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toLocaleString('ru-RU') + ' ₽';
|
||||
};
|
||||
|
||||
// Функция для корректного отображения URL изображения
|
||||
const getImageUrl = (imageUrl: string | undefined): string => {
|
||||
if (!imageUrl) return '/placeholder-image.jpg';
|
||||
|
||||
// Проверяем, начинается ли URL с http или https
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Формируем базовый URL для изображений
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api';
|
||||
const apiBaseUrl = baseUrl.replace(/\/api$/, ''); // Убираем '/api' в конце, если есть
|
||||
|
||||
// Если URL начинается с /, добавляем базовый URL API
|
||||
if (imageUrl.startsWith('/')) {
|
||||
return `${apiBaseUrl}${imageUrl}`;
|
||||
}
|
||||
|
||||
// В остальных случаях добавляем базовый URL API и /
|
||||
return `${apiBaseUrl}/${imageUrl}`;
|
||||
};
|
||||
|
||||
// Форматирование адреса для отображения
|
||||
const formatAddress = (address: Address): string => {
|
||||
let formattedAddress = address.address_line1;
|
||||
if (address.address_line2) {
|
||||
formattedAddress += `, ${address.address_line2}`;
|
||||
}
|
||||
return `${formattedAddress}, ${address.city}, ${address.state}, ${address.postal_code}, ${address.country}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Оформление заказа</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
) : error && !success ? (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
) : success ? (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-8 rounded mb-4 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-green-500 rounded-full p-2">
|
||||
<Check className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Заказ успешно оформлен!</h2>
|
||||
<p className="mb-4">Номер вашего заказа: {orderId}</p>
|
||||
<p>Вы будете перенаправлены на страницу заказа через несколько секунд...</p>
|
||||
</div>
|
||||
) : cart ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Форма оформления заказа */}
|
||||
<div className="lg:col-span-2">
|
||||
<form id="checkout-form" onSubmit={handleSubmit}>
|
||||
{/* Адрес доставки */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center">
|
||||
<Truck className="w-5 h-5 mr-2" />
|
||||
Адрес доставки
|
||||
</h2>
|
||||
|
||||
{!showAddressForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(true)}
|
||||
className="text-sm flex items-center text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Добавить новый адрес
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddressForm ? (
|
||||
<div className="border rounded-md p-4 mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium">Новый адрес доставки</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addressFormError && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-3 py-2 rounded mb-3 text-sm">
|
||||
{addressFormError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddressSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="address_line1" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Адрес (строка 1)*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
value={addressForm.address_line1}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Улица, дом"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address_line2" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Адрес (строка 2)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address_line2"
|
||||
name="address_line2"
|
||||
value={addressForm.address_line2}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Квартира, офис (необязательно)"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Город*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={addressForm.city}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Город"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="state" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Область/Регион*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={addressForm.state}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Область или регион"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Почтовый индекс*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={addressForm.postal_code}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Индекс"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Страна*
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={addressForm.country}
|
||||
onChange={handleAddressFormChange}
|
||||
placeholder="Страна"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_default"
|
||||
name="is_default"
|
||||
checked={addressForm.is_default}
|
||||
onChange={handleAddressFormChange}
|
||||
className="h-4 w-4 text-black focus:ring-black border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_default" className="ml-2 block text-sm text-gray-700">
|
||||
Использовать как адрес по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addressFormSubmitting}
|
||||
className="bg-black text-white px-4 py-2 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{addressFormSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Сохранение...
|
||||
</div>
|
||||
) : (
|
||||
'Сохранить адрес'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{addresses.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{addresses.map(address => (
|
||||
<div key={address.id} className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id={`address-${address.id}`}
|
||||
name="shipping_address_id"
|
||||
value={address.id}
|
||||
checked={form.shipping_address_id === address.id}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor={`address-${address.id}`} className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Адрес доставки</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{formatAddress(address)}</div>
|
||||
{address.is_default && (
|
||||
<div className="text-sm text-green-600 mt-1">Адрес по умолчанию</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showAddressForm ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-600 mb-4">У вас еще нет сохраненных адресов</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddressForm(true)}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
Добавить новый адрес
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Способ оплаты */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2" />
|
||||
Способ оплаты
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-card"
|
||||
name="payment_method"
|
||||
value="credit_card"
|
||||
checked={form.payment_method === 'credit_card'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-card" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Банковская карта</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата картой при получении</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-cash"
|
||||
name="payment_method"
|
||||
value="cash_on_delivery"
|
||||
checked={form.payment_method === 'cash_on_delivery'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-cash" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Наличные</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата наличными при получении</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-paypal"
|
||||
name="payment_method"
|
||||
value="paypal"
|
||||
checked={form.payment_method === 'paypal'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-paypal" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">PayPal</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата через PayPal</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
type="radio"
|
||||
id="payment-bank"
|
||||
name="payment_method"
|
||||
value="bank_transfer"
|
||||
checked={form.payment_method === 'bank_transfer'}
|
||||
onChange={handleChange}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<label htmlFor="payment-bank" className="flex-grow cursor-pointer">
|
||||
<div className="font-medium">Банковский перевод</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Оплата банковским переводом</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Комментарий к заказу */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-6">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Комментарий к заказу</h2>
|
||||
|
||||
<textarea
|
||||
name="notes"
|
||||
value={form.notes}
|
||||
onChange={handleChange}
|
||||
placeholder="Дополнительная информация к заказу"
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !formValidated}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Оформление...
|
||||
</div>
|
||||
) : (
|
||||
'Оформить заказ'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Сводка заказа */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-24">
|
||||
<h2 className="text-lg font-semibold mb-6">Ваш заказ</h2>
|
||||
|
||||
<div className="space-y-4 mb-6 max-h-80 overflow-y-auto">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.id} className="flex items-start py-2">
|
||||
<div className="flex-shrink-0 w-16 h-16 mr-4 relative">
|
||||
{item.product_image ? (
|
||||
<Image
|
||||
src={getImageUrl(item.product_image)}
|
||||
alt={item.product_name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 rounded-md flex items-center justify-center">
|
||||
<ShoppingBag className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-sm font-medium">{item.product_name}</h3>
|
||||
<p className="text-xs text-gray-500">Вариант: {item.variant_name}</p>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">Кол-во: {item.quantity}</span>
|
||||
<span className="text-sm font-medium">{item.total_price.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Товары ({cart.items_count})</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Доставка</span>
|
||||
<span>Бесплатно</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 flex justify-between font-semibold">
|
||||
<span>Итого</span>
|
||||
<span>{cart.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<button
|
||||
type="submit"
|
||||
form="checkout-form"
|
||||
disabled={submitting || !formValidated}
|
||||
className="w-full bg-black text-white py-3 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white mr-2"></div>
|
||||
Оформление...
|
||||
</div>
|
||||
) : (
|
||||
'Оформить заказ'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link href="/cart" className="text-center block text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Вернуться в корзину
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<div className="flex justify-center mb-6">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Ваша корзина пуста</h2>
|
||||
<p className="text-gray-600 mb-8">Добавьте товары в корзину, чтобы оформить заказ</p>
|
||||
<Link href="/all-products" className="bg-black text-white px-6 py-3 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/pages/order-success.tsx
Normal file
142
frontend/pages/order-success.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { orderService } from '../services/orders';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function OrderSuccessPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orderDetails, setOrderDetails] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/orders');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные заказа, если есть ID
|
||||
if (id && !isNaN(Number(id))) {
|
||||
const fetchOrderDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const orderId = Number(id);
|
||||
const orderData = await orderService.getOrderById(orderId);
|
||||
setOrderDetails(orderData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа. Пожалуйста, проверьте историю заказов в личном кабинете.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrderDetails();
|
||||
} else {
|
||||
setLoading(false);
|
||||
if (id && isNaN(Number(id))) {
|
||||
setError('Некорректный номер заказа. Пожалуйста, проверьте историю заказов в личном кабинете.');
|
||||
}
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900 mx-auto"></div>
|
||||
<h2 className="text-xl mt-4">
|
||||
Загрузка информации о заказе...
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<h1 className="text-2xl text-red-600 mb-4">
|
||||
Произошла ошибка
|
||||
</h1>
|
||||
<p className="mb-4">
|
||||
{error}
|
||||
</p>
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Перейти к истории заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 text-center">
|
||||
<h1 className="text-2xl text-red-600 mb-4">
|
||||
Информация о заказе не найдена
|
||||
</h1>
|
||||
<p className="mb-4">
|
||||
Не указан номер заказа. Пожалуйста, проверьте историю заказов в личном кабинете.
|
||||
</p>
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Перейти к истории заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-green-500 text-6xl mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
Заказ успешно оформлен!
|
||||
</h1>
|
||||
<h2 className="text-xl text-gray-600">
|
||||
Номер заказа: {id}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mb-4">
|
||||
Спасибо за ваш заказ! Мы отправили подтверждение на вашу электронную почту.
|
||||
</p>
|
||||
|
||||
<p className="mb-4">
|
||||
Вы можете отслеживать статус вашего заказа в личном кабинете.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<Link
|
||||
href="/account/orders"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Мои заказы
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="border border-gray-300 px-4 py-2 rounded hover:bg-gray-50"
|
||||
>
|
||||
Вернуться к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
frontend/pages/order-tracking.tsx
Normal file
291
frontend/pages/order-tracking.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Search, Package, Truck, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import { orderService, Order } from '../services/orders';
|
||||
|
||||
export default function OrderTrackingPage() {
|
||||
const router = useRouter();
|
||||
const { id: initialOrderId } = router.query;
|
||||
|
||||
const [orderNumber, setOrderNumber] = useState<string>(initialOrderId as string || '');
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
// Обработчик поиска заказа
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!orderNumber || orderNumber.trim() === '') {
|
||||
setError('Пожалуйста, введите номер заказа');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const orderData = await orderService.getOrderById(Number(orderNumber));
|
||||
setOrder(orderData);
|
||||
setSearched(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при поиске заказа:', err);
|
||||
setError('Заказ не найден или у вас нет доступа к нему');
|
||||
setOrder(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Получение информации о статусе заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
label: 'Ожидает оплаты',
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Ваш заказ создан и ожидает оплаты. После подтверждения оплаты мы начнем его обработку.'
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
label: 'В обработке',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Мы обрабатываем ваш заказ. Наши сотрудники собирают товары и готовят их к отправке.'
|
||||
};
|
||||
case 'shipped':
|
||||
return {
|
||||
label: 'Отправлен',
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
description: 'Ваш заказ отправлен и находится в пути. Скоро он будет доставлен по указанному адресу.'
|
||||
};
|
||||
case 'delivered':
|
||||
return {
|
||||
label: 'Доставлен',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
icon: <CheckCircle className="h-5 w-5" />,
|
||||
description: 'Ваш заказ успешно доставлен. Спасибо за покупку!'
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
label: 'Отменен',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
icon: <AlertCircle className="h-5 w-5" />,
|
||||
description: 'Заказ был отменен. Если у вас есть вопросы, пожалуйста, свяжитесь с нашей службой поддержки.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: 'Неизвестно',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
description: 'Статус заказа неизвестен. Пожалуйста, свяжитесь с нашей службой поддержки для получения дополнительной информации.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты
|
||||
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 renderOrderProgress = (status: string) => {
|
||||
const steps = [
|
||||
{ key: 'pending', label: 'Заказ создан', icon: <Package /> },
|
||||
{ key: 'processing', label: 'В обработке', icon: <Clock /> },
|
||||
{ key: 'shipped', label: 'Отправлен', icon: <Truck /> },
|
||||
{ key: 'delivered', label: 'Доставлен', icon: <CheckCircle /> }
|
||||
];
|
||||
|
||||
let currentStepIndex = steps.findIndex(step => step.key === status);
|
||||
|
||||
// Если статус "cancelled" или неизвестный, показываем только первый шаг
|
||||
if (status === 'cancelled' || currentStepIndex === -1) {
|
||||
currentStepIndex = 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="relative">
|
||||
{/* Линия прогресса */}
|
||||
<div className="absolute top-5 left-0 right-0 h-0.5 bg-gray-200">
|
||||
<div
|
||||
className="h-0.5 bg-green-500 transition-all duration-500"
|
||||
style={{ width: status === 'cancelled' ? '0%' : `${(currentStepIndex / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Шаги */}
|
||||
<div className="relative flex justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index <= currentStepIndex && status !== 'cancelled';
|
||||
const isCurrent = index === currentStepIndex && status !== 'cancelled';
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center z-10 ${
|
||||
isActive
|
||||
? 'bg-green-500 text-white'
|
||||
: status === 'cancelled' && index === 0
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
} ${isCurrent ? 'ring-4 ring-green-100' : ''}`}
|
||||
>
|
||||
{status === 'cancelled' && index === 0 ? <AlertCircle className="w-5 h-5" /> : step.icon}
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-2 text-center">{step.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow pt-24 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-8 text-center">Отслеживание заказа</h1>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-grow">
|
||||
<label htmlFor="orderNumber" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Номер заказа
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="orderNumber"
|
||||
value={orderNumber}
|
||||
onChange={(e) => setOrderNumber(e.target.value)}
|
||||
placeholder="Введите номер заказа"
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full md:w-auto bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Найти
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searched && order && (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Заказ №{order.id}</h2>
|
||||
<p className="text-gray-500 mt-1">Создан: {formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{order.status && (
|
||||
<div className={`px-3 py-1 rounded-full ${getOrderStatusInfo(order.status).bgColor} ${getOrderStatusInfo(order.status).color} flex items-center`}>
|
||||
{getOrderStatusInfo(order.status).icon}
|
||||
<span className="ml-1 text-sm font-medium">{getOrderStatusInfo(order.status).label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.status && renderOrderProgress(order.status)}
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-gray-700">{getOrderStatusInfo(order.status).description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">Информация о заказе</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600 mb-1">Сумма заказа:</p>
|
||||
<p className="font-medium">{order.total_amount.toLocaleString('ru-RU')} ₽</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-3 mb-1">Способ оплаты:</p>
|
||||
<p className="font-medium">{order.payment_method}</p>
|
||||
|
||||
<p className="text-sm text-gray-600 mt-3 mb-1">Количество товаров:</p>
|
||||
<p className="font-medium">{order.items.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">Адрес доставки</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="whitespace-pre-line">{order.shipping_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={`/account/orders/${order.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-500 flex items-center"
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Перейти к подробной информации о заказе
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searched && (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-600">Введите номер заказа, чтобы отследить его статус</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@ -6,9 +6,10 @@ import { GetServerSideProps } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import AddToCartButton from '../../components/AddToCartButton';
|
||||
import { productService, categoryService, Product as ApiProduct, Category, Collection, ProductVariant } from '../../services/catalog';
|
||||
import { Heart, ShoppingBag, ChevronLeft, ChevronRight, Check } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Heart, ShoppingBag, ChevronLeft, ChevronRight, Check, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence, PanInfo, useAnimation } from 'framer-motion';
|
||||
// Импортируем статические данные и функции из файла data/products.ts
|
||||
import { getProductBySlug as getStaticProductBySlug, getSimilarProducts as getStaticSimilarProducts, Product as StaticProduct } from '../../data/products';
|
||||
|
||||
@ -65,6 +66,25 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
||||
product.variants && product.variants.length > 0 ? product.variants[0] : null
|
||||
);
|
||||
// Состояние для отображения галереи на весь экран
|
||||
const [fullscreenGallery, setFullscreenGallery] = useState(false);
|
||||
// Состояние для отслеживания направления свайпа
|
||||
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
|
||||
// Состояние для отслеживания перетаскивания
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// Ref для контейнера изображения
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Контроллер анимации для свайпов
|
||||
const controls = useAnimation();
|
||||
|
||||
// Обновляем выбранный вариант при изменении product.variants
|
||||
useEffect(() => {
|
||||
if (product.variants && product.variants.length > 0) {
|
||||
setSelectedVariant(product.variants[0]);
|
||||
setCurrentImageIndex(0); // Сбрасываем индекс изображения
|
||||
setQuantity(1); // Сбрасываем количество
|
||||
}
|
||||
}, [product.id, product.variants]);
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
@ -78,18 +98,64 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
// Функция для переключения изображений
|
||||
const nextImage = () => {
|
||||
if (product.images && product.images.length > 0) {
|
||||
setSwipeDirection('left');
|
||||
setCurrentImageIndex((prev) => (prev === product.images!.length - 1 ? 0 : prev + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
if (product.images && product.images.length > 0) {
|
||||
setSwipeDirection('right');
|
||||
setCurrentImageIndex((prev) => (prev === 0 ? product.images!.length - 1 : prev - 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для обработки начала перетаскивания
|
||||
const handleDragStart = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
// Функция для обработки свайпов
|
||||
const handleDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
setIsDragging(false);
|
||||
const threshold = 50; // Минимальное расстояние свайпа для переключения
|
||||
|
||||
if (info.offset.x > threshold) {
|
||||
// Свайп вправо - предыдущее изображение
|
||||
prevImage();
|
||||
} else if (info.offset.x < -threshold) {
|
||||
// Свайп влево - следующее изображение
|
||||
nextImage();
|
||||
} else {
|
||||
// Если свайп недостаточно сильный, возвращаем изображение на место
|
||||
controls.start({ x: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для отслеживания перетаскивания
|
||||
const handleDrag = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
// Анимируем перетаскивание в реальном времени
|
||||
controls.set({ x: info.offset.x });
|
||||
};
|
||||
|
||||
// Функция для переключения полноэкранной галереи
|
||||
const toggleFullscreenGallery = () => {
|
||||
if (!isDragging) {
|
||||
setFullscreenGallery(!fullscreenGallery);
|
||||
}
|
||||
};
|
||||
|
||||
// Сбрасываем направление свайпа после анимации
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSwipeDirection(null);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentImageIndex]);
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = () => {
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
@ -109,13 +175,6 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
setSelectedVariant(variant);
|
||||
};
|
||||
|
||||
// Функция для добавления товара в корзину
|
||||
const addToCart = () => {
|
||||
// Здесь будет логика добавления товара в корзину
|
||||
const variantInfo = selectedVariant ? ` (вариант: ${selectedVariant.name})` : '';
|
||||
alert(`Товар "${product.name}"${variantInfo} добавлен в корзину в количестве ${quantity} шт.`);
|
||||
};
|
||||
|
||||
// Функция для форматирования цены
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
@ -137,72 +196,113 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
|
||||
<div className="mb-8">
|
||||
<Link href={product.category ? `/category/${product.category.slug}` : "/category"} className="text-gray-600 hover:text-black transition-colors">
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8 pt-24 md:pt-32">
|
||||
<div className="mb-4 md:mb-8">
|
||||
<Link href={product.category ? `/category/${product.category.slug}` : "/category"} className="text-gray-600 hover:text-black transition-colors text-sm md:text-base">
|
||||
← {product.category ? product.category.name : 'Назад к категориям'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-12">
|
||||
{/* Галерея изображений */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="relative aspect-[3/4] overflow-hidden rounded-xl cursor-pointer"
|
||||
onClick={toggleFullscreenGallery}
|
||||
>
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.7}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
animate={controls}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<Image
|
||||
src={product.images[currentImageIndex].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{
|
||||
x: swipeDirection === 'left' ? '100%' : swipeDirection === 'right' ? '-100%' : 0,
|
||||
opacity: 1
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
exit={{
|
||||
x: swipeDirection === 'left' ? '-100%' : swipeDirection === 'right' ? '100%' : 0,
|
||||
opacity: 1,
|
||||
zIndex: 0
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
opacity: { duration: 0 }
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
>
|
||||
<Image
|
||||
src={product.images[currentImageIndex].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Кнопки навигации по галерее */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-3 rounded-full transition-all z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prevImage();
|
||||
}}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-2 md:p-3 rounded-full transition-all z-10"
|
||||
aria-label="Предыдущее изображение"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-black" />
|
||||
<ChevronLeft className="w-4 h-4 md:w-5 md:h-5 text-black" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-3 rounded-full transition-all z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
nextImage();
|
||||
}}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-2 md:p-3 rounded-full transition-all z-10"
|
||||
aria-label="Следующее изображение"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-black" />
|
||||
<ChevronRight className="w-4 h-4 md:w-5 md:h-5 text-black" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Миниатюры изображений */}
|
||||
{/* Миниатюры изображений - скрыты на мобильных */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="flex mt-4 space-x-2 overflow-x-auto">
|
||||
<div className="hidden md:flex mt-4 space-x-2 overflow-x-auto">
|
||||
{product.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`relative w-20 h-20 rounded-md overflow-hidden ${
|
||||
className={`relative w-16 h-16 md:w-20 md:h-20 rounded-md overflow-hidden ${
|
||||
index === currentImageIndex ? 'ring-2 ring-black' : 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
aria-label={`Изображение ${index + 1}`}
|
||||
@ -212,6 +312,27 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Индикаторы слайдов для мобильных */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="flex justify-center mt-4 space-x-2 md:hidden">
|
||||
{product.images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === currentImageIndex ? 'bg-black' : 'bg-gray-300'
|
||||
}`}
|
||||
aria-label={`Перейти к изображению ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка о свайпе для мобильных */}
|
||||
<div className="mt-2 text-center text-xs text-gray-500 md:hidden">
|
||||
Свайпните для просмотра других фото
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
@ -220,7 +341,7 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl font-bold font-['Playfair_Display']"
|
||||
className="text-2xl md:text-3xl font-bold font-['Playfair_Display']"
|
||||
>
|
||||
{product.name}
|
||||
</motion.h1>
|
||||
@ -229,15 +350,19 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-2xl font-bold mt-4"
|
||||
className="text-xl md:text-2xl font-bold mt-4"
|
||||
>
|
||||
{currentPrice ? (
|
||||
<>
|
||||
{formatPrice(currentPrice)} ₽
|
||||
{currentDiscountPrice && (
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{currentDiscountPrice ? (
|
||||
<>
|
||||
{formatPrice(currentDiscountPrice)} ₽
|
||||
</span>
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
{formatPrice(currentPrice)} ₽
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
`${formatPrice(currentPrice)} ₽`
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@ -253,20 +378,20 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Варианты</h2>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Варианты</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants.map((variant) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleVariantSelect(variant)}
|
||||
className={`px-4 py-2 border rounded-md transition-colors ${
|
||||
className={`px-3 py-1.5 md:px-4 md:py-2 border rounded-md transition-colors text-sm md:text-base ${
|
||||
selectedVariant?.id === variant.id
|
||||
? 'border-black bg-black text-white'
|
||||
: 'border-gray-300 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{selectedVariant?.id === variant.id && <Check className="w-4 h-4 mr-1" />}
|
||||
{selectedVariant?.id === variant.id && <Check className="w-3 h-3 md:w-4 md:h-4 mr-1" />}
|
||||
{variant.name}
|
||||
{variant.stock <= 3 && variant.stock > 0 && (
|
||||
<span className="ml-2 text-xs text-red-500">Осталось {variant.stock} шт.</span>
|
||||
@ -287,30 +412,30 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Описание</h2>
|
||||
<p className="text-gray-600">{product.description}</p>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Описание</h2>
|
||||
<p className="text-sm md:text-base text-gray-600">{product.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mt-8"
|
||||
className="mt-6 md:mt-8"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Количество</h2>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Количество</h2>
|
||||
<div className="flex items-center border border-gray-300 rounded-md w-fit">
|
||||
<button
|
||||
onClick={decrementQuantity}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
className="px-3 py-1.5 md:px-4 md:py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Уменьшить количество"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-4 py-2 border-l border-r border-gray-300">{quantity}</span>
|
||||
<span className="px-3 py-1.5 md:px-4 md:py-2 border-l border-r border-gray-300 text-sm md:text-base">{quantity}</span>
|
||||
<button
|
||||
onClick={incrementQuantity}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
className="px-3 py-1.5 md:px-4 md:py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
+
|
||||
@ -322,27 +447,20 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="mt-8 flex flex-col sm:flex-row gap-4"
|
||||
className="mt-6 md:mt-8 flex flex-col sm:flex-row gap-3 md:gap-4"
|
||||
>
|
||||
<button
|
||||
onClick={addToCart}
|
||||
className={`flex items-center justify-center gap-2 bg-black text-white px-8 py-3 rounded-md transition-colors ${
|
||||
selectedVariant && selectedVariant.stock === 0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-800'
|
||||
<AddToCartButton
|
||||
variantId={selectedVariant && selectedVariant.stock > 0 ? selectedVariant.id : 0}
|
||||
quantity={quantity}
|
||||
className={`px-6 py-2.5 md:px-8 md:py-3 text-sm md:text-base ${
|
||||
selectedVariant && selectedVariant.stock === 0 ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
disabled={selectedVariant && selectedVariant.stock === 0}
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
{selectedVariant && selectedVariant.stock === 0 ? 'Нет в наличии' : 'Добавить в корзину'}
|
||||
</button>
|
||||
/>
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
className={`flex items-center justify-center gap-2 px-8 py-3 rounded-md border transition-colors ${
|
||||
isFavorite
|
||||
? 'border-red-500 text-red-500 hover:bg-red-50'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
className="flex items-center justify-center gap-2 border border-gray-300 px-6 py-2.5 md:px-8 md:py-3 rounded-md hover:bg-gray-50 transition-colors text-sm md:text-base"
|
||||
>
|
||||
<Heart className={isFavorite ? 'w-5 h-5 fill-red-500' : 'w-5 h-5'} />
|
||||
<Heart className={`w-5 h-5 ${isFavorite ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
{isFavorite ? 'В избранном' : 'В избранное'}
|
||||
</button>
|
||||
</motion.div>
|
||||
@ -351,23 +469,23 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-8 border-t border-gray-200 pt-8"
|
||||
className="mt-6 md:mt-8 border-t border-gray-200 pt-6 md:pt-8"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Категория</h2>
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Категория</h2>
|
||||
{product.category && (
|
||||
<Link href={`/category/${product.category.slug}`} className="text-gray-600 hover:text-black transition-colors">
|
||||
<Link href={`/category/${product.category.slug}`} className="text-sm md:text-base text-gray-600 hover:text-black transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{product.collection && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-lg font-medium mb-2">Коллекция</h2>
|
||||
<Link href={`/collections/${product.collection.slug}`} className="text-gray-600 hover:text-black transition-colors">
|
||||
<h2 className="text-base md:text-lg font-medium mb-2">Коллекция</h2>
|
||||
<Link href={`/collections/${product.collection.slug}`} className="text-sm md:text-base text-gray-600 hover:text-black transition-colors">
|
||||
{product.collection.name}
|
||||
</Link>
|
||||
{product.collection.description && (
|
||||
<p className="mt-2 text-sm text-gray-500">{product.collection.description}</p>
|
||||
<p className="mt-2 text-xs md:text-sm text-gray-500">{product.collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@ -377,9 +495,9 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
|
||||
{/* Похожие товары */}
|
||||
{similarProducts.length > 0 && (
|
||||
<section className="mt-16">
|
||||
<h2 className="text-2xl font-bold mb-8 font-['Playfair_Display']">Похожие товары</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
<section className="mt-12 md:mt-16">
|
||||
<h2 className="text-xl md:text-2xl font-bold mb-6 md:mb-8 font-['Playfair_Display']">Похожие товары</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-8">
|
||||
{similarProducts.map((similarProduct) => (
|
||||
<motion.div
|
||||
key={similarProduct.id}
|
||||
@ -405,7 +523,7 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
}
|
||||
alt={similarProduct.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
@ -415,19 +533,27 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{similarProduct.name}</h3>
|
||||
<div className="mt-3 md:mt-4">
|
||||
<h3 className="text-sm md:text-lg font-medium line-clamp-1">{similarProduct.name}</h3>
|
||||
{similarProduct.variants && similarProduct.variants.length > 0 ? (
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{formatPrice(similarProduct.variants[0].price)} ₽
|
||||
{similarProduct.variants[0].discount_price && (
|
||||
<span className="ml-2 text-sm line-through text-gray-500">
|
||||
<p className="mt-1 text-sm md:text-lg font-bold">
|
||||
{similarProduct.variants[0].discount_price ? (
|
||||
<span className="text-lg font-bold">
|
||||
{formatPrice(similarProduct.variants[0].discount_price)} ₽
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-lg font-bold">
|
||||
{formatPrice(similarProduct.variants[0].price)} ₽
|
||||
</span>
|
||||
)}
|
||||
{similarProduct.variants[0].discount_price && (
|
||||
<span className="ml-2 text-xs md:text-sm line-through text-gray-500">
|
||||
{formatPrice(similarProduct.variants[0].price)} ₽
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-lg font-bold">Цена по запросу</p>
|
||||
<p className="mt-1 text-sm md:text-lg font-bold">Цена по запросу</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
@ -438,6 +564,128 @@ export default function ProductPage({ product, similarProducts }: ProductPagePro
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Полноэкранная галерея */}
|
||||
<AnimatePresence>
|
||||
{fullscreenGallery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black z-50 flex flex-col"
|
||||
>
|
||||
<div className="flex justify-between items-center p-4 text-white">
|
||||
<button
|
||||
onClick={toggleFullscreenGallery}
|
||||
className="p-2"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<span className="text-sm">{currentImageIndex + 1} / {product.images?.length || 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.7}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{
|
||||
x: swipeDirection === 'left' ? '100%' : swipeDirection === 'right' ? '-100%' : 0,
|
||||
opacity: 1
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
zIndex: 1
|
||||
}}
|
||||
exit={{
|
||||
x: swipeDirection === 'left' ? '-100%' : swipeDirection === 'right' ? '100%' : 0,
|
||||
opacity: 1,
|
||||
zIndex: 0
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
opacity: { duration: 0 }
|
||||
}}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
>
|
||||
<Image
|
||||
src={product.images[currentImageIndex].url}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
quality={100}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||||
<span className="text-gray-400">Нет изображения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{product.images && product.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black/30 p-3 rounded-full z-10"
|
||||
aria-label="Предыдущее изображение"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black/30 p-3 rounded-full z-10"
|
||||
aria-label="Следующее изображение"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Миниатюры в полноэкранном режиме */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<div className="flex space-x-2">
|
||||
{product.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`relative w-16 h-16 rounded-md overflow-hidden flex-shrink-0 ${
|
||||
index === currentImageIndex ? 'ring-2 ring-white' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Image src={image.url} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка о свайпе */}
|
||||
<div className="pb-4 text-center text-xs text-gray-400">
|
||||
Свайпните для просмотра других фото
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
68
frontend/services/cart.ts
Normal file
68
frontend/services/cart.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import api from './api';
|
||||
|
||||
// Типы данных для корзины
|
||||
export interface CartItemCreate {
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CartItemUpdate {
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
variant_id: number;
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_price: number;
|
||||
product_image?: string;
|
||||
variant_name: string;
|
||||
slug: string;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
items: CartItem[];
|
||||
total_amount: number;
|
||||
items_count: number;
|
||||
}
|
||||
|
||||
// Сервис для работы с корзиной
|
||||
const cartService = {
|
||||
// Получить корзину пользователя
|
||||
getCart: async (): Promise<Cart> => {
|
||||
const response = await api.get('/cart');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Добавить товар в корзину
|
||||
addToCart: async (item: CartItemCreate): Promise<CartItem> => {
|
||||
const response = await api.post('/cart/items', item);
|
||||
return response.data.cart_item;
|
||||
},
|
||||
|
||||
// Обновить количество товара в корзине
|
||||
updateCartItem: async (id: number, item: CartItemUpdate): Promise<CartItem> => {
|
||||
const response = await api.put(`/cart/items/${id}`, item);
|
||||
return response.data.cart_item;
|
||||
},
|
||||
|
||||
// Удалить товар из корзины
|
||||
removeFromCart: async (id: number): Promise<boolean> => {
|
||||
const response = await api.delete(`/cart/items/${id}`);
|
||||
return response.data.success;
|
||||
},
|
||||
|
||||
// Очистить корзину
|
||||
clearCart: async (): Promise<boolean> => {
|
||||
const response = await api.delete('/cart/clear');
|
||||
return response.data.success;
|
||||
}
|
||||
};
|
||||
|
||||
export default cartService;
|
||||
@ -12,17 +12,38 @@ export interface OrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string;
|
||||
sku?: string;
|
||||
};
|
||||
variant_name?: string;
|
||||
variant_size?: string;
|
||||
variant_color?: string;
|
||||
product_name?: string;
|
||||
product_sku?: string;
|
||||
product_image?: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id?: number;
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_email?: string;
|
||||
user_name?: string;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
shipping_address: string;
|
||||
shipping_address: string | Address;
|
||||
shipping_method?: string;
|
||||
payment_method: string;
|
||||
tracking_number?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
items: OrderItem[];
|
||||
@ -31,6 +52,7 @@ export interface Order {
|
||||
export interface OrderCreate {
|
||||
shipping_address_id: number;
|
||||
payment_method: string;
|
||||
notes?: string;
|
||||
items?: {
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
@ -42,37 +64,278 @@ export interface OrderUpdate {
|
||||
status?: string;
|
||||
shipping_address_id?: number;
|
||||
payment_method?: string;
|
||||
shipping_method?: string;
|
||||
tracking_number?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Сервис для работы с заказами
|
||||
export const orderService = {
|
||||
// Получить список заказов пользователя
|
||||
getUserOrders: async (): Promise<Order[]> => {
|
||||
const response = await api.get('/orders');
|
||||
return response.data.orders;
|
||||
try {
|
||||
const response = await api.get('/orders/');
|
||||
console.log('Ответ от сервера с заказами:', response.data);
|
||||
|
||||
// Проверяем, что ответ содержит данные
|
||||
if (response.data) {
|
||||
let orders = [];
|
||||
|
||||
// Если ответ - это массив, используем его
|
||||
if (Array.isArray(response.data)) {
|
||||
orders = response.data;
|
||||
}
|
||||
// Если ответ - это объект с полем orders, используем это поле
|
||||
else if (response.data.orders && Array.isArray(response.data.orders)) {
|
||||
orders = response.data.orders;
|
||||
}
|
||||
|
||||
// Преобразуем данные заказов, чтобы гарантировать наличие всех необходимых полей
|
||||
return orders.map(order => {
|
||||
// Гарантируем, что у заказа есть массив items
|
||||
const items = Array.isArray(order.items) ? order.items.map(item => ({
|
||||
id: item.id || 0,
|
||||
order_id: item.order_id || order.id || 0,
|
||||
product_id: item.product_id || 0,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity || 1,
|
||||
price: item.price || 0,
|
||||
// Гарантируем, что у элемента заказа есть объект product
|
||||
product: item.product || {
|
||||
id: item.product_id || 0,
|
||||
name: 'Товар',
|
||||
image: null
|
||||
},
|
||||
variant_name: item.variant_name || ''
|
||||
})) : [];
|
||||
|
||||
// Обрабатываем адрес доставки
|
||||
let shippingAddress = order.shipping_address || '';
|
||||
|
||||
// Если адрес доставки - это объект, оставляем его как есть
|
||||
// Если адрес доставки - это строка, оставляем его как строку
|
||||
// В противном случае, устанавливаем пустую строку
|
||||
if (typeof shippingAddress !== 'string' && typeof shippingAddress !== 'object') {
|
||||
shippingAddress = '';
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id || 0,
|
||||
user_id: order.user_id || 0,
|
||||
status: order.status || 'pending',
|
||||
total_amount: order.total_amount || 0,
|
||||
shipping_address: shippingAddress,
|
||||
payment_method: order.payment_method || '',
|
||||
created_at: order.created_at || new Date().toISOString(),
|
||||
updated_at: order.updated_at,
|
||||
items: items
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Если ответ не содержит массив заказов, возвращаем пустой массив
|
||||
console.warn('Ответ от сервера не содержит массив заказов:', response.data);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении заказов пользователя:', error);
|
||||
// В случае ошибки возвращаем пустой массив
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Получить заказ по ID
|
||||
getOrderById: async (id: number): Promise<Order> => {
|
||||
const response = await api.get(`/orders/${id}`);
|
||||
return response.data;
|
||||
if (!id || isNaN(id)) {
|
||||
throw new Error('Некорректный ID заказа');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/orders/${id}/`);
|
||||
console.log('Ответ от сервера с заказом:', response.data);
|
||||
|
||||
// Проверяем, что ответ содержит данные
|
||||
if (response.data) {
|
||||
// Получаем данные заказа из ответа
|
||||
let orderData = response.data;
|
||||
|
||||
// Если ответ - это объект с полем order, используем это поле
|
||||
if (response.data.order) {
|
||||
orderData = response.data.order;
|
||||
}
|
||||
|
||||
// Гарантируем, что у заказа есть массив items
|
||||
const items = Array.isArray(orderData.items) ? orderData.items.map(item => ({
|
||||
id: item.id || 0,
|
||||
order_id: item.order_id || orderData.id || 0,
|
||||
product_id: item.product_id || 0,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity || 1,
|
||||
price: item.price || 0,
|
||||
// Гарантируем, что у элемента заказа есть объект product
|
||||
product: item.product || {
|
||||
id: item.product_id || 0,
|
||||
name: 'Товар',
|
||||
image: null
|
||||
},
|
||||
variant_name: item.variant_name || ''
|
||||
})) : [];
|
||||
|
||||
// Обрабатываем адрес доставки
|
||||
let shippingAddress = orderData.shipping_address || '';
|
||||
|
||||
// Если адрес доставки - это объект, оставляем его как есть
|
||||
// Если адрес доставки - это строка, оставляем его как строку
|
||||
// В противном случае, устанавливаем пустую строку
|
||||
if (typeof shippingAddress !== 'string' && typeof shippingAddress !== 'object') {
|
||||
shippingAddress = '';
|
||||
}
|
||||
|
||||
// Возвращаем заказ с гарантированными полями
|
||||
return {
|
||||
id: orderData.id || 0,
|
||||
user_id: orderData.user_id || 0,
|
||||
user_email: orderData.user_email || '',
|
||||
user_name: orderData.user_name || '',
|
||||
status: orderData.status || 'pending',
|
||||
total_amount: orderData.total_amount || 0,
|
||||
shipping_address: shippingAddress,
|
||||
shipping_method: orderData.shipping_method || 'Стандартная доставка',
|
||||
payment_method: orderData.payment_method || '',
|
||||
tracking_number: orderData.tracking_number || '',
|
||||
notes: orderData.notes || '',
|
||||
created_at: orderData.created_at || new Date().toISOString(),
|
||||
updated_at: orderData.updated_at,
|
||||
items: items
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Ответ от сервера не содержит данные заказа');
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении заказа с ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Получить список всех заказов (для админки)
|
||||
getOrders: async (params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
}): Promise<Order[]> => {
|
||||
try {
|
||||
// Формируем строку запроса из параметров
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params) {
|
||||
if (params.skip !== undefined) queryParams.append('skip', params.skip.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
if (params.status) queryParams.append('status', params.status);
|
||||
if (params.dateFrom) queryParams.append('date_from', params.dateFrom);
|
||||
if (params.dateTo) queryParams.append('date_to', params.dateTo);
|
||||
if (params.search) queryParams.append('search', params.search);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
|
||||
const response = await api.get(`/orders${queryString}`);
|
||||
console.log('Ответ от сервера с заказами (админка):', response.data);
|
||||
|
||||
// Проверяем, что ответ содержит данные
|
||||
if (response.data) {
|
||||
let orders = [];
|
||||
|
||||
// Если ответ - это массив, используем его
|
||||
if (Array.isArray(response.data)) {
|
||||
orders = response.data;
|
||||
}
|
||||
// Если ответ - это объект с полем orders, используем это поле
|
||||
else if (response.data.orders && Array.isArray(response.data.orders)) {
|
||||
orders = response.data.orders;
|
||||
}
|
||||
|
||||
// Преобразуем данные заказов, чтобы гарантировать наличие всех необходимых полей
|
||||
return orders.map(order => {
|
||||
// Гарантируем, что у заказа есть массив items
|
||||
const items = Array.isArray(order.items) ? order.items.map(item => ({
|
||||
id: item.id || 0,
|
||||
order_id: item.order_id || order.id || 0,
|
||||
product_id: item.product_id || 0,
|
||||
variant_id: item.variant_id,
|
||||
quantity: item.quantity || 1,
|
||||
price: item.price || 0,
|
||||
// Гарантируем, что у элемента заказа есть объект product
|
||||
product: item.product || {
|
||||
id: item.product_id || 0,
|
||||
name: item.product_name || 'Товар',
|
||||
image: item.product_image || null
|
||||
},
|
||||
variant_name: item.variant_name || ''
|
||||
})) : [];
|
||||
|
||||
// Обрабатываем адрес доставки
|
||||
let shippingAddress = order.shipping_address || '';
|
||||
|
||||
// Если адрес доставки - это объект, оставляем его как есть
|
||||
// Если адрес доставки - это строка, оставляем его как строку
|
||||
// В противном случае, устанавливаем пустую строку
|
||||
if (typeof shippingAddress !== 'string' && typeof shippingAddress !== 'object') {
|
||||
shippingAddress = '';
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id || 0,
|
||||
user_id: order.user_id || 0,
|
||||
user_email: order.user_email || '',
|
||||
user_name: order.user_name || '',
|
||||
status: order.status || 'pending',
|
||||
total_amount: order.total_amount || 0,
|
||||
shipping_address: shippingAddress,
|
||||
shipping_method: order.shipping_method || 'Стандартная доставка',
|
||||
payment_method: order.payment_method || '',
|
||||
created_at: order.created_at || new Date().toISOString(),
|
||||
updated_at: order.updated_at,
|
||||
items: items
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Если ответ не содержит массив заказов, возвращаем пустой массив
|
||||
console.warn('Ответ от сервера не содержит массив заказов:', response.data);
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка заказов:', error);
|
||||
// В случае ошибки возвращаем пустой массив
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Создать новый заказ
|
||||
createOrder: async (order: OrderCreate): Promise<Order> => {
|
||||
const response = await api.post('/orders', order);
|
||||
return response.data;
|
||||
console.log('Отправка запроса на создание заказа:', order);
|
||||
try {
|
||||
const response = await api.post('/orders/', order);
|
||||
console.log('Успешный ответ от сервера:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании заказа:', error);
|
||||
if (error.response) {
|
||||
console.error('Статус ответа:', error.response.status);
|
||||
console.error('Данные ответа:', error.response.data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Обновить заказ
|
||||
updateOrder: async (id: number, order: OrderUpdate): Promise<Order> => {
|
||||
const response = await api.put(`/orders/${id}`, 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`);
|
||||
const response = await api.post(`/orders/${id}/cancel/`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@ -4,12 +4,15 @@ import api from './api';
|
||||
export interface Address {
|
||||
id: number;
|
||||
user_id: number;
|
||||
type: string;
|
||||
address: string;
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@ -33,18 +36,20 @@ export interface UserUpdate {
|
||||
}
|
||||
|
||||
export interface AddressCreate {
|
||||
type: string;
|
||||
address: string;
|
||||
address_line1: string;
|
||||
address_line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface AddressUpdate {
|
||||
type?: string;
|
||||
address?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
is_default?: boolean;
|
||||
@ -66,8 +71,19 @@ export const userService = {
|
||||
|
||||
// Добавить адрес для текущего пользователя
|
||||
addAddress: async (address: AddressCreate): Promise<Address> => {
|
||||
const response = await api.post('/users/me/addresses', address);
|
||||
return response.data;
|
||||
console.log('Отправка запроса на добавление адреса:', address);
|
||||
try {
|
||||
const response = await api.post('/users/me/addresses', address);
|
||||
console.log('Успешный ответ от сервера:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении адреса:', error);
|
||||
if (error.response) {
|
||||
console.error('Статус ответа:', error.response.status);
|
||||
console.error('Данные ответа:', error.response.data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Обновить адрес
|
||||
|
||||
Loading…
Reference in New Issue
Block a user