Compare commits

...

2 Commits

Author SHA1 Message Date
9cf09b6894 feat: dark luxury redesign with black heart branding
Complete visual overhaul: dark-only mode, rose/crimson accent system,
glassmorphism header, animated hero with floating hearts and glow orbs,
photo-backed cards, infinite team carousel with drag support,
redesigned modals with hero images, black heart logo with rose glow silhouette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:30:10 +03:00
1f6e314af6 feat: add class modal with descriptions, photos and remove email from contacts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:41:58 +03:00
29 changed files with 950 additions and 215 deletions

78
CLAUDE.md Normal file
View File

@@ -0,0 +1,78 @@
# BLACK HEART DANCE HOUSE — Project Context
## About
Landing page for "BLACK HEART DANCE HOUSE" — a pole dance school in Minsk, Belarus.
Instagram: @blackheartdancehouse
Content language: Russian
## Tech Stack
- **Next.js 15** (App Router, TypeScript)
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
- **lucide-react** for icons
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
- **Hosting**: Vercel (planned)
## Code Style
- Function declarations for components (not arrow functions)
- PascalCase for component files, camelCase for utils
- `@/` path alias for imports
- Semantic CSS classes via `@apply`: `surface-base`, `surface-muted`, `heading-text`, `body-text`, `nav-link`, `card`, `contact-item`, `contact-icon`, `theme-border`
- Only Header + ThemeToggle are client components (minimal JS shipped)
- `next/image` with `unoptimized` for PNGs that need transparency preserved
## Project Structure
```
src/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing: Hero → Team → About → Classes → Contact
│ ├── globals.css # Tailwind imports
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── icon.png # Favicon
│ └── apple-icon.png
├── components/
│ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client")
│ │ └── Footer.tsx
│ ├── sections/
│ │ ├── Hero.tsx
│ │ ├── Team.tsx # "use client" — clickable cards + modal
│ │ ├── About.tsx
│ │ ├── Classes.tsx
│ │ └── Contact.tsx
│ └── ui/
│ ├── Button.tsx
│ ├── SectionHeading.tsx
│ ├── SocialLinks.tsx
│ ├── ThemeToggle.tsx
│ ├── Reveal.tsx # Intersection Observer scroll reveal
│ └── TeamMemberModal.tsx # "use client" — member popup
├── data/
│ └── content.ts # ALL Russian text, structured for future CMS
├── lib/
│ └── constants.ts # BRAND constants, NAV_LINKS
└── types/
├── index.ts
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo
└── navigation.ts
```
## Brand / Styling
- **Accent**: rose/red (`#e11d48`)
- **Dark mode**: bg `#0a0a0a`, surface `#171717`
- **Light mode**: bg `#fafafa`, surface `#ffffff`
- Logo: transparent PNG, uses `dark:invert` + `unoptimized`
## Content Data
- All text lives in `src/data/content.ts` (type-safe, one file to edit)
- 13 team members with photos, Instagram links, and personal descriptions
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
- 2 addresses in Minsk, Yandex Maps embed with markers
- Contact: phone, Instagram
## Git
- Remote: Gitea at `git.dolgolyov-family.by`
- User: diana.dolgolyova
- Branch: main

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -16,6 +16,17 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
body {
overflow-x: hidden;
}
/* ===== Selection ===== */
::selection {
background-color: rgba(225, 29, 72, 0.3);
color: inherit;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { html {
scroll-behavior: auto; scroll-behavior: auto;
@@ -32,6 +43,5 @@ html {
/* ===== Focus ===== */ /* ===== Focus ===== */
:focus-visible { :focus-visible {
@apply outline-2 outline-offset-2 outline-neutral-900; @apply outline-2 outline-offset-2 outline-rose-500;
@apply dark:outline-white;
} }

View File

@@ -26,31 +26,18 @@ export const metadata: Metadata = {
}, },
}; };
const themeScript = `
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
`;
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="ru" suppressHydrationWarning> <html lang="ru" className="dark">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body <body
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`} className={`${inter.variable} ${oswald.variable} bg-[#050505] text-neutral-50 font-sans antialiased`}
> >
<Header /> <Header />
<main className="pt-16">{children}</main> <main>{children}</main>
<Footer /> <Footer />
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
@keyframes hero-fade-in-up { @keyframes hero-fade-in-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(24px); transform: translateY(32px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -14,11 +14,59 @@
@keyframes hero-fade-in-scale { @keyframes hero-fade-in-scale {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.85); transform: scale(0.8);
filter: blur(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
filter: blur(0);
}
}
@keyframes gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes pulse-glow {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes heart-float {
0% {
opacity: 0;
transform: translateY(0) scale(0.5);
}
10% {
opacity: 0.6;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
transform: translateY(-100vh) scale(1);
} }
} }
@@ -26,22 +74,95 @@
.hero-logo { .hero-logo {
opacity: 0; opacity: 0;
animation: hero-fade-in-scale 1s ease-out 0.1s forwards; animation: hero-fade-in-scale 1.2s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
} }
.hero-title { .hero-title {
opacity: 0; opacity: 0;
animation: hero-fade-in-up 0.8s ease-out 0.4s forwards; animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
} }
.hero-subtitle { .hero-subtitle {
opacity: 0; opacity: 0;
animation: hero-fade-in-up 0.8s ease-out 0.7s forwards; animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.8s forwards;
} }
.hero-cta { .hero-cta {
opacity: 0; opacity: 0;
animation: hero-fade-in-up 0.8s ease-out 1s forwards; animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 1.1s forwards;
}
/* ===== Hero Background ===== */
.hero-bg-gradient {
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(225, 29, 72, 0.15), transparent),
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(225, 29, 72, 0.08), transparent),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(225, 29, 72, 0.06), transparent);
}
.hero-glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
animation: pulse-glow 6s ease-in-out infinite;
pointer-events: none;
}
/* ===== Gradient Text ===== */
.gradient-text {
background: linear-gradient(135deg, #fff 0%, #e11d48 50%, #fff 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 6s ease-in-out infinite;
}
/* Light mode gradient text */
.gradient-text-light {
background: linear-gradient(135deg, #171717 0%, #e11d48 50%, #171717 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 6s ease-in-out infinite;
}
/* ===== Animated Border ===== */
.animated-border {
position: relative;
}
.animated-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(225, 29, 72, 0.3), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.15));
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
transition: opacity 0.5s ease;
opacity: 0.5;
}
.animated-border:hover::before {
opacity: 1;
background: linear-gradient(135deg, rgba(225, 29, 72, 0.6), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.4));
}
/* ===== Glow Effect ===== */
.glow-hover {
transition: box-shadow 0.5s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.glow-hover:hover {
box-shadow: 0 0 30px rgba(225, 29, 72, 0.1), 0 0 60px rgba(225, 29, 72, 0.05);
transform: translateY(-4px);
} }
/* ===== Scroll Reveal ===== */ /* ===== Scroll Reveal ===== */
@@ -49,7 +170,7 @@
.reveal { .reveal {
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
transition: opacity 0.7s ease-out, transform 0.7s ease-out; transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
} }
.reveal.visible { .reveal.visible {
@@ -62,11 +183,11 @@
@keyframes modal-fade-in { @keyframes modal-fade-in {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95) translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1) translateY(0);
} }
} }
@@ -80,11 +201,18 @@
} }
.modal-overlay { .modal-overlay {
animation: modal-overlay-in 0.2s ease-out; animation: modal-overlay-in 0.3s ease-out;
} }
.modal-content { .modal-content {
animation: modal-fade-in 0.3s ease-out; animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ===== Section Divider ===== */
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(225, 29, 72, 0.3), transparent);
} }
/* ===== Reduced Motion ===== */ /* ===== Reduced Motion ===== */
@@ -96,6 +224,7 @@
.hero-cta { .hero-cta {
animation: none !important; animation: none !important;
opacity: 1 !important; opacity: 1 !important;
filter: none !important;
} }
.reveal { .reveal {
@@ -108,4 +237,17 @@
.modal-content { .modal-content {
animation: none !important; animation: none !important;
} }
.gradient-text,
.gradient-text-light {
animation: none !important;
}
.hero-glow-orb {
animation: none !important;
}
.glow-hover:hover {
transform: none;
}
} }

