добавлена корзина, заказ

This commit is contained in:
Zikil 2025-03-03 20:34:59 +07:00
parent 695643a874
commit 33492bb239
35 changed files with 3032 additions and 375 deletions

View File

@ -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
})

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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]:

View File

@ -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

View 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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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"
>
Подробнее

View File

@ -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>

View File

@ -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>
) : (

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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
View 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;

View File

@ -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;
}
};

View File

@ -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;
}
},
// Обновить адрес