add new pages

This commit is contained in:
Nikita 2026-09-27 11:27:54 +05:00
parent dce8b7eab5
commit 2ede0d8641
21 changed files with 3014 additions and 44 deletions

View file

@ -0,0 +1,282 @@
[
{
"model": "api.category",
"pk": 1,
"fields": {
"name": "New Arrivals",
"slug": "new-arrivals"
}
},
{
"model": "api.category",
"pk": 2,
"fields": {
"name": "Top Selling",
"slug": "top-selling"
}
},
{
"model": "api.category",
"pk": 3,
"fields": {
"name": "On Sale",
"slug": "on-sale"
}
},
{
"model": "api.product",
"pk": 1,
"fields": {
"category": 1,
"name": "T-shirt with Tape Details",
"slug": "t-shirt-with-tape-details",
"description": "A relaxed-fit t-shirt featuring sporty tape details along the shoulders and sleeves. Crafted from 100% breathable cotton for all-day comfort. Perfect for casual outings or layering under a jacket.",
"image": "products/t-shirt.png",
"rating": "4.5",
"price": "120.00",
"old_price": null
}
},
{
"model": "api.product",
"pk": 2,
"fields": {
"category": 1,
"name": "Skinny Fit Jeans",
"slug": "skinny-fit-jeans",
"description": "Slim silhouette jeans with a mid-rise waist, crafted from stretch denim that moves with you. The classic five-pocket design and clean finish make them effortlessly versatile from day to night.",
"image": "products/skinny.png",
"rating": "3.5",
"price": "240.00",
"old_price": "260.00"
}
},
{
"model": "api.product",
"pk": 3,
"fields": {
"category": 1,
"name": "Checkered Shirt",
"slug": "checkered-shirt",
"description": "A timeless checkered pattern shirt in a relaxed regular fit. Woven from lightweight cotton flannel, it's ideal for layering or wearing open over a tee. Available in classic colour combinations.",
"image": "products/checkered.png",
"rating": "4.5",
"price": "180.00",
"old_price": null
}
},
{
"model": "api.product",
"pk": 4,
"fields": {
"category": 1,
"name": "Sleeve Striped T-Shirt",
"slug": "sleeve-striped-t-shirt",
"description": "A modern spin on a classic design — bold stripes run along the sleeves while the body stays clean. Made from soft jersey fabric with a relaxed fit and a crew neckline. Effortlessly cool.",
"image": "products/sleeve.png",
"rating": "4.5",
"price": "130.00",
"old_price": "160.00"
}
},
{
"model": "api.product",
"pk": 5,
"fields": {
"category": 2,
"name": "Vertical Striped Shirt",
"slug": "vertical-striped-shirt",
"description": "Classic vertical stripes give this button-up shirt a polished, elongating silhouette. Made from a crisp cotton-blend fabric that keeps its shape all day. Great for smart-casual occasions.",
"image": "products/Striped.png",
"rating": "5.0",
"price": "212.00",
"old_price": "232.00"
}
},
{
"model": "api.product",
"pk": 6,
"fields": {
"category": 2,
"name": "Courage Graphic T-shirt",
"slug": "courage-graphic-t-shirt",
"description": "Express yourself with this bold graphic tee. Features a large front print on heavyweight cotton. The relaxed fit and ribbed crew neck make it a wardrobe staple for any creative individual.",
"image": "products/Courage.png",
"rating": "4.0",
"price": "145.00",
"old_price": null
}
},
{
"model": "api.product",
"pk": 7,
"fields": {
"category": 2,
"name": "Loose Fit Bermuda Shorts",
"slug": "loose-fit-bermuda-shorts",
"description": "Comfortable, relaxed-fit Bermuda shorts perfect for warm days. Features side pockets, a drawstring waist, and a clean hem. Made from lightweight twill fabric that keeps you cool and stylish.",
"image": "products/Loose.png",
"rating": "3.0",
"price": "80.00",
"old_price": null
}
},
{
"model": "api.product",
"pk": 8,
"fields": {
"category": 2,
"name": "Faded Skinny Jeans",
"slug": "faded-skinny-jeans",
"description": "Faded wash skinny jeans with a worn-in look that gets better over time. Slim through the hip and thigh, with a tapered leg opening. Made from a stretch-denim blend for maximum comfort.",
"image": "products/Faded.png",
"rating": "4.5",
"price": "210.00",
"old_price": null
}
},
{
"model": "api.product",
"pk": 9,
"fields": {
"category": 3,
"name": "Classic White Polo",
"slug": "classic-white-polo",
"description": "A wardrobe essential — the classic pique polo in crisp white. Features a two-button placket, ribbed collar, and cuffed short sleeves. Made from breathable cotton for smart-casual looks.",
"image": "products/t-shirt.png",
"rating": "4.5",
"price": "95.00",
"old_price": "130.00"
}
},
{
"model": "api.product",
"pk": 10,
"fields": {
"category": 3,
"name": "Slim Chino Pants",
"slug": "slim-chino-pants",
"description": "Sharp, versatile slim chinos in a refined cotton blend. Sits slightly below the waist with a neat taper from the knee to the leg opening. A smart alternative to jeans for any occasion.",
"image": "products/Faded.png",
"rating": "4.0",
"price": "110.00",
"old_price": "149.00"
}
},
{
"model": "api.product",
"pk": 11,
"fields": {
"category": 3,
"name": "Graphic Print Hoodie",
"slug": "graphic-print-hoodie",
"description": "A heavyweight pullover hoodie with a bold chest graphic. Features a lined hood with adjustable drawcords, a kangaroo pocket, and ribbed cuffs. Perfect for streetwear layering.",
"image": "products/Courage.png",
"rating": "4.5",
"price": "175.00",
"old_price": "220.00"
}
},
{
"model": "api.product",
"pk": 12,
"fields": {
"category": 3,
"name": "Relaxed Linen Shorts",
"slug": "relaxed-linen-shorts",
"description": "Breathable linen shorts with a relaxed fit and elasticated waistband. The natural texture adds a laid-back summer aesthetic, while the side pockets provide practicality.",
"image": "products/Loose.png",
"rating": "3.5",
"price": "65.00",
"old_price": "89.00"
}
},
{
"model": "reviews.review",
"pk": 1,
"fields": {
"customer_name": "Sarah M.",
"rating": 5,
"comment": "I'm blown away by the quality and style of the clothes I received from Shop.co. From casual wear to elegant dresses, every piece I've bought has exceeded my expectations.",
"verified": true,
"created_at": "2024-08-14T10:30:00Z"
}
},
{
"model": "reviews.review",
"pk": 2,
"fields": {
"customer_name": "Alex K.",
"rating": 5,
"comment": "Finding clothes that align with my personal style used to be a challenge until I discovered Shop.co. The range of options they offer is truly remarkable, catering to a variety of tastes and occasions.",
"verified": true,
"created_at": "2024-08-12T14:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 3,
"fields": {
"customer_name": "James L.",
"rating": 5,
"comment": "As someone who's always on the lookout for unique fashion pieces, I'm thrilled to have stumbled upon Shop.co. The selection of clothes is not only diverse but also on-point with the latest trends.",
"verified": true,
"created_at": "2024-08-10T09:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 4,
"fields": {
"customer_name": "Moana H.",
"rating": 4,
"comment": "I absolutely love this t-shirt! The design is unique and the fabric feels so comfortable. As a fellow designer, I appreciate the attention to detail. It's become my favorite go-to shirt.",
"verified": true,
"created_at": "2024-08-08T11:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 5,
"fields": {
"customer_name": "Ethan P.",
"rating": 5,
"comment": "I'm not just wearing a piece of clothing; I'm wearing a piece of art. The intricate details and vibrant colours have made it my go-to source for unique and stylish fashion.",
"verified": true,
"created_at": "2024-08-06T16:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 6,
"fields": {
"customer_name": "Emily R.",
"rating": 5,
"comment": "The fabric quality is outstanding and the delivery was super fast. I ordered three pieces and all of them fit perfectly. Will definitely be ordering again!",
"verified": true,
"created_at": "2024-08-04T08:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 7,
"fields": {
"customer_name": "Marcus T.",
"rating": 4,
"comment": "Great selection and competitive prices. The packaging was eco-friendly which I really appreciate. The shirt I got is exactly as described in the photos.",
"verified": true,
"created_at": "2024-08-02T12:00:00Z"
}
},
{
"model": "reviews.review",
"pk": 8,
"fields": {
"customer_name": "Olivia N.",
"rating": 5,
"comment": "Shop.co has completely changed how I shop for clothes online. The size guide is accurate, the return process is seamless, and the quality is consistently excellent across all their collections.",
"verified": true,
"created_at": "2024-07-30T15:00:00Z"
}
}
]

View file

@ -1,6 +1,11 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Product, Category
from .models import Product
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ('id', 'name', 'slug')
class ProductSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.ModelSerializer):

View file

@ -1,17 +1,18 @@
from django.urls import path from django.urls import path
from .views import hello, ProductListView, ProductDetailView from .views import hello, ProductListView, ProductDetailView, CategoryListView
from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView from reviews.views import ReviewListView, LatestReviewsView, NewsletterCreateView
urlpatterns = [ urlpatterns = [
path("hello/", hello), path("hello/", hello),
path("products/", ProductListView.as_view()), path("products/", ProductListView.as_view()),
path("categories/", CategoryListView.as_view()),
# Reviews # Reviews
path("reviews/", ReviewListView.as_view()), path("reviews/", ReviewListView.as_view()),
path("reviews/latest/", LatestReviewsView.as_view()), path("reviews/latest/", LatestReviewsView.as_view()),
# Newsletter # Newsletter
path("newsletter/", NewsletterCreateView.as_view()), path("newsletter/", NewsletterCreateView.as_view()),
path("products/<slug:slug>/", ProductDetailView.as_view()), path("products/<slug:slug>/", ProductDetailView.as_view()),
] ]

View file

@ -1,10 +1,10 @@
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 import generics from rest_framework import generics
from django.db.models import Q
from .models import Product from .models import Product, Category
from .serializers import ProductSerializer from .serializers import ProductSerializer, CategorySerializer
@api_view(["GET"]) @api_view(["GET"])
@ -14,13 +14,43 @@ def hello(request):
}) })
class ProductListView(ListAPIView): class ProductListView(generics.ListAPIView):
queryset = Product.objects.all()
serializer_class = ProductSerializer serializer_class = ProductSerializer
def get_queryset(self):
qs = Product.objects.select_related('category').all()
# ?category=on-sale or ?category=new-arrivals etc.
category_slug = self.request.query_params.get('category')
if category_slug:
# Special virtual filter: "on-sale" = products that have old_price set
if category_slug == 'on-sale':
qs = qs.filter(old_price__isnull=False)
else:
qs = qs.filter(category__slug=category_slug)
# ?sale=true → only products with discount
sale = self.request.query_params.get('sale')
if sale == 'true':
qs = qs.filter(old_price__isnull=False)
# ?search=jeans
search = self.request.query_params.get('search')
if search:
qs = qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
return qs
class ProductDetailView(generics.RetrieveAPIView): class ProductDetailView(generics.RetrieveAPIView):
"""Получить один товар по slug""" """Получить один товар по slug"""
queryset = Product.objects.all() queryset = Product.objects.all()
serializer_class = ProductSerializer serializer_class = ProductSerializer
lookup_field = 'slug' lookup_field = 'slug'
class CategoryListView(generics.ListAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer

Binary file not shown.

View file

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

View file

@ -34,6 +34,18 @@
overflow: hidden; overflow: hidden;
border-radius: 14px; border-radius: 14px;
background: #f1efef; background: #f1efef;
display: flex;
align-items: center;
justify-content: center;
}
.placeholderIcon {
font-size: 64px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
} }
.image { .image {

View file

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import styles from './DressStyle.module.css' import styles from './DressStyle.module.css'
type DressStyleItem = { type DressStyleItem = {
@ -8,6 +7,18 @@ type DressStyleItem = {
image: string image: string
} }
// Статические fallback данные если API пустой
const FALLBACK_STYLES: DressStyleItem[] = [
{ id: 1, name: 'Casual', image: '' },
{ id: 2, name: 'Formal', image: '' },
{ id: 3, name: 'Party', image: '' },
{ id: 4, name: 'Gym', image: '' },
]
// Цвета для fallback карточек
const CARD_COLORS = ['#F5E6D3', '#D3E6F5', '#F5D3E6', '#D3F5D3']
const CARD_ICONS = ['☀️', '👔', '🎉', '💪']
function DressStyle() { function DressStyle() {
const [dressStyles, setDressStyles] = useState<DressStyleItem[]>([]) const [dressStyles, setDressStyles] = useState<DressStyleItem[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -16,7 +27,14 @@ function DressStyle() {
fetch('http://127.0.0.1:8000/api/dress-styles/') fetch('http://127.0.0.1:8000/api/dress-styles/')
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
setDressStyles(data) if (Array.isArray(data) && data.length > 0) {
setDressStyles(data)
} else {
setDressStyles(FALLBACK_STYLES)
}
})
.catch(() => {
setDressStyles(FALLBACK_STYLES)
}) })
.finally(() => { .finally(() => {
setIsLoading(false) setIsLoading(false)
@ -33,13 +51,23 @@ function DressStyle() {
<h2 className={styles.title}>BROWSE BY DRESS STYLE</h2> <h2 className={styles.title}>BROWSE BY DRESS STYLE</h2>
<div className={styles.styles}> <div className={styles.styles}>
{dressStyles.map((dressStyle) => ( {dressStyles.map((dressStyle, idx) => (
<article key={dressStyle.id} className={styles.card}> <article
<img key={dressStyle.id}
src={dressStyle.image} className={styles.card}
alt={dressStyle.name} style={!dressStyle.image ? { backgroundColor: CARD_COLORS[idx % CARD_COLORS.length] } : undefined}
className={styles.image} >
/> {dressStyle.image ? (
<img
src={dressStyle.image}
alt={dressStyle.name}
className={styles.image}
/>
) : (
<div className={styles.placeholderIcon}>
{CARD_ICONS[idx % CARD_ICONS.length]}
</div>
)}
<h3 className={styles.name}>{dressStyle.name}</h3> <h3 className={styles.name}>{dressStyle.name}</h3>
</article> </article>

View file

@ -50,6 +50,92 @@
.arrow { .arrow {
font-size: 9px; font-size: 9px;
line-height: 1; line-height: 1;
transition: transform 0.25s ease;
}
.arrowOpen {
transform: rotate(180deg);
}
.navigationItemActive {
opacity: 0.7;
}
/* ── Shop Dropdown ── */
.shopWrapper {
position: relative;
}
.dropdown {
position: absolute;
top: calc(100% + 16px);
left: 50%;
transform: translateX(-50%);
z-index: 200;
min-width: 340px;
background: #fff;
border: 1px solid #E8E8E8;
border-radius: 20px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
padding: 20px;
animation: dropdownIn 0.2s ease;
}
@keyframes dropdownIn {
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Pointer arrow */
.dropdown::before {
content: '';
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 14px;
background: #fff;
border-left: 1px solid #E8E8E8;
border-top: 1px solid #E8E8E8;
rotate: 45deg;
}
.dropdownGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.dropdownSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.dropdownSectionTitle {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #999;
}
.dropdownLink {
display: block;
padding: 8px 12px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: #111;
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.dropdownLink:hover {
background: #F5F5F5;
color: #000;
} }
.search { .search {

View file

@ -1,20 +1,31 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: импорт из контекста, а не из hooks import { useCart } from '../../context/CartContext'
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 shopRef = useRef<HTMLDivElement>(null)
// Получаем актуальные данные корзины из глобального контекста // Получаем актуальные данные корзины из глобального контекста
const { cart } = useCart() const { cart } = useCart()
// Считаем общее количество товаров в реальном времени // Считаем общее количество товаров в реальном времени
// Если cart или items еще не загрузились, вернет 0
const totalItems = cart?.items?.reduce((sum: number, item: any) => sum + item.quantity, 0) ?? 0 const totalItems = cart?.items?.reduce((sum: number, item: any) => sum + item.quantity, 0) ?? 0
// Закрываем дропдаун при клике вне его
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (shopRef.current && !shopRef.current.contains(e.target as Node)) {
setIsShopOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return ( return (
<header className={styles.header}> <header className={styles.header}>
<div className="container"> <div className="container">
@ -25,23 +36,65 @@ function Header() {
<nav className={styles.navigation}> <nav className={styles.navigation}>
{navigationItems.map((item) => {navigationItems.map((item) =>
item.children ? ( item.children ? (
<button // Shop с выпадающим меню
<div
key={item.label} key={item.label}
type="button" className={styles.shopWrapper}
className={styles.navigationItem} ref={shopRef}
onClick={() => setIsShopOpen(!isShopOpen)}
> >
{item.label} <button
<span className={styles.arrow}>▼</span> type="button"
</button> className={`${styles.navigationItem} ${isShopOpen ? styles.navigationItemActive : ''}`}
onClick={() => setIsShopOpen(!isShopOpen)}
aria-haspopup="true"
aria-expanded={isShopOpen}
>
{item.label}
<span className={`${styles.arrow} ${isShopOpen ? styles.arrowOpen : ''}`}>▼</span>
</button>
{isShopOpen && (
<div className={styles.dropdown}>
<div className={styles.dropdownGrid}>
{/* Категории */}
<div className={styles.dropdownSection}>
<p className={styles.dropdownSectionTitle}>Categories</p>
{item.children.map((child) => (
<Link
key={child.href}
to={child.href!}
className={styles.dropdownLink}
onClick={() => setIsShopOpen(false)}
>
{child.label}
</Link>
))}
</div>
{/* Быстрые ссылки */}
<div className={styles.dropdownSection}>
<p className={styles.dropdownSectionTitle}>Discover</p>
<Link to="/new-arrivals" className={styles.dropdownLink} onClick={() => setIsShopOpen(false)}>
✨ New Arrivals
</Link>
<Link to="/sale" className={styles.dropdownLink} onClick={() => setIsShopOpen(false)}>
🔥 On Sale
</Link>
<Link to="/brands" className={styles.dropdownLink} onClick={() => setIsShopOpen(false)}>
🏷️ Brands
</Link>
</div>
</div>
</div>
)}
</div>
) : ( ) : (
<a <Link
key={item.href} key={item.href}
href={item.href} to={item.href!}
className={styles.navigationItem} className={styles.navigationItem}
> >
{item.label} {item.label}
</a> </Link>
), ),
)} )}
</nav> </nav>
@ -85,7 +138,6 @@ function Header() {
<circle cx="17" cy="20" r="1.5" fill="currentColor" /> <circle cx="17" cy="20" r="1.5" fill="currentColor" />
</svg> </svg>
{/* Показываем бейдж только если товаров > 0 */}
{totalItems > 0 && ( {totalItems > 0 && (
<span className={styles.cartCount}> <span className={styles.cartCount}>
{totalItems > 99 ? '99+' : totalItems} {totalItems > 99 ? '99+' : totalItems}

View file

@ -6,6 +6,7 @@ import ProductsCard from '../ProductCard/ProductCard'
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

View file

@ -6,6 +6,7 @@ import ProductsCard from '../ProductCard/ProductCard'
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

View file

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
interface CartContextType { interface CartContextType {
cart: any cart: any

View file

@ -0,0 +1,451 @@
.page {
min-height: 80vh;
}
/* ─── Hero ─────────────────────── */
.hero {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
padding: 72px 24px 60px;
color: #fff;
text-align: center;
}
.heroInner {
max-width: 720px;
margin: 0 auto;
}
.heroTitle {
margin: 0 0 20px;
font-size: 64px;
font-weight: 900;
letter-spacing: -3px;
line-height: 1;
}
.heroSub {
margin: 0 0 40px;
font-size: 17px;
color: rgba(255,255,255,0.62);
line-height: 1.65;
}
/* Stats */
.stats {
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat strong {
font-size: 32px;
font-weight: 800;
letter-spacing: -1px;
color: #fff;
}
.stat span {
font-size: 12px;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 1px;
}
/* ─── Container ─────────────────────── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 32px;
font-size: 14px;
color: #777;
}
.breadcrumbLink {
color: #777;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumbLink:hover { color: #000; }
.sep { color: #bbb; }
/* ─── Search ─────────────────────── */
.searchWrapper {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 40px;
}
.searchBox {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px;
border: 1.5px solid #e8e8e8;
border-radius: 14px;
background: #fff;
transition: border-color 0.2s;
}
.searchBox:focus-within { border-color: #000; }
.searchIcon {
width: 20px;
height: 20px;
color: #999;
flex-shrink: 0;
}
.searchInput {
flex: 1;
border: 0;
outline: none;
font-family: inherit;
font-size: 15px;
color: #111;
background: transparent;
}
.searchInput::placeholder { color: #bbb; }
.searchCount {
font-size: 14px;
color: #888;
white-space: nowrap;
flex-shrink: 0;
margin: 0;
}
/* ─── Section ─────────────────────── */
.section {
margin-bottom: 52px;
}
.sectionTitle {
margin: 0 0 24px;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.5px;
color: #000;
}
/* ─── Featured Cards (top 3) ─────────────────────── */
.featuredGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.featuredCard {
padding: 28px 28px 24px;
background: var(--bg);
border-radius: 24px;
border: 1px solid transparent;
text-decoration: none;
color: #000;
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.featuredCard:hover {
transform: translateY(-6px);
box-shadow: 0 16px 40px rgba(0,0,0,0.1);
border-color: var(--accent, #ccc);
}
.featuredCardTop {
display: flex;
align-items: center;
justify-content: space-between;
}
.cardEmoji { font-size: 36px; }
.featuredBadge {
display: inline-block;
padding: 4px 12px;
border-radius: 30px;
background: #000;
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.cardName {
margin: 0;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.5px;
}
.cardTagline {
margin: 0;
font-size: 13px;
color: #555;
line-height: 1.5;
}
.cardFooter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.cardItems {
font-size: 13px;
font-weight: 600;
color: #777;
}
.cardArrow {
font-size: 13px;
font-weight: 700;
color: var(--accent, #000);
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s, transform 0.2s;
}
.featuredCard:hover .cardArrow {
opacity: 1;
transform: translateX(0);
}
/* ─── All Brands Grid ─────────────────────── */
.brandsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.brandCard {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 20px;
background: var(--bg);
border-radius: 18px;
border: 1px solid transparent;
text-decoration: none;
color: #000;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
}
.brandCard:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
border-color: var(--accent, #ccc);
}
.brandEmoji { font-size: 28px; flex-shrink: 0; }
.brandInfo { flex: 1; min-width: 0; }
.brandName {
margin: 0 0 3px;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.3px;
}
.brandTagline {
margin: 0 0 5px;
font-size: 12px;
color: #666;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.brandItems {
font-size: 11px;
font-weight: 600;
color: var(--accent, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.brandArrow {
font-size: 16px;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.2s, transform 0.2s;
flex-shrink: 0;
color: var(--accent, #000);
}
.brandCard:hover .brandArrow {
opacity: 1;
transform: translateX(0);
}
/* ─── Empty ─────────────────────── */
.empty {
text-align: center;
padding: 80px 40px;
border: 1px solid #e8e8e8;
border-radius: 24px;
margin-bottom: 48px;
}
.emptyIcon { font-size: 52px; margin-bottom: 20px; }
.empty h3 { margin: 0 0 8px; font-size: 20px; font-weight: 700; }
.empty p { margin: 0 0 24px; color: #666; }
.emptyBtn {
padding: 12px 28px;
border: 0;
border-radius: 30px;
background: #000;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.emptyBtn:hover { background: #333; }
/* ─── Partner CTA ─────────────────────── */
.partnerCta {
display: grid;
grid-template-columns: 1fr auto;
gap: 48px;
align-items: center;
padding: 48px 52px;
background: #f7f7f7;
border: 1px solid #e8e8e8;
border-radius: 28px;
}
.partnerTitle {
margin: 0 0 12px;
font-size: 28px;
font-weight: 800;
letter-spacing: -1px;
color: #000;
}
.partnerText {
margin: 0 0 28px;
font-size: 15px;
color: #555;
line-height: 1.6;
max-width: 420px;
}
.partnerActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.partnerBtn {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 28px;
border-radius: 30px;
background: #000;
color: #fff;
text-decoration: none;
font-size: 14px;
font-weight: 700;
transition: background 0.2s, transform 0.2s;
}
.partnerBtn:hover { background: #222; transform: translateY(-2px); }
.partnerBtnOutline {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 24px;
border-radius: 30px;
border: 1.5px solid #000;
color: #000;
text-decoration: none;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.partnerBtnOutline:hover { background: #000; color: #fff; }
.partnerBenefits {
display: flex;
flex-direction: column;
gap: 14px;
}
.partnerBenefit {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: #333;
white-space: nowrap;
}
.partnerBenefit span:first-child {
width: 22px;
height: 22px;
border-radius: 50%;
background: #000;
color: #fff;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ─── Responsive ─────────────────────── */
@media (max-width: 1024px) {
.featuredGrid { grid-template-columns: repeat(2, 1fr); }
.brandsGrid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.heroTitle { font-size: 42px; }
.stats { gap: 24px; }
.partnerCta { grid-template-columns: 1fr; padding: 32px 28px; }
}
@media (max-width: 600px) {
.featuredGrid { grid-template-columns: 1fr; }
.brandsGrid { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.heroTitle { font-size: 32px; }
}

View file

@ -0,0 +1,306 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header'
import Footer from '../../components/Footer/Footer'
import styles from './BrandsPage.module.css'
const BRANDS = [
{
name: 'VERSACE',
tagline: 'Italian luxury fashion house founded in 1978',
emoji: '💎',
bg: '#fdf6e3',
accent: '#c9a84c',
items: '120+ items',
href: '/shop?brand=versace',
featured: true,
},
{
name: 'ZARA',
tagline: 'Contemporary fashion for every occasion',
emoji: '🛍️',
bg: '#e8f4fd',
accent: '#2980b9',
items: '340+ items',
href: '/shop?brand=zara',
featured: true,
},
{
name: 'GUCCI',
tagline: 'The House of Gucci — iconic luxury style',
emoji: '👑',
bg: '#fdecea',
accent: '#c0392b',
items: '85+ items',
href: '/shop?brand=gucci',
featured: true,
},
{
name: 'PRADA',
tagline: 'Modern elegance and refined Italian craftsmanship',
emoji: '✨',
bg: '#eafdf5',
accent: '#27ae60',
items: '60+ items',
href: '/shop?brand=prada',
featured: false,
},
{
name: 'Calvin Klein',
tagline: 'Minimal. Modern. Quintessentially American.',
emoji: '🖤',
bg: '#f5f0ff',
accent: '#8e44ad',
items: '200+ items',
href: '/shop?brand=calvin-klein',
featured: false,
},
{
name: 'H&M',
tagline: 'Fashion & quality at the best price',
emoji: '🌿',
bg: '#f0fff4',
accent: '#16a085',
items: '500+ items',
href: '/shop?brand=hm',
featured: false,
},
{
name: 'Nike',
tagline: 'Just Do It — performance meets style',
emoji: '👟',
bg: '#e8f0fe',
accent: '#2c3e50',
items: '280+ items',
href: '/shop?brand=nike',
featured: false,
},
{
name: 'Adidas',
tagline: 'Impossible is Nothing — sportswear icon',
emoji: '⚡',
bg: '#fff3e0',
accent: '#e67e22',
items: '190+ items',
href: '/shop?brand=adidas',
featured: false,
},
{
name: 'Levi\'s',
tagline: 'The original American denim since 1853',
emoji: '👖',
bg: '#e3f2fd',
accent: '#1565c0',
items: '150+ items',
href: '/shop?brand=levis',
featured: false,
},
{
name: 'Tommy Hilfiger',
tagline: 'Classic American cool with a preppy twist',
emoji: '⛵',
bg: '#fce4ec',
accent: '#c62828',
items: '110+ items',
href: '/shop?brand=tommy',
featured: false,
},
{
name: 'Ralph Lauren',
tagline: 'The American dream, tailored to perfection',
emoji: '🐎',
bg: '#f3e5f5',
accent: '#6a1b9a',
items: '95+ items',
href: '/shop?brand=ralph-lauren',
featured: false,
},
{
name: 'Supreme',
tagline: 'Streetwear royalty — limited, exclusive, iconic',
emoji: '🔴',
bg: '#ffebee',
accent: '#b71c1c',
items: '40+ items',
href: '/shop?brand=supreme',
featured: false,
},
]
const STATS = [
{ num: '200+', label: 'Global Brands' },
{ num: '50K+', label: 'Products' },
{ num: '2M+', label: 'Happy Customers' },
{ num: '150+', label: 'Countries Shipped' },
]
function BrandsPage() {
const [search, setSearch] = useState('')
const filtered = BRANDS.filter((b) =>
b.name.toLowerCase().includes(search.toLowerCase())
)
const featured = filtered.filter((b) => b.featured)
const regular = filtered.filter((b) => !b.featured)
return (
<>
<AnnouncementBar />
<Header />
<main className={styles.page}>
{/* ─── Hero ─── */}
<div className={styles.hero}>
<div className={styles.heroInner}>
<h1 className={styles.heroTitle}>OUR BRANDS</h1>
<p className={styles.heroSub}>
From luxury maisons to streetwear icons — discover the world's most celebrated fashion brands, all in one place.
</p>
{/* Stats */}
<div className={styles.stats}>
{STATS.map((s) => (
<div key={s.label} className={styles.stat}>
<strong>{s.num}</strong>
<span>{s.label}</span>
</div>
))}
</div>
</div>
</div>
<div className={styles.container}>
{/* ─── Breadcrumbs ─── */}
<div className={styles.breadcrumbs}>
<Link to="/" className={styles.breadcrumbLink}>Home</Link>
<span className={styles.sep}>›</span>
<span>Brands</span>
</div>
{/* ─── Search ─── */}
<div className={styles.searchWrapper}>
<div className={styles.searchBox}>
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="search"
placeholder="Search brands..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<p className={styles.searchCount}>{filtered.length} brands</p>
</div>
{filtered.length === 0 ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>🔍</div>
<h3>No brands found for "{search}"</h3>
<p>Try a different search term.</p>
<button type="button" onClick={() => setSearch('')} className={styles.emptyBtn}>
Clear Search
</button>
</div>
) : (
<>
{/* ─── Featured Brands ─── */}
{featured.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Featured Brands</h2>
<div className={styles.featuredGrid}>
{featured.map((brand) => (
<Link
key={brand.name}
to={brand.href}
className={styles.featuredCard}
style={{
'--bg': brand.bg,
'--accent': brand.accent,
} as React.CSSProperties}
>
<div className={styles.featuredCardTop}>
<span className={styles.cardEmoji}>{brand.emoji}</span>
<span className={styles.featuredBadge}>Featured</span>
</div>
<h3 className={styles.cardName}>{brand.name}</h3>
<p className={styles.cardTagline}>{brand.tagline}</p>
<div className={styles.cardFooter}>
<span className={styles.cardItems}>{brand.items}</span>
<span className={styles.cardArrow}>Shop Now →</span>
</div>
</Link>
))}
</div>
</section>
)}
{/* ─── All Brands ─── */}
{regular.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>All Brands</h2>
<div className={styles.brandsGrid}>
{regular.map((brand) => (
<Link
key={brand.name}
to={brand.href}
className={styles.brandCard}
style={{
'--bg': brand.bg,
'--accent': brand.accent,
} as React.CSSProperties}
>
<span className={styles.brandEmoji}>{brand.emoji}</span>
<div className={styles.brandInfo}>
<h3 className={styles.brandName}>{brand.name}</h3>
<p className={styles.brandTagline}>{brand.tagline}</p>
<span className={styles.brandItems}>{brand.items}</span>
</div>
<span className={styles.brandArrow}>→</span>
</Link>
))}
</div>
</section>
)}
</>
)}
{/* ─── Partner CTA ─── */}
<div className={styles.partnerCta}>
<div className={styles.partnerCtaLeft}>
<h3 className={styles.partnerTitle}>Are You a Brand?</h3>
<p className={styles.partnerText}>
Join thousands of fashion brands on Shop.co. Reach millions of style-conscious shoppers worldwide and grow your business with us.
</p>
<div className={styles.partnerActions}>
<a href="mailto:partners@shop.co" className={styles.partnerBtn}>
Become a Partner
</a>
<a href="#" className={styles.partnerBtnOutline}>
Learn More →
</a>
</div>
</div>
<div className={styles.partnerCtaRight}>
<div className={styles.partnerBenefits}>
{['Global Reach', 'Easy Setup', 'Analytics', 'Support 24/7'].map((b) => (
<div key={b} className={styles.partnerBenefit}>
<span>✓</span>
<span>{b}</span>
</div>
))}
</div>
</div>
</div>
</div>
</main>
<Footer />
</>
)
}
export default BrandsPage

View file

@ -397,4 +397,291 @@
width: 100%; width: 100%;
height: 48px; height: 48px;
} }
}
/* ────────────────────────────────────────────
Checkout Modal
──────────────────────────────────────────── */
.modalOverlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
position: relative;
width: 100%;
max-width: 640px;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 24px;
padding: 40px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18);
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(24px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal::-webkit-scrollbar {
width: 6px;
}
.modal::-webkit-scrollbar-track {
background: transparent;
}
.modal::-webkit-scrollbar-thumb {
background: #e0e0e0;
border-radius: 3px;
}
.modalClose {
position: absolute;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
border: 0;
border-radius: 50%;
background: #f5f5f5;
color: #555;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, color 0.2s ease;
}
.modalClose:hover {
background: #000;
color: #fff;
}
.modalTitle {
margin: 0 0 6px;
font-size: 28px;
font-weight: 800;
letter-spacing: -1px;
color: #000;
}
.modalSubtitle {
margin: 0 0 28px;
font-size: 15px;
color: #666;
}
/* Form */
.checkoutForm {
display: flex;
flex-direction: column;
gap: 24px;
}
.fieldset {
border: 1px solid #E8E8E8;
border-radius: 16px;
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.legend {
padding: 0 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: #888;
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 600;
color: #333;
}
.input {
height: 46px;
padding: 0 16px;
border: 1.5px solid #E8E8E8;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
color: #111;
outline: none;
transition: border-color 0.2s ease;
}
.input::placeholder {
color: #bbb;
}
.input:focus {
border-color: #000;
}
.inputError {
border-color: #FF3333 !important;
}
.error {
font-size: 12px;
color: #FF3333;
}
/* Payment */
.paymentOptions {
display: flex;
flex-direction: column;
gap: 10px;
}
.paymentOption {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border: 1.5px solid #E8E8E8;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.paymentOption:hover {
border-color: #bbb;
}
.paymentOptionActive {
border-color: #000 !important;
background: #fafafa;
}
.paymentRadio {
accent-color: #000;
width: 16px;
height: 16px;
cursor: pointer;
}
/* Submit */
.submitButton {
width: 100%;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border: 0;
border-radius: 30px;
background: #000;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.3px;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease, box-shadow 0.3s ease;
}
.submitButton:hover {
background: #222;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
/* Success */
.successState {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 20px 0;
}
.successIcon {
width: 72px;
height: 72px;
border-radius: 50%;
background: #000;
color: #fff;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.successTitle {
margin: 0 0 12px;
font-size: 32px;
font-weight: 800;
letter-spacing: -1px;
color: #000;
}
.successText {
margin: 0 0 32px;
font-size: 15px;
color: #555;
line-height: 1.6;
}
.successButton {
padding: 0 40px;
height: 52px;
border: 2px solid #000;
border-radius: 30px;
background: transparent;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.successButton:hover {
background: #000;
color: #fff;
}
@media (max-width: 480px) {
.modal {
padding: 28px 20px;
}
.formRow {
grid-template-columns: 1fr;
}
} }

View file

@ -1,17 +1,229 @@
import { useState } from 'react' import { useState } from 'react'
import { useCart } from '../../context/CartContext' // <-- ВАЖНО: только этот импорт! import { useCart } from '../../context/CartContext'
import styles from './Cart.module.css' import styles from './Cart.module.css'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar' import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header' import Header from '../../components/Header/Header'
import Footer from '../../components/Footer/Footer' import Footer from '../../components/Footer/Footer'
// ──────────────────────────────────────────────
// Checkout Modal
// ──────────────────────────────────────────────
interface CheckoutModalProps {
total: number
onClose: () => void
}
function CheckoutModal({ total, onClose }: CheckoutModalProps) {
const [step, setStep] = useState<'form' | 'success'>('form')
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
city: '',
zip: '',
payment: 'card',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = () => {
const e: Record<string, string> = {}
if (!form.firstName.trim()) e.firstName = 'Required'
if (!form.lastName.trim()) e.lastName = 'Required'
if (!form.email.trim() || !/\S+@\S+\.\S+/.test(form.email)) e.email = 'Valid email required'
if (!form.phone.trim()) e.phone = 'Required'
if (!form.address.trim()) e.address = 'Required'
if (!form.city.trim()) e.city = 'Required'
if (!form.zip.trim()) e.zip = 'Required'
return e
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const errs = validate()
if (Object.keys(errs).length > 0) {
setErrors(errs)
return
}
setStep('success')
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
setErrors((prev) => ({ ...prev, [e.target.name]: '' }))
}
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Close">
✕
</button>
{step === 'form' ? (
<>
<h2 className={styles.modalTitle}>Checkout</h2>
<p className={styles.modalSubtitle}>
Order total: <strong>${total.toFixed(0)}</strong>
</p>
<form className={styles.checkoutForm} onSubmit={handleSubmit} noValidate>
{/* Personal */}
<fieldset className={styles.fieldset}>
<legend className={styles.legend}>Personal Info</legend>
<div className={styles.formRow}>
<div className={styles.field}>
<label className={styles.label}>First Name</label>
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
className={`${styles.input} ${errors.firstName ? styles.inputError : ''}`}
placeholder="John"
/>
{errors.firstName && <span className={styles.error}>{errors.firstName}</span>}
</div>
<div className={styles.field}>
<label className={styles.label}>Last Name</label>
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
className={`${styles.input} ${errors.lastName ? styles.inputError : ''}`}
placeholder="Doe"
/>
{errors.lastName && <span className={styles.error}>{errors.lastName}</span>}
</div>
</div>
<div className={styles.formRow}>
<div className={styles.field}>
<label className={styles.label}>Email</label>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
className={`${styles.input} ${errors.email ? styles.inputError : ''}`}
placeholder="john@example.com"
/>
{errors.email && <span className={styles.error}>{errors.email}</span>}
</div>
<div className={styles.field}>
<label className={styles.label}>Phone</label>
<input
name="phone"
value={form.phone}
onChange={handleChange}
className={`${styles.input} ${errors.phone ? styles.inputError : ''}`}
placeholder="+1 (555) 000-0000"
/>
{errors.phone && <span className={styles.error}>{errors.phone}</span>}
</div>
</div>
</fieldset>
{/* Shipping */}
<fieldset className={styles.fieldset}>
<legend className={styles.legend}>Shipping Address</legend>
<div className={styles.field}>
<label className={styles.label}>Street Address</label>
<input
name="address"
value={form.address}
onChange={handleChange}
className={`${styles.input} ${errors.address ? styles.inputError : ''}`}
placeholder="123 Main St"
/>
{errors.address && <span className={styles.error}>{errors.address}</span>}
</div>
<div className={styles.formRow}>
<div className={styles.field}>
<label className={styles.label}>City</label>
<input
name="city"
value={form.city}
onChange={handleChange}
className={`${styles.input} ${errors.city ? styles.inputError : ''}`}
placeholder="New York"
/>
{errors.city && <span className={styles.error}>{errors.city}</span>}
</div>
<div className={styles.field}>
<label className={styles.label}>ZIP Code</label>
<input
name="zip"
value={form.zip}
onChange={handleChange}
className={`${styles.input} ${errors.zip ? styles.inputError : ''}`}
placeholder="10001"
/>
{errors.zip && <span className={styles.error}>{errors.zip}</span>}
</div>
</div>
</fieldset>
{/* Payment */}
<fieldset className={styles.fieldset}>
<legend className={styles.legend}>Payment Method</legend>
<div className={styles.paymentOptions}>
{(['card', 'paypal', 'cash'] as const).map((method) => (
<label
key={method}
className={`${styles.paymentOption} ${form.payment === method ? styles.paymentOptionActive : ''}`}
>
<input
type="radio"
name="payment"
value={method}
checked={form.payment === method}
onChange={handleChange}
className={styles.paymentRadio}
/>
{method === 'card' && '💳 Credit / Debit Card'}
{method === 'paypal' && '🅿️ PayPal'}
{method === 'cash' && '💵 Cash on Delivery'}
</label>
))}
</div>
</fieldset>
<button type="submit" className={styles.submitButton}>
Place Order — ${total.toFixed(0)}
<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>
</form>
</>
) : (
<div className={styles.successState}>
<div className={styles.successIcon}>✓</div>
<h2 className={styles.successTitle}>Order Placed!</h2>
<p className={styles.successText}>
Thank you, {form.firstName}! Your order has been placed successfully.
<br />
We'll send a confirmation to <strong>{form.email}</strong>.
</p>
<button type="button" className={styles.successButton} onClick={onClose}>
Continue Shopping
</button>
</div>
)}
</div>
</div>
)
}
// ──────────────────────────────────────────────
// Cart Page
// ──────────────────────────────────────────────
function Cart() { function Cart() {
// Берем всё из глобального контекста. Никаких своих fetch!
const { cart, updateQuantity, removeFromCart } = useCart() const { cart, updateQuantity, removeFromCart } = useCart()
const [promoCode, setPromoCode] = useState('') const [promoCode, setPromoCode] = useState('')
const [appliedDiscount, setAppliedDiscount] = useState<number | null>(null) const [appliedDiscount, setAppliedDiscount] = useState<number | null>(null)
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false)
// Если корзина еще загружается
if (!cart) { if (!cart) {
return <div className={styles.loading}>Loading cart...</div> return <div className={styles.loading}>Loading cart...</div>
} }
@ -27,7 +239,7 @@ function Cart() {
} }
const subtotal = cartItems.reduce( const subtotal = cartItems.reduce(
(total, item) => total + Number(item.product.price) * item.quantity, (total: number, item: any) => total + Number(item.product.price) * item.quantity,
0 0
) )
@ -58,7 +270,7 @@ function Cart() {
<p>Add some products to your cart to continue.</p> <p>Add some products to your cart to continue.</p>
</div> </div>
) : ( ) : (
cartItems.map((item) => ( cartItems.map((item: any) => (
<article key={item.id} className={styles.item}> <article key={item.id} className={styles.item}>
<div className={styles.imageWrapper}> <div className={styles.imageWrapper}>
<img <img
@ -173,6 +385,7 @@ function Cart() {
type="button" type="button"
className={styles.checkoutButton} className={styles.checkoutButton}
disabled={cartItems.length === 0} disabled={cartItems.length === 0}
onClick={() => setIsCheckoutOpen(true)}
> >
Go to Checkout Go to Checkout
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@ -186,6 +399,10 @@ function Cart() {
</main> </main>
<Footer /> <Footer />
{isCheckoutOpen && (
<CheckoutModal total={total} onClose={() => setIsCheckoutOpen(false)} />
)}
</> </>
) )
} }

View file

@ -0,0 +1,451 @@
.page {
min-height: 80vh;
}
/* ─── Hero ─────────────────────── */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 48px;
padding: 64px 24px 56px;
max-width: 1248px;
margin: 0 auto;
}
.heroContent {
max-width: 560px;
}
.heroBadge {
display: inline-block;
padding: 7px 16px;
margin-bottom: 20px;
border-radius: 30px;
background: #f0f0f0;
font-size: 13px;
font-weight: 600;
color: #333;
}
.heroTitle {
margin: 0 0 16px;
font-size: 64px;
font-weight: 900;
letter-spacing: -3px;
line-height: 1;
color: #000;
}
.heroSub {
margin: 0 0 28px;
font-size: 16px;
color: #555;
line-height: 1.65;
max-width: 460px;
}
.heroActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.heroActionPrimary {
display: inline-flex;
align-items: center;
height: 50px;
padding: 0 28px;
border-radius: 30px;
background: #000;
color: #fff;
text-decoration: none;
font-size: 15px;
font-weight: 600;
transition: background 0.25s, transform 0.2s;
}
.heroActionPrimary:hover {
background: #222;
transform: translateY(-2px);
}
.heroActionOutline {
display: inline-flex;
align-items: center;
height: 50px;
padding: 0 28px;
border-radius: 30px;
border: 1.5px solid #000;
color: #000;
text-decoration: none;
font-size: 15px;
font-weight: 600;
transition: all 0.25s;
}
.heroActionOutline:hover {
background: #000;
color: #fff;
}
.heroVisual {
flex-shrink: 0;
}
.heroCard {
width: 220px;
height: 220px;
border-radius: 28px;
background: linear-gradient(135deg, #000 0%, #333 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.heroCardLabel {
font-size: 11px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
margin-bottom: 8px;
}
.heroCardTitle {
font-size: 28px;
font-weight: 900;
letter-spacing: -1px;
}
.heroCardSub {
font-size: 12px;
color: rgba(255,255,255,0.6);
margin-top: 6px;
}
/* ─── Features Row ─────────────────────── */
.featuresRow {
display: grid;
grid-template-columns: repeat(4, 1fr);
background: #f8f8f8;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
}
.featureItem {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 28px;
border-right: 1px solid #eee;
}
.featureItem:last-child { border-right: none; }
.featureIcon { font-size: 26px; flex-shrink: 0; }
.featureItem strong {
display: block;
font-size: 13px;
font-weight: 700;
color: #111;
margin-bottom: 2px;
}
.featureItem span {
font-size: 12px;
color: #888;
}
/* ─── Container ─────────────────────── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 32px;
font-size: 14px;
color: #777;
}
.breadcrumbLink {
color: #777;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumbLink:hover { color: #000; }
.sep { color: #bbb; }
/* ─── Toolbar ─────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
flex-wrap: wrap;
}
.filterTabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filterTab {
padding: 10px 22px;
border: 1.5px solid #e8e8e8;
border-radius: 30px;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #555;
cursor: pointer;
transition: all 0.2s;
}
.filterTab:hover {
border-color: #999;
color: #000;
}
.filterTabActive {
border-color: #000 !important;
background: #000;
color: #fff !important;
}
.sortRow {
display: flex;
align-items: center;
gap: 12px;
}
.resultsCount {
font-size: 14px;
color: #888;
white-space: nowrap;
}
.sortSelect {
height: 40px;
padding: 0 14px;
border: 1.5px solid #e8e8e8;
border-radius: 10px;
font-family: inherit;
font-size: 14px;
color: #111;
background: #fff;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.sortSelect:focus { border-color: #000; }
/* ─── Grid ─────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 48px;
}
/* ─── Loading / Empty ─────────────────────── */
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 80px 0;
color: #666;
}
.spinner {
width: 44px;
height: 44px;
border: 3px solid #eee;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty {
text-align: center;
padding: 80px 40px;
border: 1px solid #e8e8e8;
border-radius: 24px;
margin-bottom: 48px;
}
.emptyIcon { font-size: 52px; margin-bottom: 20px; }
.empty h3 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
.empty p { margin: 0 0 24px; color: #666; }
.emptyBtn {
padding: 12px 28px;
border: 0;
border-radius: 30px;
background: #000;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.emptyBtn:hover { background: #333; }
/* ─── Trending Tags ─────────────────────── */
.trending {
margin-bottom: 48px;
}
.trendingTitle {
margin: 0 0 16px;
font-size: 18px;
font-weight: 700;
color: #000;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag {
padding: 8px 18px;
border-radius: 30px;
background: #f5f5f5;
font-size: 13px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.tag:hover {
background: #000;
color: #fff;
}
/* ─── Lookbook CTA ─────────────────────── */
.lookbookCta {
display: grid;
grid-template-columns: 1fr auto;
gap: 48px;
align-items: center;
padding: 48px;
background: linear-gradient(135deg, #000 0%, #1a1a1a 100%);
border-radius: 28px;
color: #fff;
}
.lookbookBadge {
display: inline-block;
padding: 5px 14px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 30px;
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: rgba(255,255,255,0.7);
margin-bottom: 16px;
}
.lookbookTitle {
margin: 0 0 12px;
font-size: 30px;
font-weight: 800;
letter-spacing: -1px;
}
.lookbookText {
margin: 0 0 24px;
font-size: 15px;
color: rgba(255,255,255,0.6);
line-height: 1.6;
max-width: 420px;
}
.lookbookBtn {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 28px;
border-radius: 30px;
background: #fff;
color: #000;
text-decoration: none;
font-size: 14px;
font-weight: 700;
transition: background 0.2s, transform 0.2s;
}
.lookbookBtn:hover {
background: #f0f0f0;
transform: translateY(-2px);
}
.styleGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.styleChip {
padding: 18px 24px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 16px;
font-size: 15px;
font-weight: 600;
color: #fff;
text-align: center;
cursor: pointer;
transition: background 0.2s;
}
.styleChip:hover {
background: rgba(255,255,255,0.2);
}
/* ─── Responsive ─────────────────────── */
@media (max-width: 1024px) {
.grid { grid-template-columns: repeat(3, 1fr); }
.featuresRow { grid-template-columns: repeat(2, 1fr); }
.featureItem:nth-child(2) { border-right: none; }
}
@media (max-width: 768px) {
.heroTitle { font-size: 44px; }
.heroVisual { display: none; }
.hero { padding: 40px 24px 32px; }
.grid { grid-template-columns: repeat(2, 1fr); }
.lookbookCta { grid-template-columns: 1fr; padding: 36px 28px; }
.styleGrid { display: flex; flex-wrap: wrap; }
}
@media (max-width: 480px) {
.heroTitle { font-size: 34px; }
.grid { grid-template-columns: 1fr; }
.featuresRow { grid-template-columns: 1fr; }
.featureItem { border-right: none; border-bottom: 1px solid #eee; }
}

View file

@ -0,0 +1,203 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header'
import Footer from '../../components/Footer/Footer'
import ProductCard from '../../components/ProductCard/ProductCard'
import styles from './NewArrivalsPage.module.css'
type Product = {
id: number
name: string
slug: string
image: string
rating: number
price: string
old_price: string | null
discount_percent: number | null
category: number
description: string
}
const FILTERS = [
{ key: 'all', label: 'All Items' },
{ key: 'new-arrivals', label: 'New Arrivals' },
{ key: 'top-selling', label: 'Top Selling' },
]
const FEATURES = [
{ icon: '✨', title: 'Fresh Weekly Drops', text: 'New styles added every Monday' },
{ icon: '📦', title: 'Express Shipping', text: 'Get it in 2-3 business days' },
{ icon: '🎯', title: 'Trend Forecast', text: 'Styles curated by our team' },
{ icon: '💯', title: 'Quality Guarantee', text: 'Love it or we replace it' },
]
function NewArrivalsPage() {
const [products, setProducts] = useState<Product[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeFilter, setActiveFilter] = useState('all')
const [sort, setSort] = useState('default')
useEffect(() => {
const url = activeFilter === 'all'
? 'http://127.0.0.1:8000/api/products/'
: `http://127.0.0.1:8000/api/products/?category=${activeFilter}`
setIsLoading(true)
fetch(url)
.then((res) => res.json())
.then((data: Product[]) => setProducts(data))
.catch(() => setProducts([]))
.finally(() => setIsLoading(false))
}, [activeFilter])
const sorted = [...products].sort((a, b) => {
if (sort === 'price-asc') return Number(a.price) - Number(b.price)
if (sort === 'price-desc') return Number(b.price) - Number(a.price)
if (sort === 'rating') return Number(b.rating) - Number(a.rating)
return 0
})
return (
<>
<AnnouncementBar />
<Header />
<main className={styles.page}>
{/* ─── Hero ─── */}
<div className={styles.hero}>
<div className={styles.heroContent}>
<span className={styles.heroBadge}>✨ Just Arrived</span>
<h1 className={styles.heroTitle}>NEW ARRIVALS</h1>
<p className={styles.heroSub}>
Discover our freshest styles — meticulously crafted garments that define the season's must-have looks. Be the first to wear what's trending.
</p>
<div className={styles.heroActions}>
<Link to="/sale" className={styles.heroActionOutline}>View Sale →</Link>
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.heroCard}>
<div className={styles.heroCardLabel}>This Season</div>
<div className={styles.heroCardTitle}>Fresh Drops</div>
<div className={styles.heroCardSub}>Updated Weekly</div>
</div>
</div>
</div>
{/* ─── Features Row ─── */}
<div className={styles.featuresRow}>
{FEATURES.map((f) => (
<div key={f.title} className={styles.featureItem}>
<span className={styles.featureIcon}>{f.icon}</span>
<div>
<strong>{f.title}</strong>
<span>{f.text}</span>
</div>
</div>
))}
</div>
<div className={styles.container}>
{/* ─── Breadcrumbs ─── */}
<div className={styles.breadcrumbs}>
<Link to="/" className={styles.breadcrumbLink}>Home</Link>
<span className={styles.sep}>›</span>
<span>New Arrivals</span>
</div>
{/* ─── Filter & Sort Toolbar ─── */}
<div className={styles.toolbar}>
<div className={styles.filterTabs}>
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`${styles.filterTab} ${activeFilter === f.key ? styles.filterTabActive : ''}`}
onClick={() => setActiveFilter(f.key)}
>
{f.label}
</button>
))}
</div>
<div className={styles.sortRow}>
<span className={styles.resultsCount}>
{isLoading ? '...' : `${products.length} items`}
</span>
<select
className={styles.sortSelect}
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="default">Sort: Featured</option>
<option value="price-asc">Price: Low → High</option>
<option value="price-desc">Price: High → Low</option>
<option value="rating">Top Rated</option>
</select>
</div>
</div>
{/* ─── Product Grid ─── */}
{isLoading ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<p>Loading products...</p>
</div>
) : sorted.length === 0 ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>🛍️</div>
<h3>Nothing here yet</h3>
<p>We're adding new items soon. Check back later!</p>
<button type="button" className={styles.emptyBtn} onClick={() => setActiveFilter('all')}>
Show All Items
</button>
</div>
) : (
<div className={styles.grid}>
{sorted.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{/* ─── Trending Tags ─── */}
<div className={styles.trending}>
<h3 className={styles.trendingTitle}>Trending Now</h3>
<div className={styles.tags}>
{['#StreetStyle', '#CasualFit', '#SummerVibes', '#MinimalistFashion', '#OverSized', '#DenimLove', '#GraphicTees', '#MonoChrome'].map((tag) => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
</div>
{/* ─── Lookbook CTA ─── */}
<div className={styles.lookbookCta}>
<div className={styles.lookbookCtaLeft}>
<span className={styles.lookbookBadge}>Style Inspiration</span>
<h3 className={styles.lookbookTitle}>Browse By Style</h3>
<p className={styles.lookbookText}>
Not sure what to pick? Explore curated outfit collections for every occasion — from casual to formal, streetwear to resort.
</p>
<Link to="/" className={styles.lookbookBtn}>
Explore Styles →
</Link>
</div>
<div className={styles.lookbookCtaRight}>
<div className={styles.styleGrid}>
{['Casual', 'Formal', 'Party', 'Gym'].map((s, i) => (
<div key={s} className={styles.styleChip} style={{ animationDelay: `${i * 0.1}s` }}>
{s}
</div>
))}
</div>
</div>
</div>
</div>
</main>
<Footer />
</>
)
}
export default NewArrivalsPage

View file

@ -0,0 +1,384 @@
.page {
min-height: 80vh;
}
/* ─── Hero Banner ─────────────────────── */
.heroBanner {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
padding: 60px 24px;
color: #fff;
overflow: hidden;
}
.heroInner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 40px;
}
.heroLeft {
max-width: 580px;
}
.heroBadge {
display: inline-block;
padding: 8px 18px;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 30px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
color: rgba(255,255,255,0.85);
margin-bottom: 20px;
}
.heroTitle {
margin: 0 0 16px;
font-size: 60px;
font-weight: 900;
letter-spacing: -3px;
line-height: 1;
text-transform: uppercase;
}
.heroHighlight {
color: #FFD700;
}
.heroText {
margin: 0 0 32px;
font-size: 17px;
color: rgba(255,255,255,0.65);
line-height: 1.6;
max-width: 440px;
}
.heroStats {
display: flex;
align-items: center;
gap: 24px;
}
.heroStat {
display: flex;
flex-direction: column;
gap: 2px;
}
.heroStat strong {
font-size: 22px;
font-weight: 800;
color: #FFD700;
letter-spacing: -0.5px;
}
.heroStat span {
font-size: 12px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.heroStatDivider {
width: 1px;
height: 36px;
background: rgba(255,255,255,0.2);
}
.heroRight {
flex-shrink: 0;
}
.discountCircle {
width: 180px;
height: 180px;
border-radius: 50%;
background: #FFD700;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 0 60px rgba(255, 215, 0, 0.4);
}
.discountCircleNum {
font-size: 42px;
font-weight: 900;
color: #000;
letter-spacing: -2px;
line-height: 1;
}
.discountCircleText {
font-size: 10px;
font-weight: 700;
color: #000;
letter-spacing: 2px;
margin-top: 4px;
}
/* ─── Promo Strips ─────────────────────── */
.promoStrips {
display: grid;
grid-template-columns: repeat(4, 1fr);
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
}
.promoStrip {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 24px;
border-right: 1px solid #e8e8e8;
}
.promoStrip:last-child {
border-right: none;
}
.promoStripIcon {
font-size: 24px;
flex-shrink: 0;
}
.promoStrip strong {
display: block;
font-size: 13px;
font-weight: 700;
color: #000;
margin-bottom: 1px;
}
.promoStrip span {
font-size: 12px;
color: #777;
}
/* ─── Container ─────────────────────── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 32px;
font-size: 14px;
color: #666;
}
.breadcrumbLink {
color: #666;
text-decoration: none;
transition: color 0.2s;
}
.breadcrumbLink:hover { color: #000; }
.sep { color: #bbb; }
/* ─── Toolbar ─────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
gap: 16px;
flex-wrap: wrap;
}
.sectionTitle {
margin: 0;
font-size: 28px;
font-weight: 800;
letter-spacing: -1px;
color: #000;
}
.count {
font-size: 18px;
font-weight: 400;
color: #888;
letter-spacing: 0;
}
.sortRow {
display: flex;
align-items: center;
gap: 10px;
}
.sortLabel {
font-size: 14px;
color: #666;
}
.sortSelect {
height: 40px;
padding: 0 14px;
border: 1.5px solid #E8E8E8;
border-radius: 10px;
font-family: inherit;
font-size: 14px;
color: #111;
background: #fff;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.sortSelect:focus { border-color: #000; }
/* ─── Grid ─────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
/* ─── Loading / Empty ─────────────────────── */
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 80px 0;
color: #666;
}
.spinner {
width: 44px;
height: 44px;
border: 3px solid #eee;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty {
text-align: center;
padding: 80px 40px;
border: 1px solid #E8E8E8;
border-radius: 24px;
background: #fafafa;
}
.emptyIcon { font-size: 52px; margin-bottom: 20px; }
.empty h3 {
margin: 0 0 8px;
font-size: 22px;
font-weight: 700;
}
.empty p {
margin: 0 0 28px;
color: #666;
font-size: 15px;
}
.emptyBtn {
display: inline-flex;
padding: 12px 32px;
border-radius: 30px;
background: #000;
color: #fff;
text-decoration: none;
font-size: 14px;
font-weight: 600;
transition: background 0.3s;
}
.emptyBtn:hover { background: #333; }
/* ─── Newsletter CTA ─────────────────────── */
.newsletterCta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
margin-top: 64px;
padding: 40px 48px;
background: #000;
border-radius: 24px;
color: #fff;
flex-wrap: wrap;
}
.newsletterCtaContent h3 {
margin: 0 0 6px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
}
.newsletterCtaContent p {
margin: 0;
font-size: 15px;
color: rgba(255,255,255,0.6);
}
.newsletterCtaForm {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.newsletterInput {
height: 48px;
width: 280px;
padding: 0 18px;
border: 0;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
outline: none;
}
.newsletterBtn {
height: 48px;
padding: 0 24px;
border: 0;
border-radius: 12px;
background: #FFD700;
color: #000;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s;
}
.newsletterBtn:hover { opacity: 0.85; }
/* ─── Responsive ─────────────────────── */
@media (max-width: 1024px) {
.grid { grid-template-columns: repeat(3, 1fr); }
.promoStrips { grid-template-columns: repeat(2, 1fr); }
.promoStrip:nth-child(2) { border-right: none; }
}
@media (max-width: 768px) {
.heroTitle { font-size: 40px; }
.heroRight { display: none; }
.grid { grid-template-columns: repeat(2, 1fr); }
.newsletterCta { flex-direction: column; }
.newsletterCtaForm { width: 100%; flex-direction: column; }
.newsletterInput { width: 100%; }
}
@media (max-width: 480px) {
.grid { grid-template-columns: 1fr; }
.promoStrips { grid-template-columns: 1fr; }
.promoStrip { border-right: none; border-bottom: 1px solid #e8e8e8; }
}

View file

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import AnnouncementBar from '../../components/AnnouncementBar/AnnouncementBar'
import Header from '../../components/Header/Header'
import Footer from '../../components/Footer/Footer'
import ProductCard from '../../components/ProductCard/ProductCard'
import styles from './OnSale.module.css'
type Product = {
id: number
name: string
slug: string
image: string
rating: number
price: string
old_price: string | null
discount_percent: number | null
category: number
description: string
}
const SORT_OPTIONS = [
{ value: 'default', label: 'Featured' },
{ value: 'price-asc', label: 'Price: Low to High' },
{ value: 'price-desc', label: 'Price: High to Low' },
{ value: 'discount', label: 'Biggest Discount' },
]
function OnSale() {
const [products, setProducts] = useState<Product[]>([])
const [isLoading, setIsLoading] = useState(true)
const [sort, setSort] = useState('default')
useEffect(() => {
fetch('http://127.0.0.1:8000/api/products/?sale=true')
.then((res) => res.json())
.then((data: Product[]) => setProducts(data))
.catch(() => setProducts([]))
.finally(() => setIsLoading(false))
}, [])
const sorted = [...products].sort((a, b) => {
if (sort === 'price-asc') return Number(a.price) - Number(b.price)
if (sort === 'price-desc') return Number(b.price) - Number(a.price)
if (sort === 'discount') return (b.discount_percent ?? 0) - (a.discount_percent ?? 0)
return 0
})
return (
<>
<AnnouncementBar />
<Header />
<main className={styles.page}>
{/* ── Hero Banner ── */}
<div className={styles.heroBanner}>
<div className={styles.heroInner}>
<div className={styles.heroLeft}>
<span className={styles.heroBadge}>🔥 Limited Time Offers</span>
<h1 className={styles.heroTitle}>
BIG SALE<br />
<span className={styles.heroHighlight}>Up to 40% Off</span>
</h1>
<p className={styles.heroText}>
Don't miss our hottest deals on premium fashion. New discounts added weekly — shop before they're gone.
</p>
<div className={styles.heroStats}>
<div className={styles.heroStat}>
<strong>{isLoading ? '—' : products.length}</strong>
<span>Items on Sale</span>
</div>
<div className={styles.heroStatDivider} />
<div className={styles.heroStat}>
<strong>Up to 40%</strong>
<span>Max Discount</span>
</div>
<div className={styles.heroStatDivider} />
<div className={styles.heroStat}>
<strong>Free</strong>
<span>Returns</span>
</div>
</div>
</div>
<div className={styles.heroRight}>
<div className={styles.discountCircle}>
<span className={styles.discountCircleNum}>-40%</span>
<span className={styles.discountCircleText}>TODAY ONLY</span>
</div>
</div>
</div>
</div>
{/* ── Promo Strips ── */}
<div className={styles.promoStrips}>
{[
{ icon: '🚚', title: 'Free Delivery', sub: 'On orders over $100' },
{ icon: '↩️', title: 'Easy Returns', sub: '30-day return policy' },
{ icon: '🔒', title: 'Secure Payment', sub: '100% protected checkout' },
{ icon: '💎', title: 'Premium Quality', sub: 'Handpicked items' },
].map((p) => (
<div key={p.title} className={styles.promoStrip}>
<span className={styles.promoStripIcon}>{p.icon}</span>
<div>
<strong>{p.title}</strong>
<span>{p.sub}</span>
</div>
</div>
))}
</div>
<div className={styles.container}>
{/* ── Breadcrumbs ── */}
<div className={styles.breadcrumbs}>
<Link to="/" className={styles.breadcrumbLink}>Home</Link>
<span className={styles.sep}>›</span>
<span>On Sale</span>
</div>
{/* ── Toolbar ── */}
<div className={styles.toolbar}>
<h2 className={styles.sectionTitle}>
Sale Items
{!isLoading && <span className={styles.count}> ({products.length})</span>}
</h2>
<div className={styles.sortRow}>
<label className={styles.sortLabel}>Sort by:</label>
<select
className={styles.sortSelect}
value={sort}
onChange={(e) => setSort(e.target.value)}
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{/* ── Products ── */}
{isLoading ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<p>Loading sale items...</p>
</div>
) : sorted.length === 0 ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>😔</div>
<h3>No sale items right now</h3>
<p>Check back soon — new deals drop every week!</p>
<Link to="/" className={styles.emptyBtn}>Browse All Products</Link>
</div>
) : (
<div className={styles.grid}>
{sorted.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
</main>
<Footer />
</>
)
}
export default OnSale