add final components and pages

This commit is contained in:
Nikita 2026-09-19 22:59:51 +05:00
parent f19c6a27d5
commit 3bc141c612
34 changed files with 2669 additions and 114 deletions

View file

@ -0,0 +1,23 @@
# Generated by Django 6.1.1 on 2026-09-19 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='product',
name='description',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='product',
name='slug',
field=models.SlugField(blank=True, max_length=200, null=True, unique=True),
),
]

View file

@ -1,4 +1,5 @@
from django.db import models
from django.utils.text import slugify
class Category(models.Model):
@ -20,6 +21,8 @@ class Product(models.Model):
related_name='products',
)
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True, null=True, blank=True) # <-- ДОБАВЛЕНО
description = models.TextField(blank=True, default='') # <-- ДОБАВЛЕНО
image = models.ImageField(upload_to='products/')
rating = models.DecimalField(max_digits=2, decimal_places=1)
price = models.DecimalField(max_digits=10, decimal_places=2)
@ -36,9 +39,25 @@ class Product(models.Model):
return round(
(self.old_price - self.price) / self.old_price * 100
)
return None
@property
def has_discount(self):
return self.old_price is not None and self.old_price > self.price
def save(self, *args, **kwargs):
# Автоматически генерируем slug из имени, если он пустой
if not self.slug:
base_slug = slugify(self.name, allow_unicode=False)
slug = base_slug
counter = 1
# Проверяем уникальность slug
while Product.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
self.slug = slug
super().save(*args, **kwargs)
class Meta:
verbose_name = 'Product'
verbose_name_plural = 'Products'

View file

@ -11,6 +11,8 @@ class ProductSerializer(serializers.ModelSerializer):
fields = (
'id',
'name',
'slug',
'description',
'image',
'rating',
'price',

View file

@ -1,5 +1,5 @@
from django.urls import path
from .views import hello, ProductListView
from .views import hello, ProductListView, ProductDetailView
from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView
urlpatterns = [
@ -12,4 +12,6 @@ urlpatterns = [
# Newsletter
path("newsletter/", NewsletterCreateView.as_view()),
path("products/<slug:slug>/", ProductDetailView.as_view()),
]

View file

@ -1,6 +1,7 @@
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework.generics import ListAPIView
from rest_framework import generics
from .models import Product
from .serializers import ProductSerializer
@ -16,3 +17,10 @@ def hello(request):
class ProductListView(ListAPIView):
queryset = Product.objects.all()
serializer_class = ProductSerializer
class ProductDetailView(generics.RetrieveAPIView):
"""Получить один товар по slug"""
queryset = Product.objects.all()
serializer_class = ProductSerializer
lookup_field = 'slug'

View file

@ -43,6 +43,7 @@ INSTALLED_APPS = [
'api',
'dressstyle',
'reviews',
'cart',
]
MIDDLEWARE = [
@ -137,5 +138,7 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
]
CORS_ALLOW_CREDENTIALS = True
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

View file

@ -12,6 +12,8 @@ urlpatterns = [
# Добавляем reviews
path("api/reviews/", include("reviews.urls")),
path("api/cart/", include("cart.urls")),
]
if settings.DEBUG:

0
cart/__init__.py Normal file
View file

3
cart/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
cart/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CartConfig(AppConfig):
name = 'cart'

View file

@ -0,0 +1,42 @@
# Generated by Django 6.1.1 on 2026-09-19 15:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('api', '0002_product_description_product_slug'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('size', models.CharField(blank=True, max_length=20)),
('color', models.CharField(blank=True, max_length=20)),
('added_at', models.DateTimeField(auto_now_add=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='cart.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.product')),
],
options={
'unique_together': {('cart', 'product', 'size', 'color')},
},
),
]

View file

@ -0,0 +1,26 @@
# Generated by Django 6.1.1 on 2026-09-19 16:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cart', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='cart',
name='session_key',
field=models.CharField(blank=True, max_length=40, null=True),
),
migrations.AlterField(
model_name='cart',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL),
),
]

View file

42
cart/models.py Normal file
View file

@ -0,0 +1,42 @@
from django.db import models
from api.models import Product
from django.contrib.auth.models import User
class Cart(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='cart',
null=True,
blank=True
)
session_key = models.CharField(max_length=40, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
if self.user:
return f"Cart for {self.user.username}"
return f"Anonymous Cart ({self.session_key})"
def get_total(self):
return sum(item.get_subtotal() for item in self.items.all())
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
size = models.CharField(max_length=20, blank=True)
color = models.CharField(max_length=20, blank=True)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['cart', 'product', 'size', 'color']
def __str__(self):
return f"{self.product.name} x {self.quantity}"
def get_subtotal(self):
return self.product.price * self.quantity

28
cart/serializers.py Normal file
View file

@ -0,0 +1,28 @@
from rest_framework import serializers
from .models import Cart, CartItem
from api.serializers import ProductSerializer
class CartItemSerializer(serializers.ModelSerializer):
product = ProductSerializer(read_only=True)
product_id = serializers.PrimaryKeyRelatedField(
queryset=CartItem.objects.all().model._meta.get_field('product').related_model,
source='product',
write_only=True
)
class Meta:
model = CartItem
fields = ['id', 'product', 'product_id', 'quantity', 'size', 'color', 'added_at']
class CartSerializer(serializers.ModelSerializer):
items = CartItemSerializer(many=True, read_only=True)
total = serializers.SerializerMethodField()
class Meta:
model = Cart
fields = ['id', 'user', 'session_key', 'items', 'total', 'created_at', 'updated_at']
def get_total(self, obj):
return obj.get_total()

3
cart/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
cart/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CartViewSet
router = DefaultRouter()
router.register(r'', CartViewSet, basename='cart')
urlpatterns = [
path('', include(router.urls)),
]

97
cart/views.py Normal file
View file

@ -0,0 +1,97 @@
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Cart, CartItem
from .serializers import CartSerializer
def get_or_create_cart(request):
"""Получить или создать корзину по сессии"""
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
cart = Cart.objects.filter(session_key=session_key).first()
if not cart:
cart = Cart.objects.create(
session_key=session_key,
user=request.user if request.user.is_authenticated else None
)
return cart
@method_decorator(csrf_exempt, name='dispatch')
class CartViewSet(viewsets.ModelViewSet):
serializer_class = CartSerializer
# ВАЖНО: Отключаем аутентификацию для этого ViewSet,
# чтобы DRF не требовал CSRF-токен для POST-запросов
authentication_classes = []
permission_classes = [permissions.AllowAny]
def get_queryset(self):
if self.request.user.is_authenticated:
return Cart.objects.filter(user=self.request.user)
return Cart.objects.none()
@action(detail=False, methods=['get'])
def my_cart(self, request):
cart = get_or_create_cart(request)
serializer = self.get_serializer(cart)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def add_item(self, request):
cart = get_or_create_cart(request)
product_id = request.data.get('product_id')
quantity = request.data.get('quantity', 1)
size = request.data.get('size', '')
color = request.data.get('color', '')
cart_item, created = CartItem.objects.get_or_create(
cart=cart,
product_id=product_id,
size=size,
color=color,
defaults={'quantity': quantity}
)
if not created:
cart_item.quantity += quantity
cart_item.save()
serializer = self.get_serializer(cart)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def update_item(self, request):
cart = get_or_create_cart(request)
item_id = request.data.get('item_id')
quantity = request.data.get('quantity')
try:
cart_item = CartItem.objects.get(cart=cart, id=item_id)
cart_item.quantity = quantity
cart_item.save()
except CartItem.DoesNotExist:
return Response({'error': 'Item not found'}, status=404)
serializer = self.get_serializer(cart)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def remove_item(self, request):
cart = get_or_create_cart(request)
item_id = request.data.get('item_id')
CartItem.objects.filter(cart=cart, id=item_id).delete()
serializer = self.get_serializer(cart)
return Response(serializer.data)

View file

@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -1324,6 +1325,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2428,6 +2442,44 @@
"react": "^19.3.0"
}
},
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
@ -2478,6 +2530,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View file