View File

@@ -1,50 +1,77 @@
/* ===== Navigation ===== */ /* ===== Navigation ===== */
.nav-link { .nav-link {
@apply text-sm font-medium text-neutral-600 transition-colors duration-200; @apply text-sm font-medium transition-all duration-300;
@apply text-neutral-500;
@apply hover:text-neutral-900; @apply hover:text-neutral-900;
@apply dark:text-neutral-400 dark:hover:text-white; @apply dark:text-neutral-400 dark:hover:text-white;
} }
.nav-link-active {
@apply text-rose-600;
@apply dark:text-rose-400;
}
.social-icon { .social-icon {
@apply text-neutral-500 transition-colors duration-200; @apply text-neutral-400 transition-all duration-300;
@apply hover:text-neutral-900; @apply hover:text-rose-600;
@apply dark:text-neutral-400 dark:hover:text-white; @apply dark:text-neutral-500 dark:hover:text-rose-400;
} }
/* ===== Cards ===== */ /* ===== Cards ===== */
.card { .card {
@apply rounded-2xl border p-6 transition-all duration-200 cursor-pointer; @apply rounded-2xl border p-6 transition-all duration-500 cursor-pointer;
@apply border-neutral-200 bg-neutral-50; @apply border-neutral-200 bg-white;
@apply hover:border-neutral-400 hover:shadow-lg; @apply hover:border-rose-200 hover:shadow-lg;
@apply dark:border-neutral-800 dark:bg-neutral-900; @apply dark:border-white/[0.06] dark:bg-white/[0.02];
@apply dark:hover:border-neutral-600; @apply dark:hover:border-rose-500/20 dark:hover:bg-white/[0.04];
@apply dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.08)];
} }
/* ===== Buttons ===== */ /* ===== Buttons ===== */
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer; @apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply bg-neutral-900 text-white; @apply bg-rose-600 text-white;
@apply hover:bg-neutral-700; @apply hover:bg-rose-500 hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
@apply dark:bg-white dark:text-neutral-900; @apply dark:bg-rose-600 dark:text-white;
@apply dark:hover:bg-neutral-200; @apply dark:hover:bg-rose-500 dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
} }
.btn-outline { .btn-outline {
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer; @apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply border border-neutral-900 text-neutral-900; @apply border border-rose-600 text-rose-600;
@apply hover:bg-neutral-900 hover:text-white; @apply hover:bg-rose-600 hover:text-white;
@apply dark:border-white dark:text-white; @apply dark:border-rose-500 dark:text-rose-400;
@apply dark:hover:bg-white dark:hover:text-neutral-900; @apply dark:hover:bg-rose-500 dark:hover:text-white;
} }
.btn-ghost { .btn-ghost {
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer; @apply inline-flex items-center justify-center font-medium rounded-full transition-all duration-300 cursor-pointer;
@apply text-neutral-600; @apply text-neutral-600;
@apply hover:text-neutral-900; @apply hover:text-rose-600;
@apply dark:text-neutral-400 dark:hover:text-white; @apply dark:text-neutral-400 dark:hover:text-rose-400;
}
/* ===== Scrollbar ===== */
.modal-content {
scrollbar-color: rgb(163 163 163) transparent;
}
.modal-content * {
scrollbar-color: rgb(163 163 163) transparent;
}
@variant dark {
.modal-content {
scrollbar-color: rgb(64 64 64) transparent;
}
.modal-content * {
scrollbar-color: rgb(64 64 64) transparent;
}
} }
/* ===== Contact ===== */ /* ===== Contact ===== */
@@ -54,6 +81,6 @@
} }
.contact-icon { .contact-icon {
@apply shrink-0 text-neutral-900; @apply shrink-0 text-rose-600;
@apply dark:text-neutral-50; @apply dark:text-rose-400;
} }

