Обновить компоненты главной страницы с улучшенным дизайном и интерактивностью
This commit is contained in:
parent
d770f217b6
commit
1430cbd25b
@ -1,63 +1,46 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Collection } from "../data/collections"
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface CollectionsProps {
|
||||
collections: Collection[];
|
||||
}
|
||||
|
||||
// Тип для коллекции
|
||||
interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
url: string;
|
||||
collections: Collection[]
|
||||
}
|
||||
|
||||
export default function Collections({ collections }: CollectionsProps) {
|
||||
return (
|
||||
<section className="py-8 bg-white">
|
||||
<div className="container mx-auto px-6">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl text-center font-medium mb-12"
|
||||
>
|
||||
Коллекции
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection, index) => (
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-8 font-['Playfair_Display']">Коллекции</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection, index) => (
|
||||
<Link href={collection.url} key={collection.id} className="group">
|
||||
<motion.div
|
||||
key={collection.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group relative overflow-hidden rounded-lg"
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<Link href={collection.url}>
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-20 transition-opacity group-hover:bg-opacity-30" />
|
||||
</div>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<h3 className="text-xl font-medium mb-2">{collection.name}</h3>
|
||||
<p className="text-sm opacity-90">{collection.description}</p>
|
||||
<h3 className="text-xl font-semibold mb-2">{collection.name}</h3>
|
||||
<p className="text-sm text-white/80 line-clamp-2">{collection.description}</p>
|
||||
<span className="inline-block mt-4 text-sm font-medium border-b border-white pb-1 transition-all group-hover:border-transparent">
|
||||
Смотреть коллекцию
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Search, Heart, User, ShoppingCart } from "lucide-react";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
// Состояние для отслеживания прокрутки страницы
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
@ -23,33 +25,57 @@ export default function Header() {
|
||||
};
|
||||
}, [scrolled]);
|
||||
|
||||
// Функция для возврата на предыдущую страницу
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Проверяем, находимся ли мы на главной странице
|
||||
const isHomePage = router.pathname === "/";
|
||||
// Проверяем, находимся ли мы на странице категорий или коллекций
|
||||
const isDetailPage = router.pathname.includes("[slug]");
|
||||
|
||||
return (
|
||||
<header className={`fixed w-full z-50 transition-all duration-300 ${scrolled ? 'bg-white shadow-sm' : 'bg-transparent'}`}>
|
||||
<nav className={`py-4 transition-all duration-300 ${scrolled ? 'text-black' : 'text-white'}`}>
|
||||
<header className="fixed w-full z-50 transition-all duration-300 bg-white shadow-sm">
|
||||
<nav className="py-4 transition-all duration-300 text-black">
|
||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="/catalog" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
{isDetailPage && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center text-sm font-medium hover:opacity-70 transition-opacity mr-4"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
)}
|
||||
<Link href="/category" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Каталог
|
||||
</Link>
|
||||
<Link href="/new" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
<Link href="/collections" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Коллекции
|
||||
</Link>
|
||||
<Link href="/new-arrivals" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Новинки
|
||||
</Link>
|
||||
<Link href="/stores" className="text-sm font-medium hover:opacity-70 transition-opacity">
|
||||
Магазины
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/" className="text-sm font-medium italic">
|
||||
|
||||
<Link href="/" className="absolute left-1/2 transform -translate-x-1/2">
|
||||
<div className="relative h-10 w-32">
|
||||
Dressed for Success
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center space-x-5">
|
||||
<button className="hover:opacity-70 transition-opacity">
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
<Link href="/favorites" className="relative hover:opacity-70 transition-opacity">
|
||||
<Heart className="w-5 h-5" />
|
||||
<span className={`absolute -top-2 -right-2 ${scrolled ? 'bg-black text-white' : 'bg-white text-black'} text-xs rounded-full w-4 h-4 flex items-center justify-center`}>
|
||||
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
0
|
||||
</span>
|
||||
</Link>
|
||||
@ -58,7 +84,7 @@ export default function Header() {
|
||||
</Link>
|
||||
<Link href="/cart" className="relative hover:opacity-70 transition-opacity">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<span className={`absolute -top-2 -right-2 ${scrolled ? 'bg-black text-white' : 'bg-white text-black'} text-xs rounded-full w-4 h-4 flex items-center justify-center`}>
|
||||
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
0
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface HeroProps {
|
||||
@ -9,126 +10,53 @@ interface HeroProps {
|
||||
}
|
||||
|
||||
export default function Hero({ images = [] }: HeroProps) {
|
||||
// Состояние для текущего индекса слайда
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
// Состояние для автоматического воспроизведения
|
||||
const [autoplay, setAutoplay] = useState(true);
|
||||
// Состояние для направления анимации: 1 - следующий слайд, -1 - предыдущий слайд
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
// Если изображения не переданы или массив пуст, используем стандартное изображение
|
||||
const heroImages = images.length > 0
|
||||
? images
|
||||
: ['/photos/head_photo.png'];
|
||||
|
||||
// Функция для перехода к следующему слайду
|
||||
const nextSlide = () => {
|
||||
setDirection(1);
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === heroImages.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
};
|
||||
|
||||
// Функция для перехода к предыдущему слайду
|
||||
const prevSlide = () => {
|
||||
setDirection(-1);
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === 0 ? heroImages.length - 1 : prevIndex - 1
|
||||
);
|
||||
};
|
||||
|
||||
// Эффект для автоматического воспроизведения слайдера
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (autoplay) {
|
||||
interval = setInterval(() => {
|
||||
nextSlide();
|
||||
}, 5000); // Смена слайда каждые 5 секунд
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [autoplay, currentIndex]);
|
||||
|
||||
// Определяем варианты анимации для эффекта "скольжения"
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? "100%" : "-100%",
|
||||
opacity: 0
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? "-100%" : "100%",
|
||||
opacity: 0
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden">
|
||||
<AnimatePresence custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.7 }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<div className="relative h-[80vh] md:h-screen overflow-hidden bg-white">
|
||||
{/* Логотип по центру */}
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20">
|
||||
<div className="relative w-[200px] h-[200px] md:w-[300px] md:h-[300px]">
|
||||
<Image
|
||||
src={heroImages[currentIndex]}
|
||||
alt="New Arrivals"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
quality={100}
|
||||
src="/logotip.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
{/* Убрали затемнение изображения */}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white bg-opacity-50 p-2 rounded-full hover:bg-opacity-70 transition-all"
|
||||
onClick={() => {
|
||||
prevSlide();
|
||||
setAutoplay(false);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-black" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white bg-opacity-50 p-2 rounded-full hover:bg-opacity-70 transition-all"
|
||||
onClick={() => {
|
||||
nextSlide();
|
||||
setAutoplay(false);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-black" />
|
||||
</button>
|
||||
|
||||
{/* Индикаторы слайдов */}
|
||||
<div className="absolute bottom-8 left-0 right-0 flex justify-center space-x-2">
|
||||
{heroImages.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
// Определяем направление в зависимости от выбранного индекса
|
||||
setDirection(index > currentIndex ? 1 : -1);
|
||||
setCurrentIndex(index);
|
||||
setAutoplay(false);
|
||||
}}
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
index === currentIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<div className="text-center text-black px-4 mt-[300px] md:mt-[400px]">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="text-4xl md:text-6xl font-bold mb-4 font-['Playfair_Display']"
|
||||
>
|
||||
Элегантность в каждой детали
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
className="text-lg md:text-xl mb-8 max-w-2xl mx-auto"
|
||||
>
|
||||
Откройте для себя новую коллекцию, созданную с любовью к качеству и стилю
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<a
|
||||
href="#new-arrivals"
|
||||
className="bg-black text-white px-8 py-3 rounded-md font-medium hover:bg-gray-800 transition-colors inline-block"
|
||||
>
|
||||
Смотреть коллекцию
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,103 +1,256 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { Heart, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Product, formatPrice } from "../data/products"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface NewArrivalsProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
// Тип для товара
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
description: string;
|
||||
isNew?: boolean;
|
||||
products: Product[]
|
||||
}
|
||||
|
||||
export default function NewArrivals({ products }: NewArrivalsProps) {
|
||||
// Состояние для отслеживания наведения на карточки товаров
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
// Состояние для отслеживания избранных товаров
|
||||
const [favorites, setFavorites] = useState<number[]>([]);
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
const sliderRef = useRef<HTMLDivElement>(null)
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false)
|
||||
const [showRightArrow, setShowRightArrow] = useState(true)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [startX, setStartX] = useState(0)
|
||||
const [scrollLeftValue, setScrollLeftValue] = useState(0)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [slidesPerView, setSlidesPerView] = useState(4)
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(itemId => itemId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
// Определение количества слайдов на экране в зависимости от размера экрана
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) {
|
||||
setSlidesPerView(1);
|
||||
} else if (width < 768) {
|
||||
setSlidesPerView(2);
|
||||
} else if (width < 1024) {
|
||||
setSlidesPerView(3);
|
||||
} else {
|
||||
setSlidesPerView(4);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Обновление состояния стрелок при скролле
|
||||
const updateArrowVisibility = () => {
|
||||
if (sliderRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = sliderRef.current
|
||||
setShowLeftArrow(scrollLeft > 0)
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 5) // 5px buffer
|
||||
|
||||
// Обновление текущего слайда
|
||||
if (clientWidth > 0) {
|
||||
const slideWidth = scrollWidth / products.length;
|
||||
const newCurrentSlide = Math.round(scrollLeft / slideWidth);
|
||||
setCurrentSlide(newCurrentSlide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация и обработка изменений размера
|
||||
useEffect(() => {
|
||||
updateArrowVisibility()
|
||||
window.addEventListener("resize", updateArrowVisibility)
|
||||
return () => window.removeEventListener("resize", updateArrowVisibility)
|
||||
}, [])
|
||||
|
||||
// Обработчики скролла
|
||||
const handleScroll = () => {
|
||||
updateArrowVisibility()
|
||||
}
|
||||
|
||||
const scrollLeft = () => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
const newScrollLeft = Math.max(0, sliderRef.current.scrollLeft - (itemWidth * slidesPerView));
|
||||
sliderRef.current.scrollTo({ left: newScrollLeft, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
const scrollRight = () => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
const newScrollLeft = Math.min(
|
||||
sliderRef.current.scrollWidth - sliderRef.current.clientWidth,
|
||||
sliderRef.current.scrollLeft + (itemWidth * slidesPerView)
|
||||
);
|
||||
sliderRef.current.scrollTo({ left: newScrollLeft, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчики перетаскивания (для мобильных)
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
setStartX(e.pageX - (sliderRef.current?.offsetLeft || 0))
|
||||
setScrollLeftValue(sliderRef.current?.scrollLeft || 0)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
e.preventDefault()
|
||||
if (sliderRef.current) {
|
||||
const x = e.pageX - (sliderRef.current.offsetLeft || 0)
|
||||
const walk = (x - startX) * 2 // Скорость скролла
|
||||
sliderRef.current.scrollLeft = scrollLeftValue - walk
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Обработчики тач-событий
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (sliderRef.current) {
|
||||
setStartX(e.touches[0].pageX - (sliderRef.current.offsetLeft || 0))
|
||||
setScrollLeftValue(sliderRef.current.scrollLeft)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (sliderRef.current) {
|
||||
const x = e.touches[0].pageX - (sliderRef.current.offsetLeft || 0)
|
||||
const walk = (x - startX) * 2
|
||||
sliderRef.current.scrollLeft = scrollLeftValue - walk
|
||||
}
|
||||
}
|
||||
|
||||
// Переход к определенному слайду
|
||||
const goToSlide = (index: number) => {
|
||||
if (sliderRef.current) {
|
||||
const itemWidth = sliderRef.current.scrollWidth / products.length;
|
||||
sliderRef.current.scrollTo({ left: itemWidth * index, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-8 bg-white">
|
||||
<div className="container mx-auto px-6">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl text-center font-medium mb-12"
|
||||
<section id="new-arrivals" className="my-12 px-4 md:px-8 max-w-7xl mx-auto relative">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-6 font-['Playfair_Display']">Новинки</h2>
|
||||
|
||||
{/* Контейнер слайдера с кнопками навигации */}
|
||||
<div className="relative">
|
||||
{/* Кнопка влево */}
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="absolute -left-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-2 shadow-md transition-all"
|
||||
aria-label="Предыдущие товары"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Слайдер продуктов */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="flex overflow-x-auto gap-4 md:gap-6 pb-6 scrollbar-hide snap-x snap-mandatory"
|
||||
onScroll={handleScroll}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleMouseUp}
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
Новинки
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-12">
|
||||
{products.map((product, index) => (
|
||||
{products.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
className="flex-none w-[280px] sm:w-[320px] md:w-[300px] lg:w-[280px] xl:w-[300px] snap-start"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group relative"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: product.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
{/* Метка "New" */}
|
||||
{product.isNew && (
|
||||
<div className="absolute top-4 left-4 z-10 bg-gray-100 text-gray-700 text-xs px-3 py-1 rounded-full">
|
||||
New
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопка избранного */}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 z-10 p-1"
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<Heart
|
||||
className={`w-6 h-6 transition-colors ${
|
||||
favorites.includes(product.id)
|
||||
? 'fill-black text-black'
|
||||
: 'text-gray-400 hover:text-black'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Изображение товара */}
|
||||
<div className="relative aspect-[3/4] w-full overflow-hidden mb-4">
|
||||
<Image
|
||||
src={hoveredProduct === product.id && product.images.length > 1 ? product.images[1] : product.images[0]}
|
||||
alt={product.name}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="transition-all duration-500 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="px-1">
|
||||
<h3 className="text-sm text-gray-700 mb-2 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-base font-medium">{product.price.toLocaleString()} ₽</p>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1 ? product.images[1] : product.images[0]
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 280px, (max-width: 768px) 320px, (max-width: 1024px) 300px, 280px"
|
||||
className="object-cover transition-all duration-500 hover:scale-105"
|
||||
/>
|
||||
{product.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">Новинка</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(product.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Кнопка вправо */}
|
||||
{showRightArrow && (
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="absolute -right-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white rounded-full p-2 shadow-md transition-all"
|
||||
aria-label="Следующие товары"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Индикаторы слайдов (точки) */}
|
||||
<div className="flex justify-center mt-6 space-x-2">
|
||||
{Array.from({ length: Math.ceil(products.length / slidesPerView) }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index * slidesPerView)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
Math.floor(currentSlide / slidesPerView) === index ? "bg-black scale-150" : "bg-gray-300"
|
||||
}`}
|
||||
aria-label={`Перейти к слайду ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
@ -1,58 +1,43 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Category } from "../data/categories"
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface PopularCategoriesProps {
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
// Тип для категории
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export default function PopularCategories({ categories }: PopularCategoriesProps) {
|
||||
return (
|
||||
<section className="py-8 bg-white">
|
||||
<div className="container mx-auto px-6">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-3xl text-center font-medium mb-12"
|
||||
>
|
||||
Популярные категории
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{categories.map((category, index) => (
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-8 font-['Playfair_Display']">Популярные категории</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
{categories.map((category, index) => (
|
||||
<Link href={category.url} key={category.id} className="group">
|
||||
<motion.div
|
||||
key={category.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group"
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="overflow-hidden rounded-xl"
|
||||
>
|
||||
<Link href={category.url} className="block">
|
||||
<div className="relative aspect-square w-full overflow-hidden mb-4 bg-gray-100">
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={category.image}
|
||||
alt={category.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<h3 className="text-base md:text-lg font-medium">{category.name}</h3>
|
||||
</div>
|
||||
<h3 className="text-center text-sm font-medium">{category.name}</h3>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
)
|
||||
}
|
||||
87
data/categories.ts
Normal file
87
data/categories.ts
Normal file
@ -0,0 +1,87 @@
|
||||
// Тип для категории
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Данные о категориях
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Женская обувь',
|
||||
image: '/category/shoes.jpg',
|
||||
url: '/category/shoes',
|
||||
slug: 'shoes',
|
||||
description: 'Элегантная и комфортная обувь для любого случая. Наша коллекция включает в себя модели из высококачественных материалов, созданные с вниманием к деталям.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Шляпы и перчатки',
|
||||
image: '/category/hat.jpg',
|
||||
url: '/category/hats-and-gloves',
|
||||
slug: 'hats-and-gloves',
|
||||
description: 'Стильные аксессуары, которые дополнят ваш образ и защитят от непогоды. Изготовлены из премиальных материалов с использованием традиционных техник.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Штаны и брюки',
|
||||
image: '/category/pants.jpg',
|
||||
url: '/category/pants',
|
||||
slug: 'pants',
|
||||
description: 'Разнообразные модели брюк для любого случая. От классических деловых моделей до повседневных и спортивных вариантов, созданных для комфорта и стиля.'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Свитеры и кардиганы',
|
||||
image: '/category/sweaters.jpg',
|
||||
url: '/category/sweaters',
|
||||
slug: 'sweaters',
|
||||
description: 'Теплые и уютные свитеры и кардиганы из натуральных материалов. Идеальный выбор для холодного времени года, сочетающий комфорт и элегантность.'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Платья и юбки',
|
||||
image: '/category/dress.jpg',
|
||||
url: '/category/dress',
|
||||
slug: 'dress',
|
||||
description: 'Элегантные платья и юбки для любого случая. От повседневных моделей до вечерних нарядов, подчеркивающих женственность и изысканность.'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Костюмы и пальто',
|
||||
image: '/category/jacket.jpg',
|
||||
url: '/category/jackets',
|
||||
slug: 'jackets',
|
||||
description: 'Стильные костюмы и пальто высокого качества. Идеальный выбор для создания элегантного образа в любое время года.'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Женский шелк',
|
||||
image: '/category/silk.jpg',
|
||||
url: '/category/womens-silk',
|
||||
slug: 'womens-silk',
|
||||
description: 'Роскошные изделия из натурального шелка. Блузки, платья и аксессуары, которые подчеркнут вашу индивидуальность и создадут ощущение роскоши.'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Аксессуары',
|
||||
image: '/category/scarf.jpg',
|
||||
url: '/category/accessories',
|
||||
slug: 'accessories',
|
||||
description: 'Стильные аксессуары, которые дополнят ваш образ. Сумки, шарфы, ремни и другие детали, которые сделают ваш образ завершенным и уникальным.'
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения категории по slug
|
||||
export const getCategoryBySlug = (slug: string): Category | undefined => {
|
||||
return categories.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения категории по id
|
||||
export const getCategoryById = (id: number): Category | undefined => {
|
||||
return categories.find(category => category.id === id);
|
||||
};
|
||||
63
data/collections.ts
Normal file
63
data/collections.ts
Normal file
@ -0,0 +1,63 @@
|
||||
// Тип для коллекции
|
||||
export interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// Данные о коллекциях
|
||||
export const collections: Collection[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Весна-Лето 2024',
|
||||
image: '/photos/photo1.jpg',
|
||||
description: 'Легкие ткани и яркие цвета для теплого сезона. Наша новая коллекция воплощает свежесть и элегантность, идеально подходящую для весенних и летних дней.',
|
||||
url: '/collections/spring-summer-2024',
|
||||
slug: 'spring-summer-2024'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Осень-Зима 2023',
|
||||
image: '/photos/photo2.jpg',
|
||||
description: 'Теплые и уютные модели для холодного времени года. Коллекция сочетает в себе комфорт и стиль, предлагая элегантные решения для зимнего гардероба.',
|
||||
url: '/collections/autumn-winter-2023',
|
||||
slug: 'autumn-winter-2023'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Базовый гардероб',
|
||||
image: '/photos/head_photo.png',
|
||||
description: 'Классические модели, которые никогда не выходят из моды. Эта коллекция представляет собой основу любого гардероба, включая вневременные предметы одежды высокого качества.',
|
||||
url: '/collections/basic',
|
||||
slug: 'basic'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Вечерняя коллекция',
|
||||
image: '/photos/evening.jpg',
|
||||
description: 'Элегантные наряды для особых случаев. Изысканные ткани и утонченный дизайн создают неповторимые образы для вечерних мероприятий и торжественных событий.',
|
||||
url: '/collections/evening',
|
||||
slug: 'evening'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Деловой стиль',
|
||||
image: '/photos/business.jpg',
|
||||
description: 'Стильная и функциональная одежда для работы и деловых встреч. Коллекция сочетает в себе профессионализм и элегантность, подчеркивая ваш статус и вкус.',
|
||||
url: '/collections/business',
|
||||
slug: 'business'
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения коллекции по slug
|
||||
export const getCollectionBySlug = (slug: string): Collection | undefined => {
|
||||
return collections.find(collection => collection.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения коллекции по id
|
||||
export const getCollectionById = (id: number): Collection | undefined => {
|
||||
return collections.find(collection => collection.id === id);
|
||||
};
|
||||
170
data/products.ts
Normal file
170
data/products.ts
Normal file
@ -0,0 +1,170 @@
|
||||
// Тип для товара
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
description: string;
|
||||
isNew?: boolean;
|
||||
categoryId: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// Данные о товарах
|
||||
export const products: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Пальто оверсайз',
|
||||
price: 43800,
|
||||
images: ['/wear/palto1.jpg', '/wear/palto2.jpg'],
|
||||
description: 'Элегантное пальто оверсайз высокого качества. Изготовлено из премиальных материалов, обеспечивающих комфорт и тепло. Идеально подходит для холодного сезона и создания стильного образа.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
slug: 'palto-oversaiz'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Костюм хлопок',
|
||||
price: 12800,
|
||||
images: ['/wear/pidzak2.jpg', '/wear/pidzak1.jpg'],
|
||||
description: 'Стильный костюм для особых случаев. Выполнен из высококачественного хлопка, обеспечивающего комфорт и элегантный внешний вид. Идеально подходит для деловых встреч и официальных мероприятий.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
slug: 'kostyum-hlopok'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Блузка',
|
||||
price: 3500,
|
||||
images: ['/wear/sorochka1.jpg', '/wear/sorochka2.jpg'],
|
||||
description: 'Классическая блузка в коричневом цвете. Изготовлена из мягкой и приятной к телу ткани. Универсальная модель, которая подойдет как для офиса, так и для повседневного образа.',
|
||||
isNew: true,
|
||||
categoryId: 7,
|
||||
slug: 'bluzka'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Платье со сборкой',
|
||||
price: 28800,
|
||||
images: ['/wear/jumpsuit_1.jpg', '/wear/jumpsuit_2.jpg'],
|
||||
description: 'Элегантное платье высокого качества со сборкой. Подчеркивает фигуру и создает женственный силуэт. Идеально подходит для особых случаев и вечерних мероприятий.',
|
||||
isNew: true,
|
||||
categoryId: 5,
|
||||
slug: 'plate-so-sborkoy'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Кожаные туфли',
|
||||
price: 15600,
|
||||
images: ['/wear/shoes1.jpg', '/wear/shoes2.jpg'],
|
||||
description: 'Элегантные кожаные туфли ручной работы. Изготовлены из натуральной кожи высшего качества. Комфортная колодка и стильный дизайн делают эту модель незаменимой в гардеробе.',
|
||||
isNew: false,
|
||||
categoryId: 1,
|
||||
slug: 'kozhanye-tufli'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Шелковый шарф',
|
||||
price: 5900,
|
||||
images: ['/wear/scarf1.jpg', '/wear/scarf2.jpg'],
|
||||
description: 'Роскошный шелковый шарф с уникальным принтом. Изготовлен из 100% натурального шелка. Добавит элегантности и шарма любому образу.',
|
||||
isNew: false,
|
||||
categoryId: 8,
|
||||
slug: 'shelkovyj-sharf'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Шерстяной свитер',
|
||||
price: 8700,
|
||||
images: ['/wear/sweater1.jpg', '/wear/sweater2.jpg'],
|
||||
description: 'Теплый шерстяной свитер крупной вязки. Изготовлен из мягкой шерсти мериноса. Идеально подходит для холодного времени года.',
|
||||
isNew: false,
|
||||
categoryId: 4,
|
||||
slug: 'sherstyanoj-sviter'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Классические брюки',
|
||||
price: 7500,
|
||||
images: ['/wear/pants1.jpg', '/wear/pants2.jpg'],
|
||||
description: 'Классические брюки прямого кроя. Выполнены из высококачественной ткани с добавлением эластана для комфортной посадки. Универсальная модель для офиса и повседневной носки.',
|
||||
isNew: false,
|
||||
categoryId: 3,
|
||||
slug: 'klassicheskie-bryuki'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Фетровая шляпа',
|
||||
price: 6200,
|
||||
images: ['/wear/hat1.jpg', '/wear/hat2.jpg'],
|
||||
description: 'Элегантная фетровая шляпа ручной работы. Изготовлена из высококачественного фетра. Дополнит любой образ и защитит от непогоды.',
|
||||
isNew: false,
|
||||
categoryId: 2,
|
||||
slug: 'fetrovaya-shlyapa'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Шелковая блузка',
|
||||
price: 9800,
|
||||
images: ['/wear/silk1.jpg', '/wear/silk2.jpg'],
|
||||
description: 'Роскошная шелковая блузка с элегантным дизайном. Изготовлена из 100% натурального шелка. Идеально подходит для создания изысканного образа.',
|
||||
isNew: true,
|
||||
categoryId: 7,
|
||||
slug: 'shelkovaya-bluzka'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Кожаная сумка',
|
||||
price: 18500,
|
||||
images: ['/wear/bag1.jpg', '/wear/bag2.jpg'],
|
||||
description: 'Стильная кожаная сумка ручной работы. Изготовлена из натуральной кожи высшего качества. Вместительная и функциональная модель для повседневного использования.',
|
||||
isNew: false,
|
||||
categoryId: 8,
|
||||
slug: 'kozhanaya-sumka'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Кашемировое пальто',
|
||||
price: 52000,
|
||||
images: ['/wear/coat1.jpg', '/wear/coat2.jpg'],
|
||||
description: 'Роскошное кашемировое пальто классического кроя. Изготовлено из 100% кашемира высшего качества. Элегантная модель, которая прослужит долгие годы.',
|
||||
isNew: true,
|
||||
categoryId: 6,
|
||||
slug: 'kashemirovoe-palto'
|
||||
}
|
||||
];
|
||||
|
||||
// Функция для получения товаров по категории
|
||||
export const getProductsByCategory = (categoryId: number): Product[] => {
|
||||
return products.filter(product => product.categoryId === categoryId);
|
||||
};
|
||||
|
||||
// Функция для получения новых товаров
|
||||
export const getNewProducts = (): Product[] => {
|
||||
return products.filter(product => product.isNew);
|
||||
};
|
||||
|
||||
// Функция для получения товара по slug
|
||||
export const getProductBySlug = (slug: string): Product | undefined => {
|
||||
return products.find(product => product.slug === slug);
|
||||
};
|
||||
|
||||
// Функция для получения товара по id
|
||||
export const getProductById = (id: number): Product | undefined => {
|
||||
return products.find(product => product.id === id);
|
||||
};
|
||||
|
||||
// Функция для получения похожих товаров
|
||||
export const getSimilarProducts = (productId: number, limit: number = 4): Product[] => {
|
||||
const product = getProductById(productId);
|
||||
if (!product) return [];
|
||||
|
||||
return products
|
||||
.filter(p => p.id !== productId && p.categoryId === product.categoryId)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
// Функция для форматирования цены
|
||||
export const formatPrice = (price: number): string => {
|
||||
return new Intl.NumberFormat('ru-RU').format(price);
|
||||
};
|
||||
170
pages/category/[slug].tsx
Normal file
170
pages/category/[slug].tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { GetStaticPaths, GetStaticProps } from "next"
|
||||
import Head from "next/head"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Heart } from "lucide-react"
|
||||
import Header from "../../components/Header"
|
||||
import Footer from "../../components/Footer"
|
||||
import { categories, getCategoryBySlug } from "../../data/categories"
|
||||
import { Product, getProductsByCategory, formatPrice } from "../../data/products"
|
||||
import { useRouter } from "next/router"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface CategoryPageProps {
|
||||
category: {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
url: string
|
||||
slug: string
|
||||
description: string
|
||||
}
|
||||
products: Product[]
|
||||
}
|
||||
|
||||
export default function CategoryPage({ category, products }: CategoryPageProps) {
|
||||
const router = useRouter()
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{category.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
|
||||
<div className="mb-12">
|
||||
<Link href="/category" className="text-gray-600 hover:text-black transition-colors">
|
||||
← Все категории
|
||||
</Link>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-4xl font-bold mt-4 font-['Playfair_Display']"
|
||||
>
|
||||
{category.name}
|
||||
</motion.h1>
|
||||
<p className="mt-2 text-gray-600 max-w-3xl">{category.description}</p>
|
||||
</div>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{products.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: product.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1
|
||||
? product.images[1]
|
||||
: product.images[0]
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{product.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">
|
||||
Новинка
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(product.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-600">В этой категории пока нет товаров</p>
|
||||
<Link href="/" className="mt-4 inline-block bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const paths = categories.map((category) => ({
|
||||
params: { slug: category.slug },
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: 'blocking',
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
const slug = params?.slug as string
|
||||
const category = getCategoryBySlug(slug)
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
const products = getProductsByCategory(category.id)
|
||||
|
||||
return {
|
||||
props: {
|
||||
category,
|
||||
products,
|
||||
},
|
||||
revalidate: 600, // Перегенерация страницы каждые 10 минут
|
||||
}
|
||||
}
|
||||
1
pages/category/index.tsx
Normal file
1
pages/category/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
|
||||
205
pages/collections/[slug].tsx
Normal file
205
pages/collections/[slug].tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { GetStaticPaths, GetStaticProps } from "next"
|
||||
import Head from "next/head"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Heart } from "lucide-react"
|
||||
import Header from "../../components/Header"
|
||||
import Footer from "../../components/Footer"
|
||||
import { collections, getCollectionBySlug } from "../../data/collections"
|
||||
import { Product, products, formatPrice } from "../../data/products"
|
||||
import { useRouter } from "next/router"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface CollectionPageProps {
|
||||
collection: {
|
||||
id: number
|
||||
name: string
|
||||
image: string
|
||||
description: string
|
||||
url: string
|
||||
slug: string
|
||||
}
|
||||
// Для демонстрации используем все товары, в реальном проекте нужно связать коллекции с товарами
|
||||
products: Product[]
|
||||
}
|
||||
|
||||
export default function CollectionPage({ collection, products }: CollectionPageProps) {
|
||||
const router = useRouter()
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null)
|
||||
const [favorites, setFavorites] = useState<number[]>([])
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setFavorites((prev) => (prev.includes(id) ? prev.filter((itemId) => itemId !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
// Для демонстрации выбираем случайные товары для коллекции
|
||||
// В реальном приложении здесь должна быть логика выбора товаров по коллекции
|
||||
const collectionProducts = products.slice(0, 8)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{collection.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
{/* Баннер коллекции */}
|
||||
<div className="relative h-[50vh] md:h-[60vh]">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
|
||||
<div className="text-center text-white px-4">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-5xl font-bold mb-4 font-['Playfair_Display']"
|
||||
>
|
||||
{collection.name}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="max-w-2xl mx-auto text-lg"
|
||||
>
|
||||
{collection.description}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список товаров */}
|
||||
<section className="py-12 px-4 md:px-8 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Link href="/collections" className="text-gray-600 hover:text-black transition-colors">
|
||||
← Все коллекции
|
||||
</Link>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mt-4 font-['Playfair_Display']">
|
||||
Товары из коллекции
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{collectionProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{collectionProducts.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: product.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${product.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(product.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === product.id && product.images.length > 1
|
||||
? product.images[1]
|
||||
: product.images[0]
|
||||
}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{product.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">
|
||||
Новинка
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(product.id, e)}
|
||||
className="absolute top-4 right-4 bg-white/80 hover:bg-white rounded-full p-2 transition-all"
|
||||
aria-label={favorites.includes(product.id) ? "Удалить из избранного" : "Добавить в избранное"}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${favorites.includes(product.id) ? "fill-red-500 text-red-500" : "text-gray-700"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{product.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(product.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-xl text-gray-600">В этой коллекции пока нет товаров</p>
|
||||
<Link href="/" className="mt-4 inline-block bg-black text-white px-6 py-2 rounded-md hover:bg-gray-800 transition-colors">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const paths = collections.map((collection) => ({
|
||||
params: { slug: collection.slug },
|
||||
}))
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: 'blocking',
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
const slug = params?.slug as string
|
||||
const collection = getCollectionBySlug(slug)
|
||||
|
||||
if (!collection) {
|
||||
return {
|
||||
notFound: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Для демонстрации используем все товары, в реальном проекте нужно связать коллекции с товарами
|
||||
// Можно добавить фильтрацию по коллекции, если добавить поле collectionId в товары
|
||||
|
||||
return {
|
||||
props: {
|
||||
collection,
|
||||
products,
|
||||
},
|
||||
revalidate: 600, // Перегенерация страницы каждые 10 минут
|
||||
}
|
||||
}
|
||||
73
pages/collections/index.tsx
Normal file
73
pages/collections/index.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Collection, collections } from '../../data/collections';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Collections() {
|
||||
const [hoveredCollection, setHoveredCollection] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>Коллекции | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl md:text-4xl font-bold mb-8 font-['Playfair_Display']"
|
||||
>
|
||||
Коллекции
|
||||
</motion.h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection) => (
|
||||
<motion.div
|
||||
key={collection.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: collection.id * 0.1 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/collections/${collection.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredCollection(collection.id)}
|
||||
onMouseLeave={() => setHoveredCollection(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl aspect-[16/9]">
|
||||
<Image
|
||||
src={collection.image}
|
||||
alt={collection.name}
|
||||
fill
|
||||
className={`object-cover transition-all duration-500 ${
|
||||
hoveredCollection === collection.id ? 'scale-110' : 'scale-100'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-30 transition-opacity duration-300"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<h2 className="text-white text-xl md:text-2xl font-bold text-center px-4 font-['Playfair_Display']">
|
||||
{collection.name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 line-clamp-3">{collection.description}</p>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
pages/index.tsx
149
pages/index.tsx
@ -13,6 +13,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Heart } from 'lucide-react';
|
||||
import Footer from '../components/Footer';
|
||||
import { Product, products as allProducts } from '../data/products';
|
||||
import { Collection, collections as allCollections } from '../data/collections';
|
||||
import { Category, categories as allCategories } from '../data/categories';
|
||||
|
||||
// Типы для свойств компонента
|
||||
interface HomeProps {
|
||||
@ -22,33 +25,6 @@ interface HomeProps {
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
// Тип для товара
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
description: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
// Тип для коллекции
|
||||
interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Тип для категории
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function Home({ heroImages, products, collections, categories }: HomeProps) {
|
||||
// Состояние для отслеживания наведения на карточки товаров
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
@ -135,119 +111,6 @@ export default function Home({ heroImages, products, collections, categories }:
|
||||
|
||||
// Функция для получения данных на стороне сервера
|
||||
export async function getStaticProps() {
|
||||
// Данные о товарах
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Пальто оверсайз',
|
||||
price: 43800,
|
||||
images: ['/wear/palto1.jpg', '/wear/palto2.jpg'],
|
||||
description: 'Элегантное пальто оверсайз высокого качества',
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Костюм хлопок',
|
||||
price: 12800,
|
||||
images: ['/wear/pidzak2.jpg', '/wear/pidzak1.jpg'],
|
||||
description: 'Стильный костюм для особых случаев',
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Блузка',
|
||||
price: 3500,
|
||||
images: ['/wear/sorochka1.jpg', '/wear/sorochka2.jpg'],
|
||||
description: 'Классическая блузка в коричневом цвете',
|
||||
isNew: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Платье со сборкой',
|
||||
price: 28800,
|
||||
images: ['/wear/jumpsuit_1.jpg', '/wear/jumpsuit_2.jpg'],
|
||||
description: 'Элегантное платье высокого качества',
|
||||
isNew: true
|
||||
}
|
||||
];
|
||||
|
||||
// Данные о коллекциях
|
||||
const collections = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Весна-Лето 2024',
|
||||
image: '/photos/photo1.jpg',
|
||||
description: 'Легкие ткани и яркие цвета для теплого сезона',
|
||||
url: '/collections/spring-summer-2024'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Осень-Зима 2023',
|
||||
image: '/photos/photo2.jpg',
|
||||
description: 'Теплые и уютные модели для холодного времени года',
|
||||
url: '/collections/autumn-winter-2023'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Базовый гардероб',
|
||||
image: '/photos/head_photo.png',
|
||||
description: 'Классические модели, которые никогда не выходят из моды',
|
||||
url: '/collections/basic'
|
||||
}
|
||||
];
|
||||
|
||||
// Данные о категориях
|
||||
const categories = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Женская обувь',
|
||||
image: '/category/shoes.jpg',
|
||||
url: '/category/shoes'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Шляпы и перчатки',
|
||||
image: '/category/hat.jpg',
|
||||
url: '/category/hats-and-gloves'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Штаны и брюки',
|
||||
image: '/category/pants.jpg',
|
||||
url: '/category/pants'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Свитеры и кардиганы',
|
||||
image: '/category/sweaters.jpg',
|
||||
url: '/category/sweaters'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Платья и юбки',
|
||||
image: '/category/dress.jpg',
|
||||
url: '/category/dress'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Костюмы',
|
||||
image: '/category/jacket.jpg',
|
||||
url: '/category/jackets'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Женский шелк',
|
||||
image: '/category/silk.jpg',
|
||||
url: '/category/womens-silk'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Аксессуары',
|
||||
image: '/category/scarf.jpg',
|
||||
url: '/category/accessories'
|
||||
}
|
||||
];
|
||||
|
||||
// Получение изображений для слайдера из папки hero_photos
|
||||
const heroImagesDirectory = path.join(process.cwd(), 'public/hero_photos');
|
||||
let heroImages = [];
|
||||
@ -275,9 +138,9 @@ export async function getStaticProps() {
|
||||
return {
|
||||
props: {
|
||||
heroImages,
|
||||
products,
|
||||
collections,
|
||||
categories
|
||||
products: allProducts,
|
||||
collections: allCollections,
|
||||
categories: allCategories
|
||||
},
|
||||
// Перегенерация страницы каждые 10 минут
|
||||
revalidate: 600,
|
||||
|
||||
338
pages/product/[slug].tsx
Normal file
338
pages/product/[slug].tsx
Normal file
@ -0,0 +1,338 @@
|
||||
import { useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { GetStaticProps, GetStaticPaths } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../../components/Header';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Product, products, getProductBySlug, getSimilarProducts, formatPrice } from '../../data/products';
|
||||
import { Category, getCategoryById } from '../../data/categories';
|
||||
import { Heart, ShoppingBag, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface ProductPageProps {
|
||||
product: Product;
|
||||
category: Category;
|
||||
similarProducts: Product[];
|
||||
}
|
||||
|
||||
export default function ProductPage({ product, category, similarProducts }: ProductPageProps) {
|
||||
const router = useRouter();
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [hoveredProduct, setHoveredProduct] = useState<number | null>(null);
|
||||
|
||||
// Если страница еще загружается, показываем заглушку
|
||||
if (router.isFallback) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Функция для переключения изображений
|
||||
const nextImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev === product.images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
setCurrentImageIndex((prev) => (prev === 0 ? product.images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
// Функция для добавления/удаления товара из избранного
|
||||
const toggleFavorite = () => {
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
// Функция для изменения количества товара
|
||||
const incrementQuantity = () => {
|
||||
setQuantity((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity > 1) {
|
||||
setQuantity((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для добавления товара в корзину
|
||||
const addToCart = () => {
|
||||
// Здесь будет логика добавления товара в корзину
|
||||
alert(`Товар "${product.name}" добавлен в корзину в количестве ${quantity} шт.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white font-['Arimo']">
|
||||
<Head>
|
||||
<title>{product.name} | Brand Store</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-12 md:px-8">
|
||||
<div className="mb-8">
|
||||
<Link href={`/category/${category.slug}`} className="text-gray-600 hover:text-black transition-colors">
|
||||
← {category.name}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Галерея изображений */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentImageIndex}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Image
|
||||
src={product.images[currentImageIndex]}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={95}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Кнопки навигации по галерее */}
|
||||
{product.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-3 rounded-full transition-all z-10"
|
||||
aria-label="Предыдущее изображение"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-black" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-white/50 hover:bg-white/70 p-3 rounded-full transition-all z-10"
|
||||
aria-label="Следующее изображение"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-black" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Миниатюры изображений */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="flex mt-4 space-x-2 overflow-x-auto">
|
||||
{product.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentImageIndex(index)}
|
||||
className={`relative w-20 h-20 rounded-md overflow-hidden ${
|
||||
index === currentImageIndex ? 'ring-2 ring-black' : 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
aria-label={`Изображение ${index + 1}`}
|
||||
>
|
||||
<Image src={image} alt={`${product.name} - изображение ${index + 1}`} fill className="object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-3xl font-bold font-['Playfair_Display']"
|
||||
>
|
||||
{product.name}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-2xl font-bold mt-4"
|
||||
>
|
||||
{formatPrice(product.price)} ₽
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-6"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Описание</h2>
|
||||
<p className="text-gray-600">{product.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Количество</h2>
|
||||
<div className="flex items-center border border-gray-300 rounded-md w-fit">
|
||||
<button
|
||||
onClick={decrementQuantity}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Уменьшить количество"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-4 py-2 border-l border-r border-gray-300">{quantity}</span>
|
||||
<button
|
||||
onClick={incrementQuantity}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="mt-8 flex flex-col sm:flex-row gap-4"
|
||||
>
|
||||
<button
|
||||
onClick={addToCart}
|
||||
className="flex items-center justify-center gap-2 bg-black text-white px-8 py-3 rounded-md hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Добавить в корзину
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
className={`flex items-center justify-center gap-2 px-8 py-3 rounded-md border transition-colors ${
|
||||
isFavorite
|
||||
? 'border-red-500 text-red-500 hover:bg-red-50'
|
||||
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Heart className={isFavorite ? 'w-5 h-5 fill-red-500' : 'w-5 h-5'} />
|
||||
{isFavorite ? 'В избранном' : 'В избранное'}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="mt-8 border-t border-gray-200 pt-8"
|
||||
>
|
||||
<h2 className="text-lg font-medium mb-2">Категория</h2>
|
||||
<Link href={`/category/${category.slug}`} className="text-gray-600 hover:text-black transition-colors">
|
||||
{category.name}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Похожие товары */}
|
||||
{similarProducts.length > 0 && (
|
||||
<section className="mt-16">
|
||||
<h2 className="text-2xl font-bold mb-8 font-['Playfair_Display']">Похожие товары</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{similarProducts.map((similarProduct) => (
|
||||
<motion.div
|
||||
key={similarProduct.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: similarProduct.id * 0.05 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Link
|
||||
href={`/product/${similarProduct.slug}`}
|
||||
className="block h-full"
|
||||
onMouseEnter={() => setHoveredProduct(similarProduct.id)}
|
||||
onMouseLeave={() => setHoveredProduct(null)}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-[3/4] relative overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={
|
||||
hoveredProduct === similarProduct.id && similarProduct.images.length > 1
|
||||
? similarProduct.images[1]
|
||||
: similarProduct.images[0]
|
||||
}
|
||||
alt={similarProduct.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||
className="object-cover transition-all duration-500 group-hover:scale-105"
|
||||
/>
|
||||
{similarProduct.isNew && (
|
||||
<span className="absolute top-4 left-4 bg-black text-white text-sm py-1 px-3 rounded">
|
||||
Новинка
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium">{similarProduct.name}</h3>
|
||||
<p className="mt-1 text-lg font-bold">{formatPrice(similarProduct.price)} ₽</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const paths = products.map((product) => ({
|
||||
params: { slug: product.slug },
|
||||
}));
|
||||
|
||||
return {
|
||||
paths,
|
||||
fallback: 'blocking',
|
||||
};
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
const slug = params?.slug as string;
|
||||
const product = getProductBySlug(slug);
|
||||
|
||||
if (!product) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const category = getCategoryById(product.categoryId);
|
||||
const similarProducts = getSimilarProducts(product.id);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
product,
|
||||
category,
|
||||
similarProducts,
|
||||
},
|
||||
revalidate: 600, // Перегенерация страницы каждые 10 минут
|
||||
};
|
||||
};
|
||||
@ -21,6 +21,16 @@
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Скрываем скроллбар для слайдера продуктов */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user