@ -1,7 +1,20 @@
import { Routes, Route } from 'react-router-dom'
import { CartProvider } from './context/CartContext' // <-- Импортируем провайдер
import Home from './pages/Home/Home'
import ProductDetail from './pages/ProductDetail/ProductDetail'
import Cart from './pages/Cart/Cart' // Убедитесь, что путь правильный
function App() {
return <Home />
return (
<CartProvider> {/* <-- Оборачиваем всё приложение */}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/product/:slug" element={<ProductDetail />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</CartProvider>
)
}
export default App

View file

@ -6,9 +6,9 @@
/* Newsletter секция */
.newsletterSection {
padding: 0 25px;
margin-top: -60px;
position: relative;
z-index: 10;
transform: translateY(-100px);
}
.newsletterContainer {

View file

@ -4,6 +4,11 @@
border-bottom: 1px solid #e8e8e8;
}
.logoLink {
text-decoration: none;
color: #000;
}
.logo {
flex-shrink: 0;
margin: 0;
@ -52,7 +57,7 @@
align-items: center;
width: 320px;
height: 44px;
margin-left: 40px;
margin-left: auto;
padding: 0 16px;
border-radius: 24px;
background-color: #f5f5f5;
@ -84,3 +89,52 @@
.searchInput::-webkit-search-cancel-button {
cursor: pointer;
}
.cart {
display: flex;
align-items: center;
margin-left: 20px;
}
.cartButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border: 0;
background: transparent;
color: #111;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.cartButton:hover {
opacity: 0.7;
transform: translateY(-1px);
}
.cartIcon {
width: 25px;
height: 25px;
}
.cartCount {
position: absolute;
top: -4px;
right: -5px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background-color: #000;
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1;
}

View file

