From 0ed0a91161469dc09acf7be9f6cb35ed66ded08b Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Tue, 10 Mar 2026 12:23:11 +0300 Subject: [PATCH] feat: showcase layout, photo filter, team specializations, scroll UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace modals with ShowcaseLayout for Team and Classes sections - Add warm photo filter matching dark/gold color scheme - Replace generic "Тренер" with actual specializations per member - Fix heart logo color animation loop (seamless repeat) - Style scrollbar with gold theme, pause auto-rotation on hover - Auto-scroll only when active item is out of view Co-Authored-By: Claude Opus 4.6 --- src/app/styles/animations.css | 37 +++++ src/app/styles/theme.css | 35 ++++ src/components/sections/Classes.tsx | 122 ++++++++------ src/components/sections/Team.tsx | 225 ++++++++------------------ src/components/ui/ClassModal.tsx | 119 -------------- src/components/ui/HeroLogo.tsx | 13 +- src/components/ui/ShowcaseLayout.tsx | 106 ++++++++++++ src/components/ui/TeamMemberModal.tsx | 91 ----------- src/data/content.ts | 32 ++-- src/hooks/useShowcaseRotation.ts | 45 ++++++ 10 files changed, 386 insertions(+), 439 deletions(-) delete mode 100644 src/components/ui/ClassModal.tsx create mode 100644 src/components/ui/ShowcaseLayout.tsx delete mode 100644 src/components/ui/TeamMemberModal.tsx create mode 100644 src/hooks/useShowcaseRotation.ts diff --git a/src/app/styles/animations.css b/src/app/styles/animations.css index 57ffa72..181cdc3 100644 --- a/src/app/styles/animations.css +++ b/src/app/styles/animations.css @@ -200,6 +200,38 @@ transform: translateY(0); } +/* ===== Showcase ===== */ + +@keyframes showcase-detail-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes showcase-image-enter { + from { + opacity: 0; + transform: scale(1.03); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.showcase-detail-enter { + animation: showcase-detail-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.showcase-detail-enter img { + animation: showcase-image-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + /* ===== Modal ===== */ @keyframes modal-fade-in { @@ -270,6 +302,11 @@ animation: none !important; } + .showcase-detail-enter, + .showcase-detail-enter img { + animation: none !important; + } + .glow-hover:hover { transform: none; } diff --git a/src/app/styles/theme.css b/src/app/styles/theme.css index 08080e7..2487dc7 100644 --- a/src/app/styles/theme.css +++ b/src/app/styles/theme.css @@ -88,3 +88,38 @@ .glass-card:hover { @apply dark:border-[#c9a96e]/15 dark:bg-white/[0.06]; } + +/* ===== Photo Filter ===== */ + +.photo-filter { + filter: saturate(0.7) sepia(0.15) brightness(0.95) contrast(1.05); +} + +:is(.dark) .photo-filter { + filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1); +} + +/* ===== Custom Scrollbar ===== */ + +.styled-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(201, 169, 110, 0.25) transparent; +} + +.styled-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.styled-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.styled-scrollbar::-webkit-scrollbar-thumb { + background: rgba(201, 169, 110, 0.25); + border-radius: 4px; +} + +.styled-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(201, 169, 110, 0.4); +} diff --git a/src/components/sections/Classes.tsx b/src/components/sections/Classes.tsx index cb16417..e692764 100644 --- a/src/components/sections/Classes.tsx +++ b/src/components/sections/Classes.tsx @@ -1,12 +1,12 @@ "use client"; -import { useState } from "react"; import Image from "next/image"; -import { Flame, Sparkles, Wind, Zap, Star, Monitor, ArrowRight } from "lucide-react"; +import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react"; import { siteContent } from "@/data/content"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; -import { ClassModal } from "@/components/ui/ClassModal"; +import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; +import { useShowcaseRotation } from "@/hooks/useShowcaseRotation"; import type { ClassItem } from "@/types"; const iconMap: Record = { @@ -20,7 +20,10 @@ const iconMap: Record = { export function Classes() { const { classes } = siteContent; - const [selectedClass, setSelectedClass] = useState(null); + const { activeIndex, select, setHovering } = useShowcaseRotation({ + totalItems: classes.items.length, + autoPlayInterval: 5000, + }); return (
@@ -30,60 +33,79 @@ export function Classes() { {classes.title} -
- {classes.items.map((item) => ( - -
setSelectedClass(item)} - > - {/* Background image */} - {item.images && item.images[0] && ( - {item.name} - )} +
+ + + items={classes.items} + activeIndex={activeIndex} + onSelect={select} + onHoverChange={setHovering} + renderDetail={(item) => ( +
+ {/* Hero image */} + {item.images && item.images[0] && ( +
+ {item.name} + {/* Gradient overlay */} +
- {/* Dark gradient overlay */} -
+ {/* Icon + name overlay */} +
+
+ {iconMap[item.icon]} +
+

+ {item.name} +

+
+
+ )} - {/* Gold tint on hover */} -
- - {/* Content */} -
- {/* Icon badge */} -
+ {/* Description */} + {item.detailedDescription && ( +
+ {item.detailedDescription} +
+ )} +
+ )} + renderSelectorItem={(item, _i, isActive) => ( +
+ {/* Icon */} +
{iconMap[item.icon]}
- -

- {item.name} -

- -

- {item.description} -

- - {/* Hover arrow */} -
- Подробнее - +
+

+ {item.name} +

+

+ {item.description} +

-
- - ))} + )} + /> +
- - setSelectedClass(null)} - />
); } diff --git a/src/components/sections/Team.tsx b/src/components/sections/Team.tsx index 8a8729c..9ba2d4b 100644 --- a/src/components/sections/Team.tsx +++ b/src/components/sections/Team.tsx @@ -1,100 +1,19 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; import Image from "next/image"; -import { Instagram, ChevronLeft, ChevronRight } from "lucide-react"; +import { Instagram } from "lucide-react"; import { siteContent } from "@/data/content"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; -import { TeamMemberModal } from "@/components/ui/TeamMemberModal"; +import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; +import { useShowcaseRotation } from "@/hooks/useShowcaseRotation"; import type { TeamMember } from "@/types"; export function Team() { const { team } = siteContent; - const [selectedMember, setSelectedMember] = useState(null); - const scrollRef = useRef(null); - const scrollTimer = useRef>(null); - const isDragging = useRef(false); - const dragStartX = useRef(0); - const dragScrollLeft = useRef(0); - const dragMoved = useRef(false); - - // Render 3 copies: [clone] [original] [clone] - const tripled = [...team.members, ...team.members, ...team.members]; - - // On mount, jump to the middle set (no animation) - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - requestAnimationFrame(() => { - const cardWidth = el.scrollWidth / 3; - el.scrollLeft = cardWidth; - }); - }, []); - - // When scroll settles, check if we need to loop - const handleScroll = useCallback(() => { - if (scrollTimer.current) clearTimeout(scrollTimer.current); - scrollTimer.current = setTimeout(() => { - const el = scrollRef.current; - if (!el) return; - const oneSetWidth = el.scrollWidth / 3; - if (el.scrollLeft < oneSetWidth * 0.3) { - el.style.scrollBehavior = "auto"; - el.scrollLeft += oneSetWidth; - el.style.scrollBehavior = ""; - } - if (el.scrollLeft > oneSetWidth * 1.7) { - el.style.scrollBehavior = "auto"; - el.scrollLeft -= oneSetWidth; - el.style.scrollBehavior = ""; - } - }, 100); - }, []); - - // Mouse drag handlers - function handleMouseDown(e: React.MouseEvent) { - const el = scrollRef.current; - if (!el) return; - isDragging.current = true; - dragMoved.current = false; - dragStartX.current = e.pageX; - dragScrollLeft.current = el.scrollLeft; - el.style.scrollBehavior = "auto"; - el.style.scrollSnapType = "none"; - el.style.cursor = "grabbing"; - } - - function handleMouseMove(e: React.MouseEvent) { - if (!isDragging.current || !scrollRef.current) return; - e.preventDefault(); - const dx = e.pageX - dragStartX.current; - if (Math.abs(dx) > 3) dragMoved.current = true; - scrollRef.current.scrollLeft = dragScrollLeft.current - dx; - } - - function handleMouseUp() { - if (!isDragging.current || !scrollRef.current) return; - isDragging.current = false; - scrollRef.current.style.scrollBehavior = ""; - scrollRef.current.style.scrollSnapType = ""; - scrollRef.current.style.cursor = ""; - } - - function handleCardClick(member: TeamMember) { - // Don't open modal if user was dragging - if (dragMoved.current) return; - setSelectedMember(member); - } - - function scroll(direction: "left" | "right") { - if (!scrollRef.current) return; - const amount = scrollRef.current.offsetWidth * 0.7; - scrollRef.current.scrollBy({ - left: direction === "left" ? -amount : amount, - behavior: "smooth", - }); - } + const { activeIndex, select, setHovering } = useShowcaseRotation({ + totalItems: team.members.length, + }); return (
@@ -104,92 +23,86 @@ export function Team() { {team.title} - - {/* Carousel wrapper */} - -
- {/* Scroll container */} -
- {tripled.map((member, i) => ( -
handleCardClick(member)} - > - {/* Photo */} -
+
+ + + items={team.members} + activeIndex={activeIndex} + onSelect={select} + onHoverChange={setHovering} + renderDetail={(member) => ( +
{member.name} -
- {/* Gradient overlay */} -
+ {/* Gradient overlay */} +
- {/* Gold glow on hover */} -
+ {/* Text over photo */} +
+

+ {member.name} +

+

+ {member.role} +

- {/* Content */} -
-

- {member.name} -

- {member.instagram && ( - e.stopPropagation()} - > - + {member.instagram && ( + {member.instagram.split("/").filter(Boolean).pop()} - - )} + )} + + {member.description && ( +

+ {member.description} +

+ )} +
-
- ))} -
- - {/* Side navigation arrows */} - - + )} + renderSelectorItem={(member, _i, isActive) => ( +
+ {/* Thumbnail */} +
+ {member.name} +
+
+

+ {member.name} +

+

+ {member.role} +

+
+
+ )} + /> +
-
- - setSelectedMember(null)} - /> +
); } diff --git a/src/components/ui/ClassModal.tsx b/src/components/ui/ClassModal.tsx deleted file mode 100644 index 3aef388..0000000 --- a/src/components/ui/ClassModal.tsx +++ /dev/null @@ -1,119 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import Image from "next/image"; -import { X, Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react"; -import type { ClassItem } from "@/types"; - -const iconMap: Record = { - flame: , - sparkles: , - wind: , - zap: , - star: , - monitor: , -}; - -interface ClassModalProps { - classItem: ClassItem | null; - onClose: () => void; -} - -export function ClassModal({ classItem, onClose }: ClassModalProps) { - useEffect(() => { - if (!classItem) return; - - document.body.style.overflow = "hidden"; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - - document.addEventListener("keydown", handleKeyDown); - return () => { - document.body.style.overflow = ""; - document.removeEventListener("keydown", handleKeyDown); - }; - }, [classItem, onClose]); - - if (!classItem) return null; - - const heroImage = classItem.images?.[0]; - - return ( -
-
e.stopPropagation()} - > - {/* Hero image banner */} - {heroImage && ( -
- {classItem.name} -
- - {/* Close button */} - - - {/* Title on image */} -
-
-
- {iconMap[classItem.icon]} -
-

- {classItem.name} -

-
-
-
- )} - - {/* Content */} -
- {/* Title fallback when no image */} - {!heroImage && ( -
-
-
- {iconMap[classItem.icon]} -
-

- {classItem.name} -

-
- -
- )} - - {classItem.detailedDescription && ( -
- {classItem.detailedDescription} -
- )} - -
-
-
- ); -} diff --git a/src/components/ui/HeroLogo.tsx b/src/components/ui/HeroLogo.tsx index a0f2cd0..a493080 100644 --- a/src/components/ui/HeroLogo.tsx +++ b/src/components/ui/HeroLogo.tsx @@ -21,17 +21,16 @@ export function HeroLogo({ className = "", size = 220, animated = false }: HeroL - + - - + + - - + + - - + diff --git a/src/components/ui/ShowcaseLayout.tsx b/src/components/ui/ShowcaseLayout.tsx new file mode 100644 index 0000000..b82be8f --- /dev/null +++ b/src/components/ui/ShowcaseLayout.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useRef, useEffect, useState } from "react"; + +interface ShowcaseLayoutProps { + items: T[]; + activeIndex: number; + onSelect: (index: number) => void; + onHoverChange?: (hovering: boolean) => void; + renderDetail: (item: T, index: number) => React.ReactNode; + renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode; +} + +export function ShowcaseLayout({ + items, + activeIndex, + onSelect, + onHoverChange, + renderDetail, + renderSelectorItem, +}: ShowcaseLayoutProps) { + const selectorRef = useRef(null); + const activeItemRef = useRef(null); + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Auto-scroll selector only when item is out of view + useEffect(() => { + if (isUserInteracting) return; + + const container = selectorRef.current; + const activeEl = activeItemRef.current; + if (!container || !activeEl) return; + + const isHorizontal = window.innerWidth < 1024; + + if (isHorizontal) { + const elLeft = activeEl.offsetLeft; + const elRight = elLeft + activeEl.offsetWidth; + const scrollLeft = container.scrollLeft; + const viewRight = scrollLeft + container.offsetWidth; + + if (elLeft < scrollLeft || elRight > viewRight) { + const left = elLeft - container.offsetWidth / 2 + activeEl.offsetWidth / 2; + container.scrollTo({ left, behavior: "smooth" }); + } + } else { + const elTop = activeEl.offsetTop; + const elBottom = elTop + activeEl.offsetHeight; + const scrollTop = container.scrollTop; + const viewBottom = scrollTop + container.offsetHeight; + + if (elTop < scrollTop || elBottom > viewBottom) { + const top = elTop - container.offsetHeight / 2 + activeEl.offsetHeight / 2; + container.scrollTo({ top, behavior: "smooth" }); + } + } + }, [activeIndex, isUserInteracting]); + + function handleMouseEnter() { + setIsUserInteracting(true); + onHoverChange?.(true); + } + + function handleMouseLeave() { + setIsUserInteracting(false); + onHoverChange?.(false); + } + + return ( +
+ {/* Detail area */} +
+
+ {renderDetail(items[activeIndex], activeIndex)} +
+
+ + {/* Selector */} +
+
+ {items.map((item, i) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/ui/TeamMemberModal.tsx b/src/components/ui/TeamMemberModal.tsx deleted file mode 100644 index d4ee570..0000000 --- a/src/components/ui/TeamMemberModal.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import Image from "next/image"; -import { X, Instagram } from "lucide-react"; -import type { TeamMember } from "@/types"; - -interface TeamMemberModalProps { - member: TeamMember | null; - onClose: () => void; -} - -export function TeamMemberModal({ member, onClose }: TeamMemberModalProps) { - useEffect(() => { - if (!member) return; - - document.body.style.overflow = "hidden"; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - - document.addEventListener("keydown", handleKeyDown); - return () => { - document.body.style.overflow = ""; - document.removeEventListener("keydown", handleKeyDown); - }; - }, [member, onClose]); - - if (!member) return null; - - return ( -
-
e.stopPropagation()} - > - {/* Hero photo */} -
- {member.name} - {/* Gradient overlay */} -
- - {/* Close button */} - - - {/* Name + Instagram on photo */} -
-

- {member.name} -

- {member.instagram && ( - - - {member.instagram.split("/").filter(Boolean).pop()} - - )} -
-
- - {/* Description */} - {member.description && ( -
-

- {member.description} -

-
- )} -
-
- ); -} diff --git a/src/data/content.ts b/src/data/content.ts index 5b8645b..fd2cf71 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -25,7 +25,7 @@ export const siteContent: SiteContent = { members: [ { name: "Виктор Артёмов", - role: "Тренер", + role: "Pole Fitness · Exotic · Strip", image: "/images/team/viktor-artyomov.webp", instagram: "https://instagram.com/viktor.artyomov/", description: @@ -33,7 +33,7 @@ export const siteContent: SiteContent = { }, { name: "Анна Тарыба", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/anna-taryba.webp", instagram: "https://instagram.com/annataryba/", description: @@ -41,7 +41,7 @@ export const siteContent: SiteContent = { }, { name: "Анастасия Чалей", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/anastasia-chaley.webp", instagram: "https://instagram.com/nastya_chaley/", description: @@ -49,7 +49,7 @@ export const siteContent: SiteContent = { }, { name: "Ольга Демидова", - role: "Тренер", + role: "Pole Dance", image: "/images/team/olga-demidova.webp", instagram: "https://instagram.com/don_olga_red/", description: @@ -57,7 +57,7 @@ export const siteContent: SiteContent = { }, { name: "Ирина Третьякович", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/irina-tretyukovich.webp", instagram: "https://instagram.com/irkatretya/", description: @@ -65,7 +65,7 @@ export const siteContent: SiteContent = { }, { name: "Надежда Сыч", - role: "Тренер", + role: "Exotic Pole Dance · Body Plastic", image: "/images/team/nadezhda-sukh.webp", instagram: "https://instagram.com/nadja.dance/", description: @@ -73,7 +73,7 @@ export const siteContent: SiteContent = { }, { name: "Ирина Карпусь", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/irina-karpus.webp", instagram: "https://instagram.com/karpus_iri/", description: @@ -81,7 +81,7 @@ export const siteContent: SiteContent = { }, { name: "Юлия Книга", - role: "Тренер", + role: "Erotic Pole Dance", image: "/images/team/yuliya-kniga.webp", instagram: "https://instagram.com/knigynzel/", description: @@ -89,7 +89,7 @@ export const siteContent: SiteContent = { }, { name: "Алёна Чигилейчик", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/elena-chigileychik.webp", instagram: "https://instagram.com/alenachygi/", description: @@ -97,7 +97,7 @@ export const siteContent: SiteContent = { }, { name: "Елена Тарасевич", - role: "Тренер", + role: "Body Plastic", image: "/images/team/elena-tarasevic.webp", instagram: "https://instagram.com/cerceia/", description: @@ -105,7 +105,7 @@ export const siteContent: SiteContent = { }, { name: "Кристина Войтович", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/kristina-voytovich.webp", instagram: "https://instagram.com/chris_voytovich/", description: @@ -113,35 +113,35 @@ export const siteContent: SiteContent = { }, { name: "Екатерина Матлахова", - role: "Тренер", + role: "Exotic · Pole Dance", image: "/images/team/ekaterina-matlakhova.webp", description: "Создаю чувственные хореографии, где женственность расцветает в сексуальных движениях, изящных линиях и плавных переходах, подкреплённых эстетичными силовыми элементами. В моих танцах рождаются богини!", }, { name: "Лилия Огурцова", - role: "Тренер", + role: "Exotic · Pole Dance", image: "/images/team/liliya-ogurtsova.webp", description: "Я проведу вас в мир акцентных и чарующих хореографий. Мои занятия наполнены мистическим вайбом, драйвом и энергией. Уделяю особое внимание развитию силы, прокачке тела и чистоте движений, а также эмоциональной подаче в танце.", }, { name: "Наталья Анцух", - role: "Тренер", + role: "Exotic Pole Dance", image: "/images/team/natalya-antsukh.webp", description: "Каждое занятие — это праздник для тела и души, где стиль, грация и внутренняя сила объединяются воедино. Новичок или профессионал — я научу вас танцевать с уверенностью, раскрывать свою женственность и получать удовольствие от каждого движения.", }, { name: "Яна Артюкевич", - role: "Тренер", + role: "Pole Dance", image: "/images/team/yana-artyukevich.webp", description: "На моих занятиях вы научитесь красиво и уверенно владеть своим телом, освоите базовые трюки и элементы на пилоне — шаг за шагом, в уютной и вдохновляющей атмосфере. Укрепим мышцы, улучшим растяжку и осанку, а в процессе — почувствуете невероятную уверенность, сексуальность и внутреннюю силу.", }, { name: "Анжела Бобко", - role: "Тренер", + role: "Pole Dance", image: "/images/team/anzhela-bobko.webp", description: "Мой индивидуальный подход и внимательное отношение к каждому ученику создают атмосферу доверия и поддержки. Со мной вы не просто осваиваете технику — вы преодолеваете себя и становитесь лучшей версией себя.", diff --git a/src/hooks/useShowcaseRotation.ts b/src/hooks/useShowcaseRotation.ts new file mode 100644 index 0000000..b33be8d --- /dev/null +++ b/src/hooks/useShowcaseRotation.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; + +interface UseShowcaseRotationOptions { + totalItems: number; + autoPlayInterval?: number; + pauseDuration?: number; +} + +export function useShowcaseRotation({ + totalItems, + autoPlayInterval = 4000, + pauseDuration = 10000, +}: UseShowcaseRotationOptions) { + const [activeIndex, setActiveIndex] = useState(0); + const pausedUntil = useRef(0); + const hoveringRef = useRef(false); + + const select = useCallback( + (index: number) => { + setActiveIndex(index); + pausedUntil.current = Date.now() + pauseDuration; + }, + [pauseDuration], + ); + + const setHovering = useCallback((hovering: boolean) => { + hoveringRef.current = hovering; + }, []); + + useEffect(() => { + if (totalItems <= 1) return; + + const id = setInterval(() => { + if (hoveringRef.current) return; + if (Date.now() < pausedUntil.current) return; + setActiveIndex((prev) => (prev + 1) % totalItems); + }, autoPlayInterval); + + return () => clearInterval(id); + }, [totalItems, autoPlayInterval]); + + return { activeIndex, select, setHovering }; +}