From 3bc141c61227d205d887bba3ae5ccb5b108e9338 Mon Sep 17 00:00:00 2001 From: Autli Date: Sat, 19 Sep 2026 22:59:51 +0500 Subject: [PATCH] add final components and pages --- .../0002_product_description_product_slug.py | 23 + api/models.py | 21 +- api/serializers.py | 2 + api/urls.py | 4 +- api/views.py | 10 +- backend/settings.py | 3 + backend/urls.py | 2 + cart/__init__.py | 0 cart/admin.py | 3 + cart/apps.py | 5 + cart/migrations/0001_initial.py | 42 + .../0002_cart_session_key_alter_cart_user.py | 26 + cart/migrations/__init__.py | 0 cart/models.py | 42 + cart/serializers.py | 28 + cart/tests.py | 3 + cart/urls.py | 10 + cart/views.py | 97 +++ frontend/package-lock.json | 60 +- frontend/package.json | 3 +- frontend/src/App.tsx | 15 +- .../src/components/Footer/Footer.module.css | 2 +- .../src/components/Header/Header.module.css | 58 +- frontend/src/components/Header/Header.tsx | 57 +- .../NewArrivals/NewArrivals.module.css | 1 + .../ProductCard/ProductCard.module.css | 224 ++++-- .../components/ProductCard/ProductCard.tsx | 119 ++- frontend/src/context/CartContext.tsx | 99 +++ frontend/src/hooks/useCart.ts | 103 +++ frontend/src/main.tsx | 5 +- frontend/src/pages/Cart/Cart.module.css | 400 ++++++++++ frontend/src/pages/Cart/Cart.tsx | 193 +++++ .../ProductDetail/ProductDetail.module.css | 745 ++++++++++++++++++ .../src/pages/ProductDetail/ProductDetail.tsx | 378 +++++++++ 34 files changed, 2669 insertions(+), 114 deletions(-) create mode 100644 api/migrations/0002_product_description_product_slug.py create mode 100644 cart/__init__.py create mode 100644 cart/admin.py create mode 100644 cart/apps.py create mode 100644 cart/migrations/0001_initial.py create mode 100644 cart/migrations/0002_cart_session_key_alter_cart_user.py create mode 100644 cart/migrations/__init__.py create mode 100644 cart/models.py create mode 100644 cart/serializers.py create mode 100644 cart/tests.py create mode 100644 cart/urls.py create mode 100644 cart/views.py create mode 100644 frontend/src/context/CartContext.tsx create mode 100644 frontend/src/hooks/useCart.ts create mode 100644 frontend/src/pages/Cart/Cart.module.css create mode 100644 frontend/src/pages/Cart/Cart.tsx create mode 100644 frontend/src/pages/ProductDetail/ProductDetail.module.css create mode 100644 frontend/src/pages/ProductDetail/ProductDetail.tsx diff --git a/api/migrations/0002_product_description_product_slug.py b/api/migrations/0002_product_description_product_slug.py new file mode 100644 index 0000000..1cc073e --- /dev/null +++ b/api/migrations/0002_product_description_product_slug.py @@ -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), + ), + ] diff --git a/api/models.py b/api/models.py index db12507..cc45925 100644 --- a/api/models.py +++ b/api/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils.text import slugify class Category(models.Model): @@ -20,6 +21,8 @@ class Product(models.Model): related_name='products', ) name = models.CharField(max_length=200) + slug = models.SlugField(max_length=200, unique=True, null=True, blank=True) # <-- ДОБАВЛЕНО + description = models.TextField(blank=True, default='') # <-- ДОБАВЛЕНО image = models.ImageField(upload_to='products/') rating = models.DecimalField(max_digits=2, decimal_places=1) price = models.DecimalField(max_digits=10, decimal_places=2) @@ -36,9 +39,25 @@ class Product(models.Model): return round( (self.old_price - self.price) / self.old_price * 100 ) - return None + @property + def has_discount(self): + return self.old_price is not None and self.old_price > self.price + + def save(self, *args, **kwargs): + # Автоматически генерируем slug из имени, если он пустой + if not self.slug: + base_slug = slugify(self.name, allow_unicode=False) + slug = base_slug + counter = 1 + # Проверяем уникальность slug + while Product.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + class Meta: verbose_name = 'Product' verbose_name_plural = 'Products' diff --git a/api/serializers.py b/api/serializers.py index f5096f7..7339f27 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -11,6 +11,8 @@ class ProductSerializer(serializers.ModelSerializer): fields = ( 'id', 'name', + 'slug', + 'description', 'image', 'rating', 'price', diff --git a/api/urls.py b/api/urls.py index 26811ed..a7436a5 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from .views import hello, ProductListView +from .views import hello, ProductListView, ProductDetailView from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView urlpatterns = [ @@ -12,4 +12,6 @@ urlpatterns = [ # Newsletter path("newsletter/", NewsletterCreateView.as_view()), + + path("products//", ProductDetailView.as_view()), ] \ No newline at end of file diff --git a/api/views.py b/api/views.py index 6d1cd5d..6250865 100644 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,7 @@ from rest_framework.response import Response from rest_framework.decorators import api_view from rest_framework.generics import ListAPIView +from rest_framework import generics from .models import Product from .serializers import ProductSerializer @@ -15,4 +16,11 @@ def hello(request): class ProductListView(ListAPIView): queryset = Product.objects.all() - serializer_class = ProductSerializer \ No newline at end of file + serializer_class = ProductSerializer + + +class ProductDetailView(generics.RetrieveAPIView): + """Получить один товар по slug""" + queryset = Product.objects.all() + serializer_class = ProductSerializer + lookup_field = 'slug' \ No newline at end of file diff --git a/backend/settings.py b/backend/settings.py index ea8b254..1683478 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'api', 'dressstyle', 'reviews', + 'cart', ] MIDDLEWARE = [ @@ -137,5 +138,7 @@ CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", ] +CORS_ALLOW_CREDENTIALS = True + MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" \ No newline at end of file diff --git a/backend/urls.py b/backend/urls.py index eedb32f..bad913a 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -12,6 +12,8 @@ urlpatterns = [ # Добавляем reviews path("api/reviews/", include("reviews.urls")), + + path("api/cart/", include("cart.urls")), ] if settings.DEBUG: diff --git a/cart/__init__.py b/cart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cart/admin.py b/cart/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/cart/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/cart/apps.py b/cart/apps.py new file mode 100644 index 0000000..7cc6ec1 --- /dev/null +++ b/cart/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CartConfig(AppConfig): + name = 'cart' diff --git a/cart/migrations/0001_initial.py b/cart/migrations/0001_initial.py new file mode 100644 index 0000000..4baa6ac --- /dev/null +++ b/cart/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/cart/migrations/0002_cart_session_key_alter_cart_user.py b/cart/migrations/0002_cart_session_key_alter_cart_user.py new file mode 100644 index 0000000..c630a15 --- /dev/null +++ b/cart/migrations/0002_cart_session_key_alter_cart_user.py @@ -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), + ), + ] diff --git a/cart/migrations/__init__.py b/cart/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cart/models.py b/cart/models.py new file mode 100644 index 0000000..d25516b --- /dev/null +++ b/cart/models.py @@ -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 \ No newline at end of file diff --git a/cart/serializers.py b/cart/serializers.py new file mode 100644 index 0000000..8d09785 --- /dev/null +++ b/cart/serializers.py @@ -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() \ No newline at end of file diff --git a/cart/tests.py b/cart/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/cart/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/cart/urls.py b/cart/urls.py new file mode 100644 index 0000000..af30b97 --- /dev/null +++ b/cart/urls.py @@ -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)), +] \ No newline at end of file diff --git a/cart/views.py b/cart/views.py new file mode 100644 index 0000000..c3e81c4 --- /dev/null +++ b/cart/views.py @@ -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) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 59cbcf9..941f51b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1324,6 +1325,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2428,6 +2442,44 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -2478,6 +2530,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 694dbd4..d8ea537 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 280c96c..2ea1a75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,20 @@ +import { Routes, Route } from 'react-router-dom' +import { CartProvider } from './context/CartContext' // <-- Импортируем провайдер + import Home from './pages/Home/Home' +import ProductDetail from './pages/ProductDetail/ProductDetail' +import Cart from './pages/Cart/Cart' // Убедитесь, что путь правильный function App() { - return + return ( + {/* <-- Оборачиваем всё приложение */} + + } /> + } /> + } /> + + + ) } export default App \ No newline at end of file diff --git a/frontend/src/components/Footer/Footer.module.css b/frontend/src/components/Footer/Footer.module.css index 887c7bf..bb9e0f3 100644 --- a/frontend/src/components/Footer/Footer.module.css +++ b/frontend/src/components/Footer/Footer.module.css @@ -6,9 +6,9 @@ /* Newsletter секция */ .newsletterSection { padding: 0 25px; - margin-top: -60px; position: relative; z-index: 10; + transform: translateY(-100px); } .newsletterContainer { diff --git a/frontend/src/components/Header/Header.module.css b/frontend/src/components/Header/Header.module.css index ffa5264..7307f17 100644 --- a/frontend/src/components/Header/Header.module.css +++ b/frontend/src/components/Header/Header.module.css @@ -4,6 +4,11 @@ border-bottom: 1px solid #e8e8e8; } +.logoLink { + text-decoration: none; + color: #000; +} + .logo { flex-shrink: 0; margin: 0; @@ -52,7 +57,7 @@ align-items: center; width: 320px; height: 44px; - margin-left: 40px; + margin-left: auto; padding: 0 16px; border-radius: 24px; background-color: #f5f5f5; @@ -83,4 +88,53 @@ .searchInput::-webkit-search-cancel-button { cursor: pointer; -} \ No newline at end of file +} + +.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; +} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 8da2178..9908f0e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,15 +1,26 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useCart } from '../../context/CartContext' // <-- ВАЖНО: импорт из контекста, а не из hooks import { navigationItems } from '../../shared/navigation/navigation.data' import styles from './Header.module.css' function Header() { const [isShopOpen, setIsShopOpen] = useState(false) + + // Получаем актуальные данные корзины из глобального контекста + const { cart } = useCart() + + // Считаем общее количество товаров в реальном времени + // Если cart или items еще не загрузились, вернет 0 + const totalItems = cart?.items?.reduce((sum: number, item: any) => sum + item.quantity, 0) ?? 0 return (
-