@ -1,4 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: импорт из контекста, а не из hooks
import { navigationItems } from '../../shared/navigation/navigation.data'
import styles from './Header.module.css'
@ -6,10 +8,19 @@ import styles from './Header.module.css'
function Header() {
const [isShopOpen, setIsShopOpen] = useState(false)
// Получаем актуальные данные корзины из глобального контекста
const { cart } = useCart()
// Считаем общее количество товаров в реальном времени
// Если cart или items еще не загрузились, вернет 0
const totalItems = cart?.items?.reduce((sum: number, item: any) => sum + item.quantity, 0) ?? 0
return (
<header className={styles.header}>
<div className="container">
<a href="/" className={styles.logoLink}>
<h1 className={styles.logo}>shop.co</h1>
</a>
<nav className={styles.navigation}>
{navigationItems.map((item) =>
@ -43,19 +54,8 @@ function Header() {
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
cx="11"
cy="11"
r="7"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M16.5 16.5L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
@ -64,6 +64,35 @@ function Header() {
className={styles.searchInput}
/>
</div>
<div className={styles.cart}>
<Link to="/cart" className={styles.cartButton} aria-label="Shopping cart">
<svg
className={styles.cartIcon}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M3 4H5L7.2 15.2C7.4 16.2 8.3 17 9.3 17H17.5C18.4 17 19.2 16.4 19.5 15.5L21 9H6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="9.5" cy="20" r="1.5" fill="currentColor" />
<circle cx="17" cy="20" r="1.5" fill="currentColor" />
</svg>
{/* Показываем бейдж только если товаров > 0 */}
{totalItems > 0 && (
<span className={styles.cartCount}>
{totalItems > 99 ? '99+' : totalItems}
</span>
)}
</Link>
</div>
</div>
</header>
)

View file

@ -39,6 +39,7 @@
font-size: 12px;
font-weight: 500;
cursor: pointer;
margin-bottom: 30px;
}
.viewAll:hover {

View file

@ -1,7 +1,30 @@
.card {
min-width: 0;
/* ===== КОНТЕЙНЕР КАРТОЧКИ ===== */
.cardContainer {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
/* ===== ССЫЛКА-ОБЁРТКА ===== */
.cardLink {
text-decoration: none;
color: inherit;
display: block;
}
/* ===== ОСНОВНАЯ КАРТОЧКА ===== */
.card {
min-width: 0;
transition: transform 0.3s ease;
cursor: pointer;
}
.cardLink:hover .card {
transform: translateY(-4px);
}
/* ===== ИЗОБРАЖЕНИЕ ===== */
.imageWrapper {
width: 100%;
aspect-ratio: 1;
@ -15,8 +38,15 @@ display: block;
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.4s ease;
}
/* Эффект легкого приближения картинки при наведении на карточку */
.cardLink:hover .image {
transform: scale(1.05);
}
/* ===== ТЕКСТ И РЕЙТИНГ ===== */
.name {
margin: 11px 0 6px;
overflow: hidden;
@ -48,6 +78,7 @@ font-size: 11px;
line-height: 1;
}
/* ===== ЦЕНА ===== */
.priceRow {
display: flex;
align-items: center;
@ -84,3 +115,86 @@ font-size: 10px;
font-weight: 500;
line-height: 1;
}
/* ===== КНОПКА "ADD TO CART" ===== */
.quickAddButton {
width: 100%;
padding: 14px 20px;
background-color: #000;
color: #fff;
border: none;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: inherit;
}
.quickAddButton:hover:not(:disabled) {
background-color: #333;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.quickAddButton:active:not(:disabled) {
transform: translateY(0);
}
.quickAddButton:disabled {
background-color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ===== СЧЕТЧИКО КОЛИЧЕСТВА В КАРТОЧКЕ ===== */
.quantitySelector {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
padding: 10px 20px;
border-radius: 30px;
background-color: #f5f5f5;
box-sizing: border-box;
}
.qtyButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
color: #111;
font-family: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.qtyButton:hover:not(:disabled) {
background-color: #e0e0e0;
border-radius: 50%;
}
.qtyButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantityValue {
min-width: 24px;
text-align: center;
font-size: 15px;
font-weight: 600;
color: #000;
}

View file

@ -1,8 +1,12 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: только этот импорт!
import styles from './ProductCard.module.css'
type Product = {
id: number
name: string
slug: string
image: string
rating: number
price: string
@ -16,43 +20,88 @@ type ProductsCardProps = {
}
function ProductsCard({ product }: ProductsCardProps) {
// Берем функции из глобального контекста
const { cart, addToCart, updateQuantity } = useCart()
const [isUpdating, setIsUpdating] = useState(false)
// Ищем этот товар в глобальной корзине
const cartItem = cart?.items?.find((item: any) => item.product.id === product.id)
const quantityInCart = cartItem ? cartItem.quantity : 0
const cartItemId = cartItem ? cartItem.id : null
const handleAddToCart = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsUpdating(true)
// Вызываем функцию из контекста, она сама обновит всё приложение
await addToCart(product.id, 1, 'M', 'Default')
setIsUpdating(false)
}
const handleUpdateQuantity = async (newQuantity: number) => {
if (!cartItemId) return
setIsUpdating(true)
// Вызываем функцию из контекста
await updateQuantity(cartItemId, newQuantity)
setIsUpdating(false)
}
return (
<div className={styles.cardContainer}>
<Link to={`/product/${product.slug}`} className={styles.cardLink}>
<article className={styles.card}>
<div className={styles.imageWrapper}>
<img
src={product.image}
alt={product.name}
className={styles.image}
/>
<img src={product.image} alt={product.name} className={styles.image} />
</div>
<h3 className={styles.name}>{product.name}</h3>
<div className={styles.rating}>
<span className={styles.stars}>★★★★★</span>
<span className={styles.ratingValue}>
{product.rating}/5
</span>
<span className={styles.ratingValue}>{product.rating}/5</span>
</div>
<div className={styles.priceRow}>
<span className={styles.price}>
${Number(product.price).toFixed(0)}
</span>
<span className={styles.price}>${Number(product.price).toFixed(0)}</span>
{product.old_price && (
<span className={styles.oldPrice}>
${Number(product.old_price).toFixed(0)}
</span>
<span className={styles.oldPrice}>${Number(product.old_price).toFixed(0)}</span>
)}
{product.discount_percent && (
<span className={styles.discount}>
-{product.discount_percent}%
</span>
<span className={styles.discount}>-{product.discount_percent}%</span>
)}
</div>
</article>
</Link>
{quantityInCart === 0 ? (
<button
className={styles.quickAddButton}
onClick={handleAddToCart}
disabled={isUpdating}
>
{isUpdating ? 'Adding...' : 'Add to Cart'}
</button>
) : (
<div className={styles.quantitySelector}>
<button
className={styles.qtyButton}
onClick={() => handleUpdateQuantity(quantityInCart - 1)}
disabled={isUpdating}
>
−
</button>
<span className={styles.quantityValue}>{quantityInCart}</span>
<button
className={styles.qtyButton}
onClick={() => handleUpdateQuantity(quantityInCart + 1)}
disabled={isUpdating}
>
+
</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,99 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface CartContextType {
cart: any
isLoading: boolean
addToCart: (product_id: number, quantity: number, size: string, color: string) => Promise<void>
updateQuantity: (item_id: number, quantity: number) => Promise<void>
removeFromCart: (item_id: number) => Promise<void>
}
const CartContext = createContext<CartContextType | undefined>(undefined)
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const fetchCart = async () => {
try {
const res = await fetch('http://localhost:8000/api/cart/my_cart/', {
credentials: 'include',
})
const data = await res.json()
console.log('🛒 [CartContext] Получены новые данные корзины:', data) // <-- ДИАГНОСТИКА
setCart(data)
} catch (error) {
console.error('❌ [CartContext] Ошибка загрузки корзины:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
console.log('🔄 [CartContext] Инициализация провайдера...')
fetchCart()
}, [])
const addToCart = async (product_id: number, quantity: number, size: string, color: string) => {
console.log('➕ [CartContext] Вызов addToCart для товара:', product_id) // <-- ДИАГНОСТИКА
try {
await fetch('http://localhost:8000/api/cart/add_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ product_id, quantity, size, color }),
})
await fetchCart() // Перезапрашиваем корзину, чтобы обновить ВСЕ компоненты
} catch (error) {
console.error('❌ [CartContext] Ошибка добавления:', error)
}
}
const updateQuantity = async (item_id: number, quantity: number) => {
console.log('🔄 [CartContext] Вызов updateQuantity для item:', item_id, 'кол-во:', quantity)
try {
if (quantity === 0) {
await removeFromCart(item_id)
return
}
await fetch('http://localhost:8000/api/cart/update_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ item_id, quantity }),
})
await fetchCart()
} catch (error) {
console.error('❌ [CartContext] Ошибка обновления количества:', error)
}
}
const removeFromCart = async (item_id: number) => {
console.log('🗑️ [CartContext] Вызов removeFromCart для item:', item_id)
try {
await fetch('http://localhost:8000/api/cart/remove_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ item_id }),
})
await fetchCart()
} catch (error) {
console.error('❌ [CartContext] Ошибка удаления:', error)
}
}
return (
<CartContext.Provider value={{ cart, isLoading, addToCart, updateQuantity, removeFromCart }}>
{children}
</CartContext.Provider>
)
}
export const useCart = () => {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart must be used within a CartProvider')
}
return context
}

View file

@ -0,0 +1,103 @@
import { useState, useEffect } from 'react'
// Глобальный кэш корзины
let cachedCart: any = null
let isFetching = false
let lastFetchTime = 0
// Кастомное событие для уведомления всех компонентов
const CART_UPDATED_EVENT = 'cart-updated'
export function refreshCartData() {
// Не делаем запрос чаще чем раз в 300мс (защита от спама)
const now = Date.now()
if (now - lastFetchTime < 300) return
lastFetchTime = now
if (isFetching) return
isFetching = true
fetch('http://localhost:8000/api/cart/my_cart/', {
credentials: 'include',
})
.then(res => res.json())
.then(data => {
cachedCart = data
isFetching = false
// Отправляем событие ВСЕМ компонентам
window.dispatchEvent(new CustomEvent(CART_UPDATED_EVENT, { detail: data }))
})
.catch(err => {
console.error('Ошибка загрузки корзины:', err)
isFetching = false
})
}
export function useCart() {
const [cart, setCart] = useState(cachedCart)
useEffect(() => {
// Если кэша нет — загружаем
if (!cachedCart) {
refreshCartData()
} else {
setCart(cachedCart)
}
// Подписываемся на глобальное событие обновления корзины
const handleCartUpdate = (event: Event) => {
const customEvent = event as CustomEvent
setCart(customEvent.detail)
}
window.addEventListener(CART_UPDATED_EVENT, handleCartUpdate)
return () => {
window.removeEventListener(CART_UPDATED_EVENT, handleCartUpdate)
}
}, [])
const addToCart = async (product_id: number, quantity: number, size: string, color: string) => {
try {
const response = await fetch('http://localhost:8000/api/cart/add_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ product_id, quantity, size, color }),
})
if (response.ok) {
// После успешного добавления — обновляем корзину для ВСЕХ компонентов
refreshCartData()
}
} catch (error) {
console.error('Ошибка при добавлении:', error)
}
}
const updateQuantity = async (item_id: number, quantity: number) => {
try {
if (quantity === 0) {
await fetch('http://localhost:8000/api/cart/remove_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ item_id }),
})
} else {
await fetch('http://localhost:8000/api/cart/update_item/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ item_id, quantity }),
})
}
// После изменения — обновляем корзину для ВСЕХ компонентов
refreshCartData()
} catch (error) {
console.error('Ошибка при обновлении количества:', error)
}
}
return { cart, addToCart, updateQuantity }
}

View file

@ -1,11 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles/globals.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,400 @@
.loading {
text-align: center;
padding: 100px 20px;
font-size: 18px;
color: #666;
min-height: 60vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 25px;
}
.cartPage {
padding: 40px 0 80px;
min-height: 70vh;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 14px;
color: #666;
}
.breadcrumbSeparator {
color: #999;
}
.title {
margin: 0 0 40px;
font-size: 48px;
font-weight: 800;
line-height: 1;
letter-spacing: -2px;
color: #000;
}
.cartLayout {
display: grid;
grid-template-columns: 1fr 420px;
gap: 40px;
align-items: start;
}
.items {
display: flex;
flex-direction: column;
gap: 20px;
}
.item {
display: flex;
align-items: center;
gap: 24px;
padding: 24px;
border: 1px solid #E8E8E8;
border-radius: 20px;
background-color: #fff;
transition: box-shadow 0.3s ease;
}
.item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.imageWrapper {
width: 140px;
height: 140px;
flex-shrink: 0;
border-radius: 16px;
background-color: #F0F0F0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.image {
width: 100%;
height: 100%;
object-fit: contain;
}
.itemInfo {
flex: 1;
min-width: 0;
}
.itemName {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: #000;
line-height: 1.3;
}
.itemDetails {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
color: #666;
}
.detailSeparator {
color: #999;
}
.price {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #000;
}
.quantity {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
border-radius: 30px;
background-color: #F5F5F5;
}
.qtyButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
color: #111;
font-family: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.qtyButton:hover {
background-color: #E0E0E0;
border-radius: 50%;
}
.qtyValue {
min-width: 24px;
text-align: center;
font-size: 15px;
font-weight: 600;
color: #000;
}
.removeButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease;
}
.removeButton:hover {
transform: scale(1.1);
}
.summary {
padding: 32px;
border: 1px solid #E8E8E8;
border-radius: 20px;
background-color: #fff;
position: sticky;
top: 100px;
}
.summaryTitle {
margin: 0 0 28px;
font-size: 24px;
font-weight: 700;
color: #000;
}
.summaryRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
font-size: 15px;
}
.summaryLabel {
color: #666;
}
.summaryValue {
color: #000;
font-weight: 600;
}
.summaryValueDiscount {
color: #FF3333;
font-weight: 600;
}
.divider {
width: 100%;
height: 1px;
margin: 24px 0;
background-color: #E8E8E8;
}
.total {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
font-size: 16px;
color: #000;
}
.total strong {
font-size: 28px;
font-weight: 700;
}
.promoSection {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.promoInput {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.promoIcon {
position: absolute;
left: 16px;
color: #999;
pointer-events: none;
}
.promoCodeInput {
width: 100%;
height: 48px;
padding: 0 16px 0 44px;
border: 1px solid #E8E8E8;
border-radius: 30px;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.promoCodeInput:focus {
border-color: #000;
}
.applyButton {
padding: 0 24px;
border: 0;
border-radius: 30px;
background-color: #000;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.applyButton:hover {
background-color: #222;
transform: translateY(-2px);
}
.checkoutButton {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border: 0;
border-radius: 30px;
background-color: #000;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.checkoutButton:hover:not(:disabled) {
background-color: #222;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
}
.checkoutButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty {
padding: 80px 40px;
border: 1px solid #E8E8E8;
border-radius: 20px;
text-align: center;
background-color: #fff;
}
.empty h2 {
margin: 0 0 12px;
font-size: 28px;
font-weight: 700;
color: #000;
}
.empty p {
margin: 0;
color: #666;
font-size: 16px;
}
@media (max-width: 1024px) {
.cartLayout {
grid-template-columns: 1fr;
}
.summary {
position: static;
order: -1;
}
}
@media (max-width: 640px) {
.title {
font-size: 32px;
margin-bottom: 30px;
}
.item {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 20px;
}
.imageWrapper {
width: 100%;
height: 200px;
}
.itemInfo {
width: 100%;
}
.quantity {
width: 100%;
justify-content: center;
}
.removeButton {
position: absolute;
top: 16px;
right: 16px;
}
.item {
position: relative;
}
.summary {
padding: 24px;
}
.promoSection {
flex-direction: column;
}
.applyButton {
width: 100%;
height: 48px;
}
}

View file

@ -0,0 +1,193 @@
import { useState } from 'react'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: только этот импорт!
import styles from './Cart.module.css'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header'
import Footer from '../../components/Footer/Footer'
function Cart() {
// Берем всё из глобального контекста. Никаких своих fetch!
const { cart, updateQuantity, removeFromCart } = useCart()
const [promoCode, setPromoCode] = useState('')
const [appliedDiscount, setAppliedDiscount] = useState<number | null>(null)
// Если корзина еще загружается
if (!cart) {
return <div className={styles.loading}>Loading cart...</div>
}
const cartItems = cart.items || []
const handleApplyPromo = () => {
if (promoCode.toLowerCase() === 'sale20') {
setAppliedDiscount(20)
} else {
alert('Invalid promo code. Try "SALE20"')
}
}
const subtotal = cartItems.reduce(
(total, item) => total + Number(item.product.price) * item.quantity,
0
)
const discountAmount = appliedDiscount ? (subtotal * appliedDiscount) / 100 : 0
const delivery = subtotal > 0 ? 15 : 0
const total = subtotal - discountAmount + delivery
return (
<>
<AnnouncementBar />
<Header />
<main className={styles.cartPage}>
<div className={styles.container}>
<div className={styles.breadcrumbs}>
<span>Home</span>
<span className={styles.breadcrumbSeparator}>›</span>
<span>Cart</span>
</div>
<h1 className={styles.title}>YOUR CART</h1>
<div className={styles.cartLayout}>
<section className={styles.items}>
{cartItems.length === 0 ? (
<div className={styles.empty}>
<h2>Your cart is empty</h2>
<p>Add some products to your cart to continue.</p>
</div>
) : (
cartItems.map((item) => (
<article key={item.id} className={styles.item}>
<div className={styles.imageWrapper}>
<img
src={item.product.image}
alt={item.product.name}
className={styles.image}
/>
</div>
<div className={styles.itemInfo}>
<h3 className={styles.itemName}>{item.product.name}</h3>
<div className={styles.itemDetails}>
<span>Size: {item.size || 'N/A'}</span>
<span className={styles.detailSeparator}>|</span>
<span>Color: {item.color || 'N/A'}</span>
</div>
<p className={styles.price}>
${Number(item.product.price).toFixed(0)}
</p>
</div>
<div className={styles.quantity}>
<button
type="button"
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className={styles.qtyButton}
aria-label="Decrease quantity"
>
−
</button>
<span className={styles.qtyValue}>{item.quantity}</span>
<button
type="button"
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className={styles.qtyButton}
aria-label="Increase quantity"
>
+
</button>
</div>
<button
type="button"
className={styles.removeButton}
onClick={() => removeFromCart(item.id)}
aria-label="Remove item"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</article>
))
)}
</section>
<aside className={styles.summary}>
<h2 className={styles.summaryTitle}>Order Summary</h2>
<div className={styles.summaryRow}>
<span className={styles.summaryLabel}>Subtotal</span>
<span className={styles.summaryValue}>${subtotal.toFixed(0)}</span>
</div>
{appliedDiscount && (
<div className={styles.summaryRow}>
<span className={styles.summaryLabel}>
Discount (-{appliedDiscount}%)
</span>
<span className={styles.summaryValueDiscount}>
-${discountAmount.toFixed(0)}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryLabel}>Delivery Fee</span>
<span className={styles.summaryValue}>${delivery.toFixed(0)}</span>
</div>
<div className={styles.divider} />
<div className={styles.total}>
<span>Total</span>
<strong>${total.toFixed(0)}</strong>
</div>
<div className={styles.promoSection}>
<div className={styles.promoInput}>
<input
type="text"
placeholder="Add promo code"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
className={styles.promoCodeInput}
/>
</div>
<button
type="button"
className={styles.applyButton}
onClick={handleApplyPromo}
>
Apply
</button>
</div>
<button
type="button"
className={styles.checkoutButton}
disabled={cartItems.length === 0}
>
Go to Checkout
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" />
<path d="M12 5l7 7-7 7" />
</svg>
</button>
</aside>
</div>
</div>
</main>
<Footer />
</>
)
}
export default Cart

View file

@ -0,0 +1,745 @@
/* ===== БАЗОВЫЕ СТИЛИ СТРАНИЦЫ ===== */
.page {
padding: 40px 25px;
max-width: 1400px;
margin: 0 auto;
min-height: 80vh;
}
.container {
max-width: 100%;
margin: 0 auto;
}
/* ===== ХЛЕБНЫЕ КРОШКИ ===== */
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 40px;
font-size: 14px;
flex-wrap: wrap;
}
.breadcrumbLink {
color: #666;
text-decoration: none;
transition: color 0.2s ease;
}
.breadcrumbLink:hover {
color: #000;
text-decoration: underline;
}
.breadcrumbSeparator {
color: #999;
margin: 0 4px;
}
.breadcrumbCurrent {
color: #000;
font-weight: 500;
}
/* ===== ЗАГРУЗКА И ОШИБКИ ===== */
.loading, .notFound {
text-align: center;
padding: 100px 20px;
font-size: 18px;
color: #666;
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.notFound h2 {
margin-bottom: 20px;
font-size: 24px;
color: #000;
}
.backLink {
color: #000;
text-decoration: none;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 8px;
transition: gap 0.2s ease;
}
.backLink:hover {
text-decoration: underline;
gap: 12px;
}
/* ===== СЕТКА ТОВАРА ===== */
.productGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
margin-bottom: 60px;
}
/* ===== ГАЛЕРЕЯ ИЗОБРАЖЕНИЙ ===== */
.gallerySection {
display: flex;
gap: 16px;
}
.thumbnails {
display: flex;
flex-direction: column;
gap: 12px;
width: 100px;
}
.thumbnail {
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
border: 2px solid transparent;
cursor: pointer;
background: #F0F0F0;
transition: all 0.3s ease;
padding: 8px;
}
.thumbnail:hover {
border-color: #ccc;
background: #fff;
}
.thumbnailActive {
border-color: #000;
background: #fff;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: contain;
}
.mainImageLarge {
flex: 1;
background: #F0F0F0;
border-radius: 20px;
padding: 40px;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
}
.mainImageLarge img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* ===== ИНФОРМАЦИЯ О ТОВАРЕ ===== */
.productInfo {
display: flex;
flex-direction: column;
gap: 24px;
}
.productTitle {
font-size: 36px;
font-weight: 800;
color: #000;
margin: 0;
line-height: 1.2;
letter-spacing: -0.5px;
}
.ratingRow {
display: flex;
align-items: center;
gap: 12px;
}
.starsRow {
display: flex;
gap: 4px;
}
.starMedium {
font-size: 18px;
}
.starSmall {
font-size: 14px;
}
.starFilled {
color: #FFC107;
}
.starEmpty {
color: #E0E0E0;
}
.ratingValue {
font-size: 14px;
color: #666;
}
/* ===== ЦЕНА ===== */
.priceSection {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.currentPrice {
font-size: 32px;
font-weight: 700;
color: #000;
}
.oldPrice {
font-size: 24px;
color: #999;
text-decoration: line-through;
}
.discountBadge {
font-size: 14px;
color: #E74C3C;
background: #FFE5E5;
padding: 4px 12px;
border-radius: 20px;
font-weight: 600;
}
/* ===== ОПИСАНИЕ ===== */
.productDescription {
font-size: 15px;
line-height: 1.7;
color: #666;
margin: 0;
}
/* ===== СЕКЦИЯ ВЫБОРА (ЦВЕТА И РАЗМЕРЫ) ===== */
.optionSection {
display: flex;
flex-direction: column;
gap: 12px;
}
.optionLabel {
font-size: 14px;
font-weight: 600;
color: #000;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ===== ЦВЕТА ===== */
.colorOptions {
display: flex;
gap: 12px;
align-items: center;
}
.colorCircle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.colorCircle:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.colorActive {
border-color: #000;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #000;
}
.checkmark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 16px;
font-weight: bold;
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
/* ===== РАЗМЕРЫ ===== */
.sizeOptions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.sizePill {
padding: 12px 28px;
border: 1px solid #E0E0E0;
background: #fff;
border-radius: 30px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
min-width: 80px;
text-align: center;
}
.sizePill:hover {
border-color: #000;
background: #f9f9f9;
color: #000;
}
.sizePillActive {
background: #000;
color: #fff;
border-color: #000;
}
/* ===== КОРЗИНА И КОЛИЧЕСТВО ===== */
.addToCartSection {
display: flex;
gap: 16px;
margin-top: 20px;
align-items: stretch;
}
.quantitySelector {
display: flex;
align-items: center;
border: 1px solid #E0E0E0;
border-radius: 50px;
overflow: hidden;
background: #fff;
}
.qtyButton {
width: 52px;
height: 52px;
border: none;
background: transparent;
cursor: pointer;
font-size: 24px;
font-weight: 300;
transition: all 0.2s ease;
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.qtyButton:hover {
background: #F0F0F0;
}
.qtyButton:active {
background: #E0E0E0;
}
.quantityValue {
width: 52px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: #000;
}
.addToCartButton {
flex: 1;
padding: 18px 32px;
background: #000;
color: #fff;
border: none;
border-radius: 50px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.addToCartButton:hover {
background: #222;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
.addToCartButton:active {
transform: translateY(0);
}
/* ===== ВКЛАДКИ ===== */
.tabsSection {
margin-top: 80px;
}
.tabs {
display: flex;
gap: 40px;
border-bottom: 2px solid #E0E0E0;
margin-bottom: 40px;
}
.tab {
padding: 16px 0;
border: none;
background: transparent;
font-size: 16px;
font-weight: 600;
color: #999;
cursor: pointer;
position: relative;
transition: all 0.3s ease;
}
.tab:hover {
color: #000;
}
.tabActive {
color: #000;
}
.tabActive::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: #000;
}
.tabContent {
min-height: 300px;
}
/* ===== КОНТЕНТ ВКЛАДОК ===== */
.detailsContent h3,
.faqContent h3 {
font-size: 24px;
font-weight: 700;
margin-bottom: 20px;
color: #000;
}
.detailsContent p,
.faqContent p {
font-size: 15px;
line-height: 1.7;
color: #666;
}
/* ===== ОТЗЫВЫ ===== */
.reviewsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
flex-wrap: wrap;
gap: 20px;
}
.reviewsHeader h3 {
font-size: 24px;
font-weight: 700;
margin: 0;
color: #000;
}
.reviewsActions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.filterButton {
padding: 10px 20px;
border: 1px solid #E0E0E0;
background: #fff;
border-radius: 30px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
}
.filterButton:hover {
border-color: #000;
background: #f9f9f9;
}
.filterButton span {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #666;
border-radius: 3px;
}
.sortSelect {
padding: 10px 20px;
border: 1px solid #E0E0E0;
background: #fff;
border-radius: 30px;
font-size: 14px;
cursor: pointer;
outline: none;
transition: all 0.2s ease;
}
.sortSelect:hover,
.sortSelect:focus {
border-color: #000;
}
.writeReviewButton {
padding: 12px 28px;
background: #000;
color: #fff;
border: none;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.writeReviewButton:hover {
background: #222;
transform: translateY(-2px);
}
/* ===== СЕТКА ОТЗЫВОВ ===== */
.reviewsGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 32px;
}
.reviewCard {
padding: 28px;
border: 1px solid #E0E0E0;
border-radius: 16px;
transition: all 0.3s ease;
background: #fff;
}
.reviewCard:hover {
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
.reviewHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.reviewDots {
color: #999;
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 4px;
}
.reviewDots:hover {
color: #000;
}
.reviewAuthor {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.reviewAuthor strong {
font-size: 15px;
color: #000;
font-weight: 600;
}
.verifiedBadge {
width: 18px;
height: 18px;
background: #10B981;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
.reviewText {
font-size: 14px;
line-height: 1.6;
color: #666;
margin: 0 0 12px 0;
}
.reviewDate {
font-size: 12px;
color: #999;
}
.loadMoreButton {
display: block;
margin: 0 auto;
padding: 14px 40px;
border: 1px solid #E0E0E0;
background: #fff;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
color: #000;
}
.loadMoreButton:hover {
border-color: #000;
background: #f9f9f9;
transform: translateY(-2px);
}
/* ===== АДАПТИВНОСТЬ ===== */
@media (max-width: 1024px) {
.productGrid {
grid-template-columns: 1fr;
gap: 40px;
}
.gallerySection {
flex-direction: column;
}
.thumbnails {
flex-direction: row;
width: 100%;
overflow-x: auto;
padding-bottom: 10px;
}
.thumbnail {
min-width: 100px;
flex-shrink: 0;
}
.mainImageLarge {
width: 100%;
}
.productTitle {
font-size: 28px;
}
.currentPrice {
font-size: 28px;
}
.reviewsGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page {
padding: 30px 20px;
}
.breadcrumbs {
margin-bottom: 30px;
}
.productTitle {
font-size: 24px;
}
.addToCartSection {
flex-direction: column;
}
.quantitySelector {
width: 100%;
justify-content: center;
}
.qtyButton,
.quantityValue {
width: 60px;
}
.tabs {
gap: 20px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
white-space: nowrap;
padding: 12px 0;
}
.reviewsHeader {
flex-direction: column;
align-items: flex-start;
}
.reviewsActions {
width: 100%;
}
.filterButton,
.sortSelect,
.writeReviewButton {
flex: 1;
justify-content: center;
}
}
@media (max-width: 480px) {
.colorCircle {
width: 36px;
height: 36px;
}
.sizePill {
padding: 10px 20px;
font-size: 13px;
min-width: 70px;
}
.currentPrice {
font-size: 24px;
}
.oldPrice {
font-size: 18px;
}
}

View file

@ -0,0 +1,378 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: импорт из контекста
import styles from './ProductDetail.module.css'
import Header from '../../components/Header/Header'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Footer from '../../components/Footer/Footer'
interface Product {
id: number
name: string
slug: string
description: string
price: string
old_price: string | null
image: string
rating: string
reviews_count: number
discount_percent: number | null
has_discount: boolean
category: number
}
interface Review {
id: number
customer_name: string
rating: number
comment: string
verified: boolean
created_at: string
}
function ProductDetail() {
const { slug } = useParams<{ slug: string }>()
// 1. Получаем глобальные функции и состояние корзины
const { cart, addToCart, updateQuantity } = useCart()
const [product, setProduct] = useState<Product | null>(null)
const [loading, setLoading] = useState(true)
const [selectedSize, setSelectedSize] = useState('Large')
const [selectedColor, setSelectedColor] = useState(0)
const [isUpdating, setIsUpdating] = useState(false) // Только для анимации кнопки
const [activeTab, setActiveTab] = useState<'details' | 'reviews' | 'faq'>('reviews')
const [reviews, setReviews] = useState<Review[]>([])
const galleryImages = [
product?.image || '',
product?.image || '',
product?.image || '',
]
// 2. Загружаем товар и отзывы
useEffect(() => {
if (!slug) return
fetch(`http://localhost:8000/api/products/${slug}/`)
.then(res => {
if (!res.ok) throw new Error('Product not found')
return res.json()
})
.then(data => {
setProduct(data)
setLoading(false)
})
.catch(err => {
console.error(err)
setLoading(false)
})
fetch('http://localhost:8000/api/reviews/')
.then(res => res.json())
.then(data => setReviews(data.slice(0, 6)))
.catch(console.error)
}, [slug])
// 3. Вычисляем состояние корзины для текущего товара из глобального объекта cart
const cartItem = cart?.items?.find((item: any) => item.product.id === product?.id)
const cartQuantity = cartItem ? cartItem.quantity : 0
const cartItemId = cartItem ? cartItem.id : null
// 4. Функция добавления товара (использует глобальный addToCart)
const handleAddToCart = async () => {
if (!product) return
setIsUpdating(true)
try {
await addToCart(product.id, 1, selectedSize, colors[selectedColor])
} catch (error) {
console.error('Ошибка при добавлении:', error)
} finally {
setIsUpdating(false)
}
}
// 5. Функция изменения количества (использует глобальный updateQuantity)
const handleUpdateQuantity = async (newQuantity: number) => {
if (!cartItemId) return
setIsUpdating(true)
try {
await updateQuantity(cartItemId, newQuantity)
} catch (error) {
console.error('Ошибка при обновлении количества:', error)
} finally {
setIsUpdating(false)
}
}
// Ждем загрузки и товара, и корзины
if (loading || !cart) {
return <div className={styles.loading}>Loading...</div>
}
if (!product) {
return (
<div className={styles.notFound}>
<h2>Product not found</h2>
<Link to="/" className={styles.backLink}>← Back to home</Link>
</div>
)
}
const sizes = ['Small', 'Medium', 'Large', 'X-Large']
const colors = ['#556B2F', '#2F4F4F', '#2C3E50']
const renderStars = (rating: number, size: 'small' | 'medium' = 'medium') => {
const starSize = size === 'small' ? styles.starSmall : styles.starMedium
return Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={`${starSize} ${i < Math.round(rating) ? styles.starFilled : styles.starEmpty}`}
>
★
</span>
))
}
return (
<>
<AnnouncementBar />
<Header />
<div className={styles.page}>
<div className={styles.container}>
<div className={styles.breadcrumbs}>
<Link to="/" className={styles.breadcrumbLink}>Home</Link>
<span className={styles.breadcrumbSeparator}>/</span>
<Link to="/" className={styles.breadcrumbLink}>Shop</Link>
<span className={styles.breadcrumbSeparator}>/</span>
<Link to="/" className={styles.breadcrumbLink}>Men</Link>
<span className={styles.breadcrumbSeparator}>/</span>
<span className={styles.breadcrumbCurrent}>T-shirts</span>
</div>
<div className={styles.productGrid}>
<div className={styles.gallerySection}>
<div className={styles.thumbnails}>
{galleryImages.map((img, idx) => (
<div
key={idx}
className={`${styles.thumbnail} ${idx === 0 ? styles.thumbnailActive : ''}`}
>
<img src={img} alt={`View ${idx + 1}`} />
</div>
))}
</div>
<div className={styles.mainImageLarge}>
<img src={product.image} alt={product.name} />
</div>
</div>
<div className={styles.productInfo}>
<h1 className={styles.productTitle}>{product.name}</h1>
<div className={styles.ratingRow}>
<div className={styles.starsRow}>
{renderStars(Number(product.rating))}
</div>
<span className={styles.ratingValue}>
{Number(product.rating).toFixed(1)}/5
</span>
</div>
<div className={styles.priceSection}>
<span className={styles.currentPrice}>
${Number(product.price).toFixed(0)}
</span>
{product.old_price && (
<>
<span className={styles.oldPrice}>
${Number(product.old_price).toFixed(0)}
</span>
{product.discount_percent && (
<span className={styles.discountBadge}>
-{product.discount_percent}%
</span>
)}
</>
)}
</div>
<p className={styles.productDescription}>
{product.description ||
'This graphic t-shirt which is perfect for any occasion. Crafted from a soft and breathable fabric, it offers superior comfort and style.'}
</p>
<div className={styles.optionSection}>
<span className={styles.optionLabel}>Select Colors</span>
<div className={styles.colorOptions}>
{colors.map((color, idx) => (
<button
key={idx}
className={`${styles.colorCircle} ${
selectedColor === idx ? styles.colorActive : ''
}`}
style={{ backgroundColor: color }}
onClick={() => setSelectedColor(idx)}
>
{selectedColor === idx && (
<span className={styles.checkmark}>✓</span>
)}
</button>
))}
</div>
</div>
<div className={styles.optionSection}>
<span className={styles.optionLabel}>Choose Size</span>
<div className={styles.sizeOptions}>
{sizes.map(size => (
<button
key={size}
className={`${styles.sizePill} ${
selectedSize === size ? styles.sizePillActive : ''
}`}
onClick={() => setSelectedSize(size)}
>
{size}
</button>
))}
</div>
</div>
{/* ===== ДИНАМИЧЕСКАЯ СЕКЦИЯ КОРЗИНЫ ===== */}
<div className={styles.addToCartSection}>
{cartQuantity === 0 ? (
<button
className={styles.addToCartButton}
onClick={handleAddToCart}
disabled={isUpdating}
>
{isUpdating ? 'Adding...' : 'Add to Cart'}
</button>
) : (
<div className={styles.quantitySelector}>
<button
className={styles.qtyButton}
onClick={() => handleUpdateQuantity(cartQuantity - 1)}
disabled={isUpdating}
>
−
</button>
<span className={styles.quantityValue}>{cartQuantity}</span>
<button
className={styles.qtyButton}
onClick={() => handleUpdateQuantity(cartQuantity + 1)}
disabled={isUpdating}
>
+
</button>
</div>
)}
</div>
{/* ========================================== */}
</div>
</div>
<div className={styles.tabsSection}>
<div className={styles.tabs}>
<button
className={`${styles.tab} ${activeTab === 'details' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('details')}
>
Product Details
</button>
<button
className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('reviews')}
>
Rating & Reviews
</button>
<button
className={`${styles.tab} ${activeTab === 'faq' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('faq')}
>
FAQs
</button>
</div>
<div className={styles.tabContent}>
{activeTab === 'details' && (
<div className={styles.detailsContent}>
<h3>Product Details</h3>
<p>{product.description}</p>
</div>
)}
{activeTab === 'reviews' && (
<div className={styles.reviewsContent}>
<div className={styles.reviewsHeader}>
<h3>All Reviews ({product.reviews_count || 451})</h3>
<div className={styles.reviewsActions}>
<button className={styles.filterButton}>
<span></span> Filter
</button>
<select className={styles.sortSelect}>
<option>Latest</option>
<option>Oldest</option>
<option>Highest Rated</option>
</select>
<button className={styles.writeReviewButton}>
Write a Review
</button>
</div>
</div>
<div className={styles.reviewsGrid}>
{reviews.map(review => (
<div key={review.id} className={styles.reviewCard}>
<div className={styles.reviewHeader}>
<div className={styles.reviewStars}>
{renderStars(review.rating, 'small')}
</div>
<span className={styles.reviewDots}>⋯</span>
</div>
<div className={styles.reviewAuthor}>
<strong>{review.customer_name}</strong>
{review.verified && (
<span className={styles.verifiedBadge}>✓</span>
)}
</div>
<p className={styles.reviewText}>"{review.comment}"</p>
<div className={styles.reviewDate}>
Posted on {new Date(review.created_at).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
))}
</div>
<button className={styles.loadMoreButton}>
Load More Reviews
</button>
</div>
)}
{activeTab === 'faq' && (
<div className={styles.faqContent}>
<h3>Frequently Asked Questions</h3>
<p>No FAQs available for this product yet.</p>
</div>
)}
</div>
</div>
</div>
</div>
<Footer />
</>
)
}
export default ProductDetail