diff --git a/api/urls.py b/api/urls.py index bdef1f9..26811ed 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,8 +1,15 @@ from django.urls import path - from .views import hello, ProductListView +from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView urlpatterns = [ path("hello/", hello), path("products/", ProductListView.as_view()), + + # Reviews + path("reviews/", ReviewListView.as_view()), + path("reviews/latest/", LatestReviewsView.as_view()), + + # Newsletter + path("newsletter/", NewsletterCreateView.as_view()), ] \ No newline at end of file diff --git a/backend/settings.py b/backend/settings.py index 2fd54a6..ea8b254 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -38,10 +38,11 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', # ============================= - "corsheaders", - "rest_framework", - "api", - "dressstyle", + 'corsheaders', + 'rest_framework', + 'api', + 'dressstyle', + 'reviews', ] MIDDLEWARE = [ diff --git a/backend/urls.py b/backend/urls.py index 34b6873..eedb32f 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -4,14 +4,16 @@ from django.contrib import admin from django.urls import include, path urlpatterns = [ - path("admin/", admin.site.urls), - + path("api/", include("api.urls")), - + path("api/dress-styles/", include("dressstyle.urls")), - + + # Добавляем reviews + path("api/reviews/", include("reviews.urls")), ] + if settings.DEBUG: urlpatterns += static( settings.MEDIA_URL, diff --git a/frontend/src/components/Footer/Footer.module.css b/frontend/src/components/Footer/Footer.module.css new file mode 100644 index 0000000..887c7bf --- /dev/null +++ b/frontend/src/components/Footer/Footer.module.css @@ -0,0 +1,311 @@ +.footer { + background-color: #F0F0F0; + margin-top: 80px; +} + +/* Newsletter секция */ +.newsletterSection { + padding: 0 25px; + margin-top: -60px; + position: relative; + z-index: 10; +} + +.newsletterContainer { + max-width: 1200px; + margin: 0 auto; + background-color: #000; + border-radius: 20px; + padding: 50px 60px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 40px; +} + +.newsletterContent { + flex: 1; +} + +.newsletterTitle { + font-size: 40px; + font-weight: 800; + color: #fff; + text-transform: uppercase; + line-height: 1.2; + margin: 0; + letter-spacing: -1px; +} + +.newsletterForm { + flex: 0 0 400px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.emailIcon { + position: absolute; + left: 20px; + width: 20px; + height: 20px; + color: #999; +} + +.emailInput { + width: 100%; + padding: 16px 20px 16px 52px; + border: none; + border-radius: 50px; + font-size: 14px; + background: #fff; + outline: none; + transition: box-shadow 0.3s ease; +} + +.emailInput:focus { + box-shadow: 0 0 0 3px rgba(255,255,255,0.3); +} + +.emailInput::placeholder { + color: #999; +} + +.subscribeButton { + width: 100%; + padding: 16px 32px; + background: #fff; + color: #000; + border: none; + border-radius: 50px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.subscribeButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(255,255,255,0.2); +} + +.subscribeButton:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.message { + text-align: center; + padding: 10px; + border-radius: 8px; + font-size: 13px; +} + +.success { + background: #10B981; + color: #fff; +} + +.error { + background: #EF4444; + color: #fff; +} + +/* Основной footer */ +.mainFooter { + padding: 80px 25px 40px; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +.mainContent { + display: flex; + justify-content: space-between; + gap: 60px; + margin-bottom: 60px; +} + +.brand { + flex: 0 0 280px; +} + +.brandName { + font-size: 28px; + font-weight: 800; + color: #000; + margin: 0 0 16px 0; + letter-spacing: -1px; +} + +.brandDescription { + font-size: 13px; + line-height: 1.6; + color: #666; + margin: 0 0 24px 0; +} + +.socialLinks { + display: flex; + gap: 12px; +} + +.socialLink { + width: 36px; + height: 36px; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + color: #000; + transition: all 0.3s ease; + border: 1px solid #E0E0E0; +} + +.socialLink:hover { + background: #000; + color: #fff; + transform: translateY(-3px); +} + +.linksGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 40px; + flex: 1; +} + +.linksColumn { + display: flex; + flex-direction: column; +} + +.columnTitle { + font-size: 13px; + font-weight: 700; + color: #000; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0 0 20px 0; +} + +.linksList { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.link { + font-size: 13px; + color: #666; + text-decoration: none; + transition: color 0.3s ease; +} + +.link:hover { + color: #000; +} + +.bottomBar { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 30px; + border-top: 1px solid #D0D0D0; +} + +.copyright { + font-size: 13px; + color: #666; + margin: 0; +} + +.paymentMethods { + display: flex; + gap: 10px; +} + +.paymentIcon { + padding: 6px 12px; + background: #fff; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: #666; + border: 1px solid #E0E0E0; +} + +/* Адаптивность */ +@media (max-width: 1024px) { + .newsletterContainer { + flex-direction: column; + text-align: center; + padding: 40px 30px; + } + + .newsletterForm { + width: 100%; + flex: none; + } + + .mainContent { + flex-direction: column; + gap: 40px; + } + + .brand { + flex: none; + } + + .linksGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .newsletterSection { + margin-top: -40px; + padding: 0 15px; + } + + .newsletterTitle { + font-size: 28px; + } + + .mainFooter { + padding: 60px 15px 30px; + } + + .linksGrid { + grid-template-columns: 1fr; + gap: 30px; + } + + .brandName { + font-size: 24px; + } + + .bottomBar { + flex-direction: column; + gap: 20px; + text-align: center; + } + + .paymentMethods { + flex-wrap: wrap; + justify-content: center; + } +} \ No newline at end of file diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..63a1ef0 --- /dev/null +++ b/frontend/src/components/Footer/Footer.tsx @@ -0,0 +1,221 @@ +import { useState } from 'react' +import styles from './Footer.module.css' + +const footerLinks = { + company: [ + { name: 'About', href: '#' }, + { name: 'Features', href: '#' }, + { name: 'Works', href: '#' }, + { name: 'Career', href: '#' }, + ], + help: [ + { name: 'Customer Support', href: '#' }, + { name: 'Delivery Details', href: '#' }, + { name: 'Terms & Conditions', href: '#' }, + { name: 'Privacy Policy', href: '#' }, + ], + faq: [ + { name: 'Account', href: '#' }, + { name: 'Manage Deliveries', href: '#' }, + { name: 'Orders', href: '#' }, + { name: 'Payments', href: '#' }, + ], + resources: [ + { name: 'Free eBooks', href: '#' }, + { name: 'Development Tutorial', href: '#' }, + { name: 'How to - Blog', href: '#' }, + { name: 'Youtube Playlist', href: '#' }, + ], +} + +function Footer() { + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [message, setMessage] = useState('') + + const currentYear = new Date().getFullYear(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!email) { + setStatus('error') + setMessage('Please enter your email') + return + } + + setStatus('loading') + + try { + const response = await fetch('http://localhost:8000/api/reviews/newsletter/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + + const data = await response.json() + + if (response.ok) { + setStatus('success') + setMessage(data.message) + setEmail('') + setTimeout(() => setStatus('idle'), 3000) + } else { + setStatus('error') + setMessage(data.error || data.message) + } + } catch (error) { + setStatus('error') + setMessage('Something went wrong. Please try again.') + } + } + + return ( + + ) +} + +export default Footer \ No newline at end of file diff --git a/frontend/src/components/Reviews/Reviews.module.css b/frontend/src/components/Reviews/Reviews.module.css new file mode 100644 index 0000000..73ec0ad --- /dev/null +++ b/frontend/src/components/Reviews/Reviews.module.css @@ -0,0 +1,133 @@ +.section { + padding: 80px 25px; + max-width: 1200px; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} + +.title { + font-size: 48px; + font-weight: 800; + text-transform: uppercase; + color: #000; + letter-spacing: -1px; + margin: 0; +} + +.navigation { + display: flex; + gap: 12px; +} + +.navButton { + width: 48px; + height: 48px; + border: 2px solid #000; + background: transparent; + border-radius: 50%; + cursor: pointer; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.navButton:hover { + background: #000; + color: #fff; +} + +.reviewsGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.reviewCard { + background: #fff; + border: 1px solid #E0E0E0; + border-radius: 16px; + padding: 32px; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.reviewCard:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(0,0,0,0.1); +} + +.stars { + display: flex; + gap: 4px; + margin-bottom: 16px; + font-size: 20px; +} + +.starFilled { + color: #FFC107; +} + +.starEmpty { + color: #E0E0E0; +} + +.customer { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.customerName { + font-size: 18px; + font-weight: 700; + color: #000; + margin: 0; +} + +.verifiedBadge { + width: 20px; + height: 20px; + background: #10B981; + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; +} + +.comment { + font-size: 14px; + line-height: 1.6; + color: #666; + margin: 0; +} + +.loading, .empty { + text-align: center; + padding: 80px; + font-size: 18px; + color: #666; +} + +@media (max-width: 968px) { + .reviewsGrid { + grid-template-columns: 1fr; + } + .title { + font-size: 32px; + } + .header { + flex-direction: column; + gap: 20px; + text-align: center; + } +} \ No newline at end of file diff --git a/frontend/src/components/Reviews/Reviews.tsx b/frontend/src/components/Reviews/Reviews.tsx new file mode 100644 index 0000000..51fd2db --- /dev/null +++ b/frontend/src/components/Reviews/Reviews.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import styles from './Reviews.module.css' + +interface Review { + id: number + customer_name: string + rating: number + comment: string + verified: boolean +} + +function Reviews() { + const [reviews, setReviews] = useState([]) + const [loading, setLoading] = useState(true) + const [currentIndex, setCurrentIndex] = useState(0) + + useEffect(() => { + fetch('http://localhost:8000/api/reviews/latest/') + .then(res => res.json()) + .then(data => { + setReviews(data) + setLoading(false) + }) + .catch(err => { + console.error('Error fetching reviews:', err) + setLoading(false) + }) + }, []) + + const nextReview = () => { + setCurrentIndex((prev) => + prev + 3 >= reviews.length ? 0 : prev + 1 + ) + } + + const prevReview = () => { + setCurrentIndex((prev) => + prev - 1 < 0 ? Math.max(0, reviews.length - 3) : prev - 1 + ) + } + + const renderStars = (rating: number) => { + return Array.from({ length: 5 }, (_, i) => ( + + ★ + + )) + } + + if (loading) return Loading reviews... + if (reviews.length === 0) return No reviews yet + + return ( + + + OUR HAPPY CUSTOMERS + + ← + → + + + + + {reviews.slice(currentIndex, currentIndex + 3).map((review) => ( + + + {renderStars(review.rating)} + + + {review.customer_name} + {review.verified && ✓} + + "{review.comment}" + + ))} + + + ) +} + +export default Reviews \ No newline at end of file diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 419f738..1ced32a 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -5,6 +5,8 @@ import Brends from '../../components/Brends/Brends' import Arrivals from '../../components/NewArrivals/NewArrivals' import TopSelling from '../../components/TopSelling/TopSelling' import DressStyle from '../../components/DressStyle/DressStyle' +import Reviews from '../../components/Reviews/Reviews' +import Footer from '../../components/Footer/Footer' function Home() { return ( @@ -22,6 +24,10 @@ function Home() { + + + + > ) } diff --git a/reviews/__init__.py b/reviews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reviews/admin.py b/reviews/admin.py new file mode 100644 index 0000000..4ebd42e --- /dev/null +++ b/reviews/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import Review, NewsletterSubscription + + +@admin.register(Review) +class ReviewAdmin(admin.ModelAdmin): + list_display = ("customer_name", "rating", "verified", "created_at") + list_filter = ("verified", "rating", "created_at") + search_fields = ("customer_name", "comment") + list_editable = ("verified", "rating") + + +@admin.register(NewsletterSubscription) +class NewsletterSubscriptionAdmin(admin.ModelAdmin): + list_display = ("email", "subscribed_at", "is_active") + list_filter = ("is_active", "subscribed_at") + search_fields = ("email",) + readonly_fields = ("subscribed_at",) \ No newline at end of file diff --git a/reviews/apps.py b/reviews/apps.py new file mode 100644 index 0000000..485225c --- /dev/null +++ b/reviews/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ReviewsConfig(AppConfig): + name = 'reviews' diff --git a/reviews/migrations/0001_initial.py b/reviews/migrations/0001_initial.py new file mode 100644 index 0000000..d312318 --- /dev/null +++ b/reviews/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.1.1 on 2026-09-19 04:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='NewsletterSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True)), + ('subscribed_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_name', models.CharField(max_length=100)), + ('rating', models.IntegerField(choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)])), + ('comment', models.TextField()), + ('verified', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/reviews/migrations/__init__.py b/reviews/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reviews/models.py b/reviews/models.py new file mode 100644 index 0000000..68ec0c8 --- /dev/null +++ b/reviews/models.py @@ -0,0 +1,23 @@ +from django.db import models + +class Review(models.Model): + customer_name = models.CharField(max_length=100) + rating = models.IntegerField(choices=[(i, i) for i in range(1, 6)]) + comment = models.TextField() + verified = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Review by {self.customer_name}" + + class Meta: + ordering = ['-created_at'] + + +class NewsletterSubscription(models.Model): + email = models.EmailField(unique=True) + subscribed_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.email \ No newline at end of file diff --git a/reviews/serializers.py b/reviews/serializers.py new file mode 100644 index 0000000..a066063 --- /dev/null +++ b/reviews/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers +from .models import Review, NewsletterSubscription + + +class ReviewSerializer(serializers.ModelSerializer): + class Meta: + model = Review + fields = ["id", "customer_name", "rating", "comment", "verified", "created_at"] + + +class NewsletterSerializer(serializers.ModelSerializer): + class Meta: + model = NewsletterSubscription + fields = ["email"] \ No newline at end of file diff --git a/reviews/tests.py b/reviews/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/reviews/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/reviews/urls.py b/reviews/urls.py new file mode 100644 index 0000000..b329247 --- /dev/null +++ b/reviews/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import ReviewListView, LatestReviewsView, NewsletterCreateView + +urlpatterns = [ + path("", ReviewListView.as_view()), + path("latest/", LatestReviewsView.as_view()), + path("newsletter/", NewsletterCreateView.as_view()), +] \ No newline at end of file diff --git a/reviews/views.py b/reviews/views.py new file mode 100644 index 0000000..796c812 --- /dev/null +++ b/reviews/views.py @@ -0,0 +1,47 @@ +from rest_framework import generics, status +from rest_framework.response import Response +from .models import Review, NewsletterSubscription +from .serializers import ReviewSerializer, NewsletterSerializer + + +class ReviewListView(generics.ListAPIView): + """Все верифицированные отзывы""" + queryset = Review.objects.filter(verified=True) + serializer_class = ReviewSerializer + + +class LatestReviewsView(generics.ListAPIView): + """Последние 6 отзывов для слайдера""" + serializer_class = ReviewSerializer + + def get_queryset(self): + return Review.objects.filter(verified=True).order_by("-created_at")[:6] + + +class NewsletterCreateView(generics.CreateAPIView): + """Подписка на newsletter""" + serializer_class = NewsletterSerializer + + def create(self, request, *args, **kwargs): + email = request.data.get("email") + + if not email: + return Response( + {"error": "Email is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if NewsletterSubscription.objects.filter(email=email).exists(): + return Response( + {"message": "You are already subscribed!"}, + status=status.HTTP_200_OK, + ) + + serializer = self.get_serializer(data={"email": email}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + return Response( + {"message": "Successfully subscribed to newsletter!"}, + status=status.HTTP_201_CREATED, + ) \ No newline at end of file
"{review.comment}"