shop.co

+ +

shop.co

+
+ +
+ + + + {/* Показываем бейдж только если товаров > 0 */} + {totalItems > 0 && ( + + {totalItems > 99 ? '99+' : totalItems} + + )} + +
) diff --git a/frontend/src/components/NewArrivals/NewArrivals.module.css b/frontend/src/components/NewArrivals/NewArrivals.module.css index 2f6a677..f9070cb 100644 --- a/frontend/src/components/NewArrivals/NewArrivals.module.css +++ b/frontend/src/components/NewArrivals/NewArrivals.module.css @@ -39,6 +39,7 @@ font-size: 12px; font-weight: 500; cursor: pointer; + margin-bottom: 30px; } .viewAll:hover { diff --git a/frontend/src/components/ProductCard/ProductCard.module.css b/frontend/src/components/ProductCard/ProductCard.module.css index 7a5249c..d5d3b45 100644 --- a/frontend/src/components/ProductCard/ProductCard.module.css +++ b/frontend/src/components/ProductCard/ProductCard.module.css @@ -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 { -width: 100%; -aspect-ratio: 1; -overflow: hidden; -border-radius: 12px; -background: #f1efef; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + border-radius: 12px; + background: #f1efef; } .image { -display: block; -width: 100%; -height: 100%; -object-fit: contain; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.4s ease; } +/* Эффект легкого приближения картинки при наведении на карточку */ +.cardLink:hover .image { + transform: scale(1.05); +} + +/* ===== ТЕКСТ И РЕЙТИНГ ===== */ .name { -margin: 11px 0 6px; -overflow: hidden; -color: #000; -font-size: 14px; -font-weight: 700; -line-height: 1.25; -text-overflow: ellipsis; -white-space: nowrap; + margin: 11px 0 6px; + overflow: hidden; + color: #000; + font-size: 14px; + font-weight: 700; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; } .rating { -display: flex; -align-items: center; -gap: 7px; -height: 17px; + display: flex; + align-items: center; + gap: 7px; + height: 17px; } .stars { -color: #ffc633; -font-size: 15px; -line-height: 1; -letter-spacing: 0; + color: #ffc633; + font-size: 15px; + line-height: 1; + letter-spacing: 0; } .ratingValue { -color: #555; -font-size: 11px; -line-height: 1; + color: #555; + font-size: 11px; + line-height: 1; } +/* ===== ЦЕНА ===== */ .priceRow { -display: flex; -align-items: center; -gap: 9px; -margin-top: 6px; + display: flex; + align-items: center; + gap: 9px; + margin-top: 6px; } .price { -color: #000; -font-size: 18px; -font-weight: 700; -line-height: 1; + color: #000; + font-size: 18px; + font-weight: 700; + line-height: 1; } .oldPrice { -color: #999; -font-size: 16px; -font-weight: 600; -line-height: 1; -text-decoration: line-through; + color: #999; + font-size: 16px; + font-weight: 600; + line-height: 1; + text-decoration: line-through; } .discount { -display: inline-flex; -align-items: center; -justify-content: center; -min-width: 39px; -height: 20px; -padding: 0 7px; -border-radius: 20px; -background: #ffebeb; -color: #ff3333; -font-size: 10px; -font-weight: 500; -line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 39px; + height: 20px; + padding: 0 7px; + border-radius: 20px; + background: #ffebeb; + color: #ff3333; + font-size: 10px; + font-weight: 500; + line-height: 1; } + +/* ===== КНОПКА "ADD TO CART" ===== */ +.quickAddButton { + width: 100%; + padding: 14px 20px; + background-color: #000; + color: #fff; + border: none; + border-radius: 30px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + font-family: inherit; +} + +.quickAddButton:hover:not(:disabled) { + background-color: #333; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.quickAddButton:active:not(:disabled) { + transform: translateY(0); +} + +.quickAddButton:disabled { + background-color: #999; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + + +/* ===== СЧЕТЧИКО КОЛИЧЕСТВА В КАРТОЧКЕ ===== */ +.quantitySelector { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + width: 100%; + padding: 10px 20px; + border-radius: 30px; + background-color: #f5f5f5; + box-sizing: border-box; +} + +.qtyButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + background: transparent; + color: #111; + font-family: inherit; + font-size: 20px; + line-height: 1; + cursor: pointer; + transition: all 0.2s ease; +} + +.qtyButton:hover:not(:disabled) { + background-color: #e0e0e0; + border-radius: 50%; +} + +.qtyButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.quantityValue { + min-width: 24px; + text-align: center; + font-size: 15px; + font-weight: 600; + color: #000; +} \ No newline at end of file diff --git a/frontend/src/components/ProductCard/ProductCard.tsx b/frontend/src/components/ProductCard/ProductCard.tsx index c5a88e0..59f2d33 100644 --- a/frontend/src/components/ProductCard/ProductCard.tsx +++ b/frontend/src/components/ProductCard/ProductCard.tsx @@ -1,8 +1,12 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useCart } from '../../context/CartContext' // <-- ВАЖНО: только этот импорт! import styles from './ProductCard.module.css' type Product = { id: number name: string + slug: string image: string rating: number price: string @@ -16,43 +20,88 @@ type ProductsCardProps = { } function ProductsCard({ product }: ProductsCardProps) { + // Берем функции из глобального контекста + const { cart, addToCart, updateQuantity } = useCart() + const [isUpdating, setIsUpdating] = useState(false) + + // Ищем этот товар в глобальной корзине + const cartItem = cart?.items?.find((item: any) => item.product.id === product.id) + const quantityInCart = cartItem ? cartItem.quantity : 0 + const cartItemId = cartItem ? cartItem.id : null + + const handleAddToCart = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsUpdating(true) + + // Вызываем функцию из контекста, она сама обновит всё приложение + await addToCart(product.id, 1, 'M', 'Default') + + setIsUpdating(false) + } + + const handleUpdateQuantity = async (newQuantity: number) => { + if (!cartItemId) return + setIsUpdating(true) + + // Вызываем функцию из контекста + await updateQuantity(cartItemId, newQuantity) + + setIsUpdating(false) + } + return ( -
-
- {product.name} -
+
+ +
+
+ {product.name} +
+

{product.name}

+
+ ★★★★★ + {product.rating}/5 +
+
+ ${Number(product.price).toFixed(0)} + {product.old_price && ( + ${Number(product.old_price).toFixed(0)} + )} + {product.discount_percent && ( + -{product.discount_percent}% + )} +
+
+ -

{product.name}

- -
- ★★★★★ - - {product.rating}/5 - -
- -
- - ${Number(product.price).toFixed(0)} - - - {product.old_price && ( - - ${Number(product.old_price).toFixed(0)} - - )} - - {product.discount_percent && ( - - -{product.discount_percent}% - - )} -
-
+ {quantityInCart === 0 ? ( + + ) : ( +
+ + {quantityInCart} + +
+ )} + ) } diff --git a/frontend/src/context/CartContext.tsx b/frontend/src/context/CartContext.tsx new file mode 100644 index 0000000..e4a104a --- /dev/null +++ b/frontend/src/context/CartContext.tsx @@ -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 + updateQuantity: (item_id: number, quantity: number) => Promise + removeFromCart: (item_id: number) => Promise +} + +const CartContext = createContext(undefined) + +export function CartProvider({ children }: { children: ReactNode }) { + const [cart, setCart] = useState(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 ( + + {children} + + ) +} + +export const useCart = () => { + const context = useContext(CartContext) + if (!context) { + throw new Error('useCart must be used within a CartProvider') + } + return context +} \ No newline at end of file diff --git a/frontend/src/hooks/useCart.ts b/frontend/src/hooks/useCart.ts new file mode 100644 index 0000000..c9bdf58 --- /dev/null +++ b/frontend/src/hooks/useCart.ts @@ -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 } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d3010a..7aba42e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import App from './App' import './styles/globals.css' createRoot(document.getElementById('root')!).render( - + + + , ) \ No newline at end of file diff --git a/frontend/src/pages/Cart/Cart.module.css b/frontend/src/pages/Cart/Cart.module.css new file mode 100644 index 0000000..d25f1ca --- /dev/null +++ b/frontend/src/pages/Cart/Cart.module.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/pages/Cart/Cart.tsx b/frontend/src/pages/Cart/Cart.tsx new file mode 100644 index 0000000..f9ff1f5 --- /dev/null +++ b/frontend/src/pages/Cart/Cart.tsx @@ -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(null) + + // Если корзина еще загружается + if (!cart) { + return
Loading cart...
+ } + + 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 ( + <> + +
+ +
+
+
+ Home + › + Cart +
+ +

YOUR CART

+ +
+
+ {cartItems.length === 0 ? ( +
+

Your cart is empty

+

Add some products to your cart to continue.

+
+ ) : ( + cartItems.map((item) => ( +
+
+ {item.product.name} +
+ +
+

{item.product.name}

+
+ Size: {item.size || 'N/A'} + | + Color: {item.color || 'N/A'} +
+

+ ${Number(item.product.price).toFixed(0)} +

+
+ +
+ + {item.quantity} + +
+ + +
+ )) + )} +
+ + +
+
+
+ +