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.db import models
from django.utils.text import slugify
class Category(models.Model): class Category(models.Model):
@ -20,6 +21,8 @@ class Product(models.Model):
related_name='products', related_name='products',
) )
name = models.CharField(max_length=200) 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/') image = models.ImageField(upload_to='products/')
rating = models.DecimalField(max_digits=2, decimal_places=1) rating = models.DecimalField(max_digits=2, decimal_places=1)
price = models.DecimalField(max_digits=10, decimal_places=2) price = models.DecimalField(max_digits=10, decimal_places=2)
@ -36,9 +39,25 @@ class Product(models.Model):
return round( return round(
(self.old_price - self.price) / self.old_price * 100 (self.old_price - self.price) / self.old_price * 100
) )
return None 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: class Meta:
verbose_name = 'Product' verbose_name = 'Product'
verbose_name_plural = 'Products' verbose_name_plural = 'Products'

View file

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

View file

@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from .views import hello, ProductListView from .views import hello, ProductListView, ProductDetailView
from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView
urlpatterns = [ urlpatterns = [
@ -12,4 +12,6 @@ urlpatterns = [
# Newsletter # Newsletter
path("newsletter/", NewsletterCreateView.as_view()), 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.response import Response
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.generics import ListAPIView from rest_framework.generics import ListAPIView
from rest_framework import generics
from .models import Product from .models import Product
from .serializers import ProductSerializer from .serializers import ProductSerializer
@ -16,3 +17,10 @@ def hello(request):
class ProductListView(ListAPIView): class ProductListView(ListAPIView):
queryset = Product.objects.all() queryset = Product.objects.all()
serializer_class = ProductSerializer 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', 'api',
'dressstyle', 'dressstyle',
'reviews', 'reviews',
'cart',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -137,5 +138,7 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", "http://localhost:5173",
] ]
CORS_ALLOW_CREDENTIALS = True
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"

View file

@ -12,6 +12,8 @@ urlpatterns = [
# Добавляем reviews # Добавляем reviews
path("api/reviews/", include("reviews.urls")), path("api/reviews/", include("reviews.urls")),
path("api/cart/", include("cart.urls")),
] ]
if settings.DEBUG: 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", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@ -1324,6 +1325,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2428,6 +2442,44 @@
"react": "^19.3.0" "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": { "node_modules/rolldown": {
"version": "1.2.9", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
@ -2478,6 +2530,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@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 Home from './pages/Home/Home'
import ProductDetail from './pages/ProductDetail/ProductDetail'
import Cart from './pages/Cart/Cart' // Убедитесь, что путь правильный
function App() { 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 export default App

View file

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

View file

@ -4,6 +4,11 @@
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
} }
.logoLink {
text-decoration: none;
color: #000;
}
.logo { .logo {
flex-shrink: 0; flex-shrink: 0;
margin: 0; margin: 0;
@ -52,7 +57,7 @@
align-items: center; align-items: center;
width: 320px; width: 320px;
height: 44px; height: 44px;
margin-left: 40px; margin-left: auto;
padding: 0 16px; padding: 0 16px;
border-radius: 24px; border-radius: 24px;
background-color: #f5f5f5; background-color: #f5f5f5;
@ -84,3 +89,52 @@
.searchInput::-webkit-search-cancel-button { .searchInput::-webkit-search-cancel-button {
cursor: pointer; 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 { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: импорт из контекста, а не из hooks
import { navigationItems } from '../../shared/navigation/navigation.data' import { navigationItems } from '../../shared/navigation/navigation.data'
import styles from './Header.module.css' import styles from './Header.module.css'
@ -6,10 +8,19 @@ import styles from './Header.module.css'
function Header() { function Header() {
const [isShopOpen, setIsShopOpen] = useState(false) 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 ( return (
<header className={styles.header}> <header className={styles.header}>
<div className="container"> <div className="container">
<a href="/" className={styles.logoLink}>
<h1 className={styles.logo}>shop.co</h1> <h1 className={styles.logo}>shop.co</h1>
</a>
<nav className={styles.navigation}> <nav className={styles.navigation}>
{navigationItems.map((item) => {navigationItems.map((item) =>
@ -43,19 +54,8 @@ function Header() {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" aria-hidden="true"
> >
<circle <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
cx="11" <path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
cy="11"
r="7"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M16.5 16.5L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg> </svg>
<input <input
@ -64,6 +64,35 @@ function Header() {
className={styles.searchInput} className={styles.searchInput}
/> />
</div> </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> </div>
</header> </header>
) )

View file

@ -39,6 +39,7 @@
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
margin-bottom: 30px;
} }
.viewAll:hover { .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 { .imageWrapper {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
@ -15,8 +38,15 @@ display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
transition: transform 0.4s ease;
} }
/* Эффект легкого приближения картинки при наведении на карточку */
.cardLink:hover .image {
transform: scale(1.05);
}
/* ===== ТЕКСТ И РЕЙТИНГ ===== */
.name { .name {
margin: 11px 0 6px; margin: 11px 0 6px;
overflow: hidden; overflow: hidden;
@ -48,6 +78,7 @@ font-size: 11px;
line-height: 1; line-height: 1;
} }
/* ===== ЦЕНА ===== */
.priceRow { .priceRow {
display: flex; display: flex;
align-items: center; align-items: center;
@ -84,3 +115,86 @@ font-size: 10px;
font-weight: 500; font-weight: 500;
line-height: 1; 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' import styles from './ProductCard.module.css'
type Product = { type Product = {
id: number id: number
name: string name: string
slug: string
image: string image: string
rating: number rating: number
price: string price: string
@ -16,43 +20,88 @@ type ProductsCardProps = {
} }
function ProductsCard({ product }: 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 ( return (
<div className={styles.cardContainer}>
<Link to={`/product/${product.slug}`} className={styles.cardLink}>
<article className={styles.card}> <article className={styles.card}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<img <img src={product.image} alt={product.name} className={styles.image} />
src={product.image}
alt={product.name}
className={styles.image}
/>
</div> </div>
<h3 className={styles.name}>{product.name}</h3> <h3 className={styles.name}>{product.name}</h3>
<div className={styles.rating}> <div className={styles.rating}>
<span className={styles.stars}>★★★★★</span> <span className={styles.stars}>★★★★★</span>
<span className={styles.ratingValue}> <span className={styles.ratingValue}>{product.rating}/5</span>
{product.rating}/5
</span>
</div> </div>
<div className={styles.priceRow}> <div className={styles.priceRow}>
<span className={styles.price}> <span className={styles.price}>${Number(product.price).toFixed(0)}</span>
${Number(product.price).toFixed(0)}
</span>
{product.old_price && ( {product.old_price && (
<span className={styles.oldPrice}> <span className={styles.oldPrice}>${Number(product.old_price).toFixed(0)}</span>
${Number(product.old_price).toFixed(0)}
</span>
)} )}
{product.discount_percent && ( {product.discount_percent && (
<span className={styles.discount}> <span className={styles.discount}>-{product.discount_percent}%</span>
-{product.discount_percent}%
</span>
)} )}
</div> </div>
</article> </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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import './styles/globals.css' import './styles/globals.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter>
<App /> <App />
</BrowserRouter>
</StrictMode>, </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