View File

@@ -1,32 +1,37 @@
/* ===== Surfaces ===== */ /* ===== Surfaces ===== */
.surface-base { .surface-base {
@apply bg-white text-neutral-900; @apply bg-neutral-50 text-neutral-900;
@apply dark:bg-neutral-950 dark:text-neutral-50; @apply dark:bg-[#050505] dark:text-neutral-50;
} }
.surface-muted { .surface-muted {
@apply bg-neutral-100; @apply bg-neutral-100;
@apply dark:bg-neutral-900; @apply dark:bg-[#0a0a0a];
} }
.surface-glass { .surface-glass {
@apply bg-white/80 backdrop-blur-md; @apply bg-white/70 backdrop-blur-xl;
@apply dark:bg-neutral-950/80; @apply dark:bg-black/40 dark:backdrop-blur-xl;
}
.surface-card {
@apply bg-white/80 backdrop-blur-sm;
@apply dark:bg-white/[0.03] dark:backdrop-blur-sm;
} }
/* ===== Borders ===== */ /* ===== Borders ===== */
.theme-border { .theme-border {
@apply border-neutral-200; @apply border-neutral-200;
@apply dark:border-neutral-800; @apply dark:border-white/[0.06];
} }
/* ===== Text ===== */ /* ===== Text ===== */
.heading-text { .heading-text {
@apply text-neutral-900; @apply text-neutral-900;
@apply dark:text-neutral-50; @apply dark:text-white;
} }
.body-text { .body-text {
@@ -36,13 +41,18 @@
.muted-text { .muted-text {
@apply text-neutral-500; @apply text-neutral-500;
@apply dark:text-neutral-400; @apply dark:text-neutral-500;
}
.accent-text {
@apply text-rose-600;
@apply dark:text-rose-400;
} }
/* ===== Layout ===== */ /* ===== Layout ===== */
.section-padding { .section-padding {
@apply py-10 sm:py-14; @apply py-16 sm:py-24;
} }
.section-container { .section-container {

View File

@@ -1,17 +1,20 @@
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { siteContent } from "@/data/content"; import { Heart } from "lucide-react";
import { SocialLinks } from "@/components/ui/SocialLinks";
export function Footer() { export function Footer() {
const { contact } = siteContent;
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( return (
<footer className="surface-muted theme-border border-t"> <footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.06] dark:bg-[#050505]">
<div className="section-container flex flex-col items-center gap-4 py-8 sm:flex-row sm:justify-between"> <div className="section-divider absolute top-0 left-0 right-0" />
<p className="muted-text text-sm"> <div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
<p className="text-sm text-neutral-500">
&copy; {year} {BRAND.name} &copy; {year} {BRAND.name}
</p> </p>
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
<span>Made with</span>
<Heart size={14} className="fill-rose-500 text-rose-500" />
</div>
</div> </div>
</footer> </footer>
); );

View File

@@ -3,65 +3,97 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import { useState } from "react"; import { useState, useEffect } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants"; import { BRAND, NAV_LINKS } from "@/lib/constants";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
export function Header() { export function Header() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
function handleScroll() {
setScrolled(window.scrollY > 20);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return ( return (
<header className="surface-glass theme-border fixed top-0 z-50 w-full border-b"> <header
<div className="flex h-16 items-center justify-between px-6 sm:px-8"> className={`fixed top-0 z-50 w-full transition-all duration-500 ${
<Link href="/" className="flex items-center gap-2"> scrolled
? "border-b border-white/[0.06] bg-black/40 shadow-none backdrop-blur-xl"
: "bg-transparent"
}`}
>
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 sm:px-8">
<Link href="/" className="group flex items-center gap-2.5">
<div className="relative flex h-8 w-8 items-center justify-center">
<div
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
style={{
background: "radial-gradient(circle, rgba(225,29,72,0.5) 0%, rgba(225,29,72,0.15) 50%, transparent 70%)",
}}
/>
<Image <Image
src="/images/logo.png" src="/images/logo.png"
alt={BRAND.name} alt={BRAND.name}
width={32} width={24}
height={32} height={24}
unoptimized unoptimized
className="dark:invert" className="relative transition-transform duration-300 group-hover:scale-110"
style={{
filter: "drop-shadow(0 0 3px rgba(225,29,72,0.5))",
}}
/> />
<span className="font-display text-lg font-bold tracking-tight"> </div>
<span className="font-display text-lg font-bold tracking-tight text-white">
{BRAND.shortName} {BRAND.shortName}
</span> </span>
</Link> </Link>
<nav className="hidden items-center gap-8 md:flex"> <nav className="hidden items-center gap-8 md:flex">
{NAV_LINKS.map((link) => ( {NAV_LINKS.map((link) => (
<a key={link.href} href={link.href} className="nav-link"> <a
key={link.href}
href={link.href}
className="relative py-1 text-sm font-medium text-neutral-400 transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-rose-500 after:transition-all after:duration-300 hover:text-white hover:after:w-full"
>
{link.label} {link.label}
</a> </a>
))} ))}
<ThemeToggle />
</nav> </nav>
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
aria-label="Меню" aria-label="Меню"
className="body-text rounded-md p-2" className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
> >
{menuOpen ? <X size={24} /> : <Menu size={24} />} {menuOpen ? <X size={24} /> : <Menu size={24} />}
</button> </button>
</div> </div>
</div> </div>
{menuOpen && ( {/* Mobile menu */}
<nav className="surface-base theme-border border-t px-6 py-4 sm:px-8 md:hidden"> <div
className={`overflow-hidden transition-all duration-300 md:hidden ${
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
}`}
>
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
{NAV_LINKS.map((link) => ( {NAV_LINKS.map((link) => (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className="nav-link block py-3" className="block py-3 text-base text-neutral-400 transition-colors hover:text-white"
> >
{link.label} {link.label}
</a> </a>
))} ))}
</nav> </nav>
)} </div>
</header> </header>
); );
} }

View File

@@ -1,21 +1,29 @@
import { siteContent } from "@/data/content"; import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { Heart } from "lucide-react";
export function About() { export function About() {
const { about } = siteContent; const { about } = siteContent;
return ( return (
<section id="about" className="surface-muted section-padding"> <section id="about" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container"> <div className="section-container">
<Reveal> <Reveal>
<SectionHeading>{about.title}</SectionHeading> <SectionHeading>{about.title}</SectionHeading>
</Reveal> </Reveal>
<div className="mt-8 max-w-3xl space-y-4"> <div className="mt-10 max-w-3xl space-y-6">
{about.paragraphs.map((text, i) => ( {about.paragraphs.map((text, i) => (
<Reveal key={i}> <Reveal key={i}>
<div className="flex gap-4">
<Heart
size={20}
className="mt-1 shrink-0 fill-rose-500/20 text-rose-500 dark:fill-rose-500/10 dark:text-rose-400"
/>
<p className="body-text text-lg leading-relaxed">{text}</p> <p className="body-text text-lg leading-relaxed">{text}</p>
</div>
</Reveal> </Reveal>
))} ))}
</div> </div>

View File

@@ -1,39 +1,89 @@
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react"; "use client";
import { useState } from "react";
import Image from "next/image";
import { Flame, Sparkles, Wind, Zap, Star, Monitor, ArrowRight } from "lucide-react";
import { siteContent } from "@/data/content"; import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { ClassModal } from "@/components/ui/ClassModal";
import type { ClassItem } from "@/types";
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
flame: <Flame size={32} />, flame: <Flame size={20} />,
sparkles: <Sparkles size={32} />, sparkles: <Sparkles size={20} />,
wind: <Wind size={32} />, wind: <Wind size={20} />,
zap: <Zap size={32} />, zap: <Zap size={20} />,
star: <Star size={32} />, star: <Star size={20} />,
monitor: <Monitor size={32} />, monitor: <Monitor size={20} />,
}; };
export function Classes() { export function Classes() {
const { classes } = siteContent; const { classes } = siteContent;
const [selectedClass, setSelectedClass] = useState<ClassItem | null>(null);
return ( return (
<section id="classes" className="surface-muted section-padding"> <section id="classes" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container"> <div className="section-container">
<Reveal> <Reveal>
<SectionHeading>{classes.title}</SectionHeading> <SectionHeading>{classes.title}</SectionHeading>
</Reveal> </Reveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2"> <div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{classes.items.map((item) => ( {classes.items.map((item) => (
<Reveal key={item.name}> <Reveal key={item.name} className="h-full">
<div className="card"> <div
<div className="heading-text">{iconMap[item.icon]}</div> className="group relative h-full min-h-[280px] cursor-pointer overflow-hidden rounded-2xl"
<h3 className="heading-text mt-4 text-xl font-semibold">{item.name}</h3> onClick={() => setSelectedClass(item)}
<p className="body-text mt-2">{item.description}</p> >
{/* Background image */}
{item.images && item.images[0] && (
<Image
src={item.images[0]}
alt={item.name}
fill
className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
)}
{/* Dark gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-black/10 transition-all duration-500 group-hover:from-black/95 group-hover:via-black/50" />
{/* Rose tint on hover */}
<div className="absolute inset-0 bg-rose-900/0 transition-all duration-500 group-hover:bg-rose-900/10" />
{/* Content */}
<div className="relative flex h-full flex-col justify-end p-6">
{/* Icon badge */}
<div className="mb-3 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-white backdrop-blur-sm transition-all duration-300 group-hover:bg-rose-500/20 group-hover:text-rose-300">
{iconMap[item.icon]}
</div>
<h3 className="text-xl font-semibold text-white">
{item.name}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-white/60 line-clamp-2">
{item.description}
</p>
{/* Hover arrow */}
<div className="mt-3 flex items-center gap-1.5 text-sm font-medium text-rose-400 opacity-0 translate-y-2 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0">
<span>Подробнее</span>
<ArrowRight size={14} />
</div>
</div>
</div> </div>
</Reveal> </Reveal>
))} ))}
</div> </div>
</div> </div>
<ClassModal
classItem={selectedClass}
onClose={() => setSelectedClass(null)}
/>
</section> </section>
); );
} }

View File

@@ -1,4 +1,4 @@
import { MapPin, Phone, Mail, Clock, Instagram } from "lucide-react"; import { MapPin, Phone, Clock, Instagram } from "lucide-react";
import { siteContent } from "@/data/content"; import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
@@ -8,58 +8,65 @@ export function Contact() {
const { contact } = siteContent; const { contact } = siteContent;
return ( return (
<section id="contact" className="surface-base section-padding"> <section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container grid items-start gap-12 lg:grid-cols-2"> <div className="section-container grid items-start gap-12 lg:grid-cols-2">
<Reveal> <Reveal>
<SectionHeading>{contact.title}</SectionHeading> <SectionHeading>{contact.title}</SectionHeading>
<div className="mt-12 space-y-6"> <div className="mt-10 space-y-5">
{contact.addresses.map((address, i) => ( {contact.addresses.map((address, i) => (
<div key={i} className="contact-item"> <div key={i} className="group flex items-center gap-4">
<MapPin size={20} className="contact-icon" /> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<MapPin size={18} />
</div>
<p className="body-text">{address}</p> <p className="body-text">{address}</p>
</div> </div>
))} ))}
<div className="contact-item"> <div className="group flex items-center gap-4">
<Phone size={20} className="contact-icon" /> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<a href={`tel:${contact.phone}`} className="nav-link text-base"> <Phone size={18} />
</div>
<a
href={`tel:${contact.phone}`}
className="text-neutral-600 transition-colors hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
>
{contact.phone} {contact.phone}
</a> </a>
</div> </div>
<div className="contact-item"> <div className="group flex items-center gap-4">
<Mail size={20} className="contact-icon" /> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<a href={`mailto:${contact.email}`} className="nav-link text-base"> <Clock size={18} />
{contact.email}
</a>
</div> </div>
<div className="contact-item">
<Clock size={20} className="contact-icon" />
<p className="body-text">{contact.workingHours}</p> <p className="body-text">{contact.workingHours}</p>
</div> </div>
<div className="theme-border contact-item border-t pt-6"> <div className="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
<Instagram size={20} className="contact-icon" /> <div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<Instagram size={18} />
</div>
<a <a
href={contact.instagram} href={contact.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="nav-link text-base" className="text-neutral-600 transition-colors hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
> >
{BRAND.instagramHandle} {BRAND.instagramHandle}
</a> </a>
</div> </div>
</div> </div>
</div>
</Reveal> </Reveal>
<Reveal> <Reveal>
<div className="theme-border overflow-hidden rounded-2xl border"> <div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.06] dark:shadow-[0_0_30px_rgba(225,29,72,0.05)]">
<iframe <iframe
src={contact.mapEmbedUrl} src={contact.mapEmbedUrl}
width="100%" width="100%"
height="350" height="380"
style={{ border: 0 }} style={{ border: 0 }}
allowFullScreen allowFullScreen
loading="lazy" loading="lazy"

View File

@@ -1,35 +1,99 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import { siteContent } from "@/data/content"; import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { ChevronDown } from "lucide-react";
export function Hero() { export function Hero() {
const { hero } = siteContent; const { hero } = siteContent;
return ( return (
<section className="surface-base section-container flex min-h-svh items-center justify-center"> <section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
<div className="text-center"> {/* Animated gradient background */}
<div className="hero-bg-gradient absolute inset-0" />
{/* Glow orbs */}
<div
className="hero-glow-orb"
style={{
width: "500px",
height: "500px",
top: "-10%",
left: "50%",
transform: "translateX(-50%)",
background: "radial-gradient(circle, rgba(225, 29, 72, 0.12), transparent 70%)",
}}
/>
<div
className="hero-glow-orb"
style={{
width: "300px",
height: "300px",
bottom: "10%",
right: "10%",
background: "radial-gradient(circle, rgba(225, 29, 72, 0.08), transparent 70%)",
animationDelay: "3s",
}}
/>
{/* Floating hearts */}
<FloatingHearts />
{/* Content */}
<div className="section-container relative z-10 text-center">
<div className="hero-logo relative mx-auto mb-10 h-[220px] w-[220px]">
{/* Outer ambient glow */}
<div className="absolute -inset-16 rounded-full bg-rose-500/8 blur-[60px]" />
{/* Rose disc — makes black heart visible as silhouette */}
<div
className="absolute inset-2 rounded-full"
style={{
background: "radial-gradient(circle, rgba(225,29,72,0.45) 0%, rgba(225,29,72,0.18) 45%, transparent 70%)",
}}
/>
<Image <Image
src="/images/logo.png" src="/images/logo.png"
alt={BRAND.name} alt={BRAND.name}
width={280} width={220}
height={280} height={220}
priority priority
unoptimized unoptimized
className="hero-logo mx-auto mb-8 dark:invert" className="relative"
style={{
filter:
"drop-shadow(0 0 6px rgba(225,29,72,0.5)) drop-shadow(0 0 20px rgba(225,29,72,0.25))",
}}
/> />
</div>
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl"> <h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
{hero.headline} <span className="gradient-text">{hero.headline}</span>
</h1> </h1>
<p className="hero-subtitle body-text mx-auto mt-6 max-w-md text-lg sm:text-xl">
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-neutral-400 sm:text-xl">
{hero.subheadline} {hero.subheadline}
</p> </p>
<div className="hero-cta mt-10">
<div className="hero-cta mt-12">
<Button href={hero.ctaHref} size="lg"> <Button href={hero.ctaHref} size="lg">
{hero.ctaText} {hero.ctaText}
</Button> </Button>
</div> </div>
</div> </div>
{/* Scroll indicator */}
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
<a
href="#about"
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-rose-400"
>
<span className="text-xs uppercase tracking-widest">Scroll</span>
<ChevronDown size={20} className="animate-bounce" />
</a>
</div>
</section> </section>
); );
} }

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import { Instagram } from "lucide-react"; import { Instagram, ChevronLeft, ChevronRight } from "lucide-react";
import { siteContent } from "@/data/content"; import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
@@ -12,37 +12,148 @@ import type { TeamMember } from "@/types";
export function Team() { export function Team() {
const { team } = siteContent; const { team } = siteContent;
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null); const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollTimer = useRef<ReturnType<typeof setTimeout>>(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",
});
}
return ( return (
<section id="team" className="surface-base section-padding"> <section id="team" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container"> <div className="section-container">
<Reveal> <Reveal>
<SectionHeading>{team.title}</SectionHeading> <SectionHeading>{team.title}</SectionHeading>
</Reveal> </Reveal>
</div>
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3"> {/* Carousel wrapper */}
{team.members.map((member, i) => ( <Reveal>
<Reveal key={i}> <div className="relative mt-10">
{/* Scroll container */}
<div <div
className="card flex h-full cursor-pointer flex-col items-center text-center transition-transform hover:scale-[1.02]" ref={scrollRef}
onClick={() => setSelectedMember(member)} onScroll={handleScroll}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="flex cursor-grab gap-4 overflow-x-auto px-6 pb-4 sm:px-8 scroll-smooth snap-x snap-mandatory select-none lg:px-[max(2rem,calc((100vw-72rem)/2+2rem))]"
style={{ scrollbarWidth: "none" }}
> >
<div className="mx-auto h-32 w-32 overflow-hidden rounded-full"> {tripled.map((member, i) => (
<div
key={`${i}-${member.name}`}
className="group relative w-[220px] shrink-0 cursor-pointer snap-start overflow-hidden rounded-2xl sm:w-[260px]"
onClick={() => handleCardClick(member)}
>
{/* Photo */}
<div className="aspect-[3/4] w-full overflow-hidden">
<Image <Image
src={member.image} src={member.image}
alt={member.name} alt={member.name}
width={128} width={260}
height={128} height={347}
className="h-full w-full object-cover" className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/> />
</div> </div>
<h3 className="heading-text mt-4 text-lg font-semibold">{member.name}</h3>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-100" />
{/* Rose glow on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-rose-900/20 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-1 transition-transform duration-500 group-hover:translate-y-0">
<h3 className="text-base font-semibold text-white sm:text-lg">
{member.name}
</h3>
{member.instagram && ( {member.instagram && (
<span <span
className="nav-link mt-1 inline-flex gap-1.5 text-sm" className="mt-1 inline-flex items-center gap-1.5 text-xs text-white/60 transition-colors hover:text-rose-400 sm:text-sm"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Instagram size={14} className="shrink-0 mt-[3px]" /> <Instagram size={12} className="shrink-0" />
<a <a
href={member.instagram} href={member.instagram}
target="_blank" target="_blank"
@@ -53,10 +164,27 @@ export function Team() {
</span> </span>
)} )}
</div> </div>
</Reveal> </div>
))} ))}
</div> </div>
{/* Side navigation arrows */}
<button
onClick={() => scroll("left")}
className="absolute left-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
aria-label="Назад"
>
<ChevronLeft size={22} />
</button>
<button
onClick={() => scroll("right")}
className="absolute right-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
aria-label="Вперёд"
>
<ChevronRight size={22} />
</button>
</div> </div>
</Reveal>
<TeamMemberModal <TeamMemberModal
member={selectedMember} member={selectedMember}

View File

@@ -0,0 +1,119 @@
"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<string, React.ReactNode> = {
flame: <Flame size={20} />,
sparkles: <Sparkles size={20} />,
wind: <Wind size={20} />,
zap: <Zap size={20} />,
star: <Star size={20} />,
monitor: <Monitor size={20} />,
};
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 (
<div
className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-2xl sm:rounded-3xl dark:bg-[#111]"
onClick={(e) => e.stopPropagation()}
>
{/* Hero image banner */}
{heroImage && (
<div className="relative h-52 w-full shrink-0 sm:h-64">
<Image
src={heroImage}
alt={classItem.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
aria-label="Закрыть"
>
<X size={16} />
</button>
{/* Title on image */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/15 text-white backdrop-blur-sm">
{iconMap[classItem.icon]}
</div>
<h3 className="text-2xl font-bold text-white">
{classItem.name}
</h3>
</div>
</div>
</div>
)}
{/* Content */}
<div className="overflow-y-auto">
{/* Title fallback when no image */}
{!heroImage && (
<div className="flex items-center justify-between p-6 pb-0">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400">
{iconMap[classItem.icon]}
</div>
<h3 className="heading-text text-xl font-bold">
{classItem.name}
</h3>
</div>
<button
onClick={onClose}
className="rounded-full p-1.5 text-neutral-400 transition-all hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white"
aria-label="Закрыть"
>
<X size={18} />
</button>
</div>
)}
{classItem.detailedDescription && (
<div className="p-6 text-sm leading-relaxed whitespace-pre-line text-neutral-600 dark:text-neutral-400">
{classItem.detailedDescription}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
interface Heart {
id: number;
left: number;
size: number;
delay: number;
duration: number;
opacity: number;
}
export function FloatingHearts() {
const [hearts, setHearts] = useState<Heart[]>([]);
useEffect(() => {
const generated: Heart[] = Array.from({ length: 12 }, (_, i) => ({
id: i,
left: Math.random() * 100,
size: 8 + Math.random() * 16,
delay: Math.random() * 10,
duration: 10 + Math.random() * 15,
opacity: 0.03 + Math.random() * 0.08,
}));
setHearts(generated);
}, []);
if (hearts.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{hearts.map((heart) => (
<div
key={heart.id}
className="absolute text-rose-500"
style={{
left: `${heart.left}%`,
bottom: "-20px",
fontSize: `${heart.size}px`,
opacity: heart.opacity,
animation: `heart-float ${heart.duration}s ease-in ${heart.delay}s infinite`,
}}
>
&#9829;
</div>
))}
</div>
);
}

View File

@@ -9,7 +9,7 @@ export function SectionHeading({ children, className = "" }: SectionHeadingProps
className={`font-display text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${className}`} className={`font-display text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${className}`}
> >
{children} {children}
<span className="mt-2 block h-1 w-16 rounded bg-neutral-900 dark:bg-white" /> <span className="mt-3 block h-[2px] w-16 rounded-full bg-gradient-to-r from-rose-500 to-rose-500/0" />
</h2> </h2>
); );
} }

View File

@@ -31,59 +31,61 @@ export function TeamMemberModal({ member, onClose }: TeamMemberModalProps) {
return ( return (
<div <div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
onClick={onClose} onClick={onClose}
> >
<div <div
className="modal-content surface-base relative w-full max-w-md md:max-w-2xl max-h-[85vh] flex flex-col rounded-2xl shadow-xl" className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-lg sm:rounded-3xl dark:bg-[#111]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button {/* Hero photo */}
onClick={onClose} <div className="relative h-72 w-full shrink-0 sm:h-80">
className="heading-text absolute right-4 top-4 z-10 rounded-full p-1 transition-opacity hover:opacity-70"
aria-label="Закрыть"
>
<X size={20} />
</button>
<div className="flex flex-col md:flex-row overflow-y-auto p-6 gap-6">
<div className="flex flex-col items-center md:items-start shrink-0">
<div className="h-40 w-40 md:h-48 md:w-48 overflow-hidden rounded-full ring-2 ring-rose-500/30">
<Image <Image
src={member.image} src={member.image}
alt={member.name} alt={member.name}
width={192} fill
height={192} className="object-cover"
className="h-full w-full object-cover"
/> />
</div> {/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<h3 className="heading-text mt-4 text-xl font-semibold text-center md:text-left w-full"> {/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
aria-label="Закрыть"
>
<X size={16} />
</button>
{/* Name + Instagram on photo */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<h3 className="text-2xl font-bold text-white">
{member.name} {member.name}
</h3> </h3>
{member.instagram && ( {member.instagram && (
<a <a
href={member.instagram} href={member.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="nav-link mt-1 inline-flex gap-1.5 text-sm" className="mt-2 inline-flex items-center gap-2 text-sm text-white/70 transition-colors hover:text-rose-400"
> >
<Instagram size={14} className="shrink-0 mt-[3px]" /> <Instagram size={15} className="shrink-0" />
<span>{member.instagram.split("/").filter(Boolean).pop()}</span> <span>{member.instagram.split("/").filter(Boolean).pop()}</span>
</a> </a>
)} )}
</div> </div>
</div>
{/* Description */}
{member.description && ( {member.description && (
<div className="md:border-l md:theme-border md:pl-6 flex items-center"> <div className="overflow-y-auto p-6">
<p className="body-text text-sm leading-relaxed"> <p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{member.description} {member.description}
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -136,35 +136,53 @@ export const siteContent: SiteContent = {
description: description:
"Чувственная хореография с элементами pole dance в каблуках.", "Чувственная хореография с элементами pole dance в каблуках.",
icon: "sparkles", icon: "sparkles",
detailedDescription:
"Чувственный, эстетичный, сексуальный вид танца. Он богат на плавные линии, манящие прогибы и развитие вашей женственности.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны и возможность влюбиться заново,\n— вы учитесь наслаждаться собой.",
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
}, },
{ {
name: "Pole Dance", name: "Pole Dance",
description: description:
"Сила, грация и пластика на пилоне. Для любого уровня подготовки.", "Сила, грация и пластика на пилоне. Для любого уровня подготовки.",
icon: "flame", icon: "flame",
detailedDescription:
"Пилон — это отличный тренажер для рук, ног, спины и пресса. Pole Dance учит красиво двигаться, улучшает растяжку, силовые показатели и выдержку.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
images: ["/images/classes/pole-dance.webp"],
}, },
{ {
name: "Body Plastic", name: "Body Plastic",
description: description:
"Танцевальное направление, раскрывающее женственность и пластику тела.", "Танцевальное направление, раскрывающее женственность и пластику тела.",
icon: "wind", icon: "wind",
detailedDescription:
"Растяжка — это искусство, которое позволяет вам не только улучшить гибкость, но и раскрыть истинную красоту вашего тела. Это больше, чем просто упражнения — это плавные движения, которые учат вас слушать своё тело и чувствовать его.\n\nЗанимаясь растяжкой, вы получите:\n— уверенность в себе,\n— красивую осанку и гибкость,\n— улучшение общего тонуса тела и расслабление мышц,\n— возможность открыть новые грани своей чувственности и женственности,\n— умение наслаждаться каждым движением и моментом.\n\nРастяжка помогает вам не только достигнуть физического совершенства, но и найти внутреннюю гармонию и любовь к себе.",
images: ["/images/classes/body-plastic.webp"],
}, },
{ {
name: "Партерная акробатика", name: "Партерная акробатика",
description: description:
"Акробатические элементы в партере для развития силы и гибкости.", "Акробатические элементы в партере для развития силы и гибкости.",
icon: "zap", icon: "zap",
detailedDescription:
"Партерная акробатика — это завораживающее сочетание силы, гибкости и грации, которое раскрывает безграничные возможности вашего тела. Этот вид искусства позволяет вам воплотить в жизнь самые смелые акробатические элементы, создавая уникальные и впечатляющие комбинации на полу.\n\nЗанимаясь партерной акробатикой, вы получите:\n— невероятную физическую силу и выносливость,\n— улучшение координации и равновесия,\n— развитие всех групп мышц и повышение гибкости,\n— возможность выразить себя через мощные и динамичные движения,\n— уверенность в своих возможностях и преодоление собственных границ.\n\nПартерная акробатика — это путь к совершенству тела и духа, который дарит ощущение полёта и свободы на земле.",
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
}, },
{ {
name: "Мастер классы", name: "Мастер классы",
description: description:
"Уникальные занятия с приглашёнными топовыми тренерами.", "Уникальные занятия с приглашёнными топовыми тренерами.",
icon: "star", icon: "star",
detailedDescription:
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров, которые помогут вам достичь новых высот.\n\nНаши мастер-классы — это не просто тренировки, это путь к самопознанию и любви к своему телу. Присоединяйтесь к нам и откройте для себя мир танца, где каждый шаг приносит удовольствие и уверенность.",
images: ["/images/classes/master-class-1.webp", "/images/classes/master-class-2.webp", "/images/classes/master-class-3.webp"],
}, },
{ {
name: "Онлайн занятия", name: "Онлайн занятия",
description: "Тренировки в удобное время из любой точки мира.", description: "Тренировки в удобное время из любой точки мира.",
icon: "monitor", icon: "monitor",
detailedDescription:
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram, который подскажет и скорректирует в случае трудностей в процессе изучения материала.",
images: ["/images/classes/online-classes.webp"],
}, },
], ],
}, },
@@ -175,7 +193,6 @@ export const siteContent: SiteContent = {
"г. Минск, Притыцкого, 62к1", "г. Минск, Притыцкого, 62к1",
], ],
phone: "+375 29 389-70-01", phone: "+375 29 389-70-01",
email: "info@blackheartdance.by",
instagram: "https://instagram.com/blackheartdancehouse/", instagram: "https://instagram.com/blackheartdancehouse/",
mapEmbedUrl: mapEmbedUrl:
"https://yandex.ru/map-widget/v1/?ll=27.512%2C53.912&z=12&l=map&pt=27.5656%2C53.91583%2Cpm2rdm~27.45974%2C53.90832%2Cpm2rdm", "https://yandex.ru/map-widget/v1/?ll=27.512%2C53.912&z=12&l=map&pt=27.5656%2C53.91583%2Cpm2rdm~27.45974%2C53.90832%2Cpm2rdm",

View File

@@ -2,6 +2,8 @@ export interface ClassItem {
name: string; name: string;
description: string; description: string;
icon: string; icon: string;
detailedDescription?: string;
images?: string[];
} }
export interface TeamMember { export interface TeamMember {
@@ -16,7 +18,6 @@ export interface ContactInfo {
title: string; title: string;
addresses: string[]; addresses: string[];
phone: string; phone: string;
email: string;
instagram: string; instagram: string;
mapEmbedUrl: string; mapEmbedUrl: string;
workingHours: string; workingHours: string;