add final components and pages
This commit is contained in:
parent
f19c6a27d5
commit
3bc141c612
34 changed files with 2669 additions and 114 deletions
23
api/migrations/0002_product_description_product_slug.py
Normal file
23
api/migrations/0002_product_description_product_slug.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||||
fields = (
|
fields = (
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
'image',
|
'image',
|
||||||
'rating',
|
'rating',
|
||||||
'price',
|
'price',
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
]
|
]
|
||||||
10
api/views.py
10
api/views.py
|
|
@ -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
|
||||||
|
|
@ -15,4 +16,11 @@ 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'
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
0
cart/__init__.py
Normal file
3
cart/admin.py
Normal file
3
cart/admin.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
cart/apps.py
Normal file
5
cart/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CartConfig(AppConfig):
|
||||||
|
name = 'cart'
|
||||||
42
cart/migrations/0001_initial.py
Normal file
42
cart/migrations/0001_initial.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
26
cart/migrations/0002_cart_session_key_alter_cart_user.py
Normal file
26
cart/migrations/0002_cart_session_key_alter_cart_user.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
cart/migrations/__init__.py
Normal file
0
cart/migrations/__init__.py
Normal file
42
cart/models.py
Normal file
42
cart/models.py
Normal 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
28
cart/serializers.py
Normal 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
3
cart/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
cart/urls.py
Normal file
10
cart/urls.py
Normal 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
97
cart/views.py
Normal 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)
|
||||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -83,4 +88,53 @@
|
||||||
|
|
||||||
.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
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'
|
||||||
|
|
||||||
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">
|
||||||
<h1 className={styles.logo}>shop.co</h1>
|
<a href="/" className={styles.logoLink}>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,200 @@
|
||||||
.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;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #f1efef;
|
background: #f1efef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
display: block;
|
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;
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating {
|
.rating {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stars {
|
.stars {
|
||||||
color: #ffc633;
|
color: #ffc633;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ratingValue {
|
.ratingValue {
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== ЦЕНА ===== */
|
||||||
.priceRow {
|
.priceRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
color: #000;
|
color: #000;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oldPrice {
|
.oldPrice {
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discount {
|
.discount {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 39px;
|
min-width: 39px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 7px;
|
padding: 0 7px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #ffebeb;
|
background: #ffebeb;
|
||||||
color: #ff3333;
|
color: #ff3333;
|
||||||
font-size: 10px;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
<article className={styles.card}>
|
<div className={styles.cardContainer}>
|
||||||
<div className={styles.imageWrapper}>
|
<Link to={`/product/${product.slug}`} className={styles.cardLink}>
|
||||||
<img
|
<article className={styles.card}>
|
||||||
src={product.image}
|
<div className={styles.imageWrapper}>
|
||||||
alt={product.name}
|
<img src={product.image} alt={product.name} className={styles.image} />
|
||||||
className={styles.image}
|
</div>
|
||||||
/>
|
<h3 className={styles.name}>{product.name}</h3>
|
||||||
</div>
|
<div className={styles.rating}>
|
||||||
|
<span className={styles.stars}>★★★★★</span>
|
||||||
|
<span className={styles.ratingValue}>{product.rating}/5</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceRow}>
|
||||||
|
<span className={styles.price}>${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.discount}>-{product.discount_percent}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<h3 className={styles.name}>{product.name}</h3>
|
{quantityInCart === 0 ? (
|
||||||
|
<button
|
||||||
<div className={styles.rating}>
|
className={styles.quickAddButton}
|
||||||
<span className={styles.stars}>★★★★★</span>
|
onClick={handleAddToCart}
|
||||||
<span className={styles.ratingValue}>
|
disabled={isUpdating}
|
||||||
{product.rating}/5
|
>
|
||||||
</span>
|
{isUpdating ? 'Adding...' : 'Add to Cart'}
|
||||||
</div>
|
</button>
|
||||||
|
) : (
|
||||||
<div className={styles.priceRow}>
|
<div className={styles.quantitySelector}>
|
||||||
<span className={styles.price}>
|
<button
|
||||||
${Number(product.price).toFixed(0)}
|
className={styles.qtyButton}
|
||||||
</span>
|
onClick={() => handleUpdateQuantity(quantityInCart - 1)}
|
||||||
|
disabled={isUpdating}
|
||||||
{product.old_price && (
|
>
|
||||||
<span className={styles.oldPrice}>
|
−
|
||||||
${Number(product.old_price).toFixed(0)}
|
</button>
|
||||||
</span>
|
<span className={styles.quantityValue}>{quantityInCart}</span>
|
||||||
)}
|
<button
|
||||||
|
className={styles.qtyButton}
|
||||||
{product.discount_percent && (
|
onClick={() => handleUpdateQuantity(quantityInCart + 1)}
|
||||||
<span className={styles.discount}>
|
disabled={isUpdating}
|
||||||
-{product.discount_percent}%
|
>
|
||||||
</span>
|
+
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
99
frontend/src/context/CartContext.tsx
Normal file
99
frontend/src/context/CartContext.tsx
Normal 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
|
||||||
|
}
|
||||||
103
frontend/src/hooks/useCart.ts
Normal file
103
frontend/src/hooks/useCart.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
400
frontend/src/pages/Cart/Cart.module.css
Normal file
400
frontend/src/pages/Cart/Cart.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
frontend/src/pages/Cart/Cart.tsx
Normal file
193
frontend/src/pages/Cart/Cart.tsx
Normal 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
|
||||||
745
frontend/src/pages/ProductDetail/ProductDetail.module.css
Normal file
745
frontend/src/pages/ProductDetail/ProductDetail.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
frontend/src/pages/ProductDetail/ProductDetail.tsx
Normal file
378
frontend/src/pages/ProductDetail/ProductDetail.tsx
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue