feat: separate /team and /schedule pages with Pinterest-style team grid

- Add /team page with masonry grid, style filters, search, and trainer profiles
- Add /schedule page as dedicated full-page schedule viewer
- Landing page: replace team section with auto-scrolling photo marquee (TeamPreview)
  - Clicking a trainer opens modal overlay with profile (no page navigation)
  - "Познакомиться с командой" links to full /team gallery
- Landing page: replace schedule section with compact CTA linking to /schedule
- Header: support mixed route links (/team) and hash links (#about)
  - Sub-pages show all nav links, hash links prefixed with /
  - Sub-pages always use scrolled header style (readable on light theme)
- Remove unused content.ts and seed.ts (DB is primary data source)
- Add marquee and grid card entrance animations
This commit is contained in:
2026-04-13 22:01:38 +03:00
parent 8c84da279e
commit 1571b63ec3
10 changed files with 612 additions and 590 deletions
+20 -6
View File
@@ -1,15 +1,16 @@
import Link from "next/link";
import { Hero } from "@/components/sections/Hero";
import { Team } from "@/components/sections/Team";
import { About } from "@/components/sections/About";
import { Classes } from "@/components/sections/Classes";
import { TeamPreview } from "@/components/sections/TeamPreview";
import { MasterClasses } from "@/components/sections/MasterClasses";
import { Schedule } from "@/components/sections/Schedule";
import { Pricing } from "@/components/sections/Pricing";
import { News } from "@/components/sections/News";
import { FAQ } from "@/components/sections/FAQ";
import { Contact } from "@/components/sections/Contact";
import { BackToTop } from "@/components/ui/BackToTop";
import { FloatingContact } from "@/components/ui/FloatingContact";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { ClientShell } from "@/components/layout/ClientShell";
@@ -26,7 +27,7 @@ export default function HomePage() {
return (
<>
<ClientShell>
<Header />
<Header popups={content?.popups} />
<main id="main-content">
{content?.hero && <Hero data={content.hero} />}
{content?.about && (
@@ -40,16 +41,29 @@ export default function HomePage() {
/>
)}
{content?.classes && <Classes data={content.classes} />}
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
{content?.team && <TeamPreview title={content.team.title} members={content.team.members} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.schedule && (
<section id="schedule" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container text-center">
<SectionHeading centered>{content.schedule.title}</SectionHeading>
<p className="mt-4 text-neutral-500 dark:text-neutral-400">
{content.schedule.locations.length} студии · {content.schedule.locations.reduce((sum, loc) => sum + loc.days.reduce((s, d) => s + d.classes.length, 0), 0)} занятий в неделю
</p>
<Link href="/schedule" className="mt-6 inline-flex items-center gap-2 rounded-full bg-gold px-8 py-3 text-sm font-semibold text-black transition-all hover:shadow-[0_0_24px_rgba(201,169,110,0.4)]">
Смотреть расписание
</Link>
</div>
</section>
)}
{content?.pricing && <Pricing data={content.pricing} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} locations={content.schedule?.locations} />}
{content?.news && <News data={content.news} />}
{content?.faq && <FAQ data={content.faq} />}
{content?.contact && <Contact data={content.contact} />}
<BackToTop />
<FloatingContact />
<FloatingContact popups={content?.popups} contactInstagram={content?.contact?.instagram} contactPhone={content?.contact?.phone} />
</main>
<Footer />
</ClientShell>
+43
View File
@@ -0,0 +1,43 @@
import { Metadata } from "next";
import { getContent } from "@/lib/content";
import { Schedule } from "@/components/sections/Schedule";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { ClientShell } from "@/components/layout/ClientShell";
import { BackToTop } from "@/components/ui/BackToTop";
export const metadata: Metadata = {
title: "Расписание | BLACK HEART DANCE HOUSE",
};
export default function SchedulePage() {
const content = getContent();
if (!content?.schedule) {
return (
<ClientShell>
<Header popups={content?.popups} />
<main id="main-content" className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Расписание не найдено.</p>
</main>
<Footer />
</ClientShell>
);
}
return (
<ClientShell>
<Header popups={content.popups} />
<main id="main-content">
<Schedule
data={content.schedule}
scheduleConfig={content.scheduleConfig}
classItems={content.classes?.items ?? []}
teamMembers={content.team?.members ?? []}
/>
<BackToTop />
</main>
<Footer />
</ClientShell>
);
}
+31
View File
@@ -288,6 +288,31 @@ html:not(.dark) .gradient-text {
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ===== Team Marquee ===== */
@keyframes team-marquee-left {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes team-marquee-right {
from { transform: translateX(-50%); }
to { transform: translateX(0); }
}
/* ===== Team Grid Card Entrance ===== */
@keyframes team-grid-card-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ===== Team Info Fade ===== */
@keyframes team-info-in {
@@ -422,4 +447,10 @@ noscript ~ * [style*="opacity: 0"],
.team-card-glitter::before {
animation: none !important;
}
[style*="team-grid-card-in"] {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { ClientShell } from "@/components/layout/ClientShell";
import { BackToTop } from "@/components/ui/BackToTop";
import { TeamGrid } from "@/components/sections/team/TeamGrid";
import { getContent } from "@/lib/content";
export const metadata: Metadata = {
title: "Команда | BLACK HEART DANCE HOUSE",
};
export default function TeamPage() {
const content = getContent();
if (!content?.team) {
notFound();
}
return (
<ClientShell>
<Header popups={content?.popups} />
<main id="main-content" className="pt-16">
<TeamGrid
data={content.team}
schedule={content.schedule?.locations}
scheduleConfig={content.scheduleConfig}
/>
<BackToTop />
</main>
<Footer />
</ClientShell>
);
}
+43 -8
View File
@@ -9,8 +9,13 @@ import { HeroLogo } from "@/components/ui/HeroLogo";
import { SignupModal } from "@/components/ui/SignupModal";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useBooking } from "@/contexts/BookingContext";
import type { SiteContent } from "@/types/content";
export function Header() {
interface HeaderProps {
popups?: SiteContent["popups"];
}
export function Header({ popups }: HeaderProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
@@ -60,13 +65,33 @@ export function Header() {
prevMenuOpenRef.current = menuOpen;
}, [menuOpen]);
// Detect if we're on a sub-page (not the landing page)
const [currentPath, setCurrentPath] = useState("/");
useEffect(() => {
setCurrentPath(window.location.pathname);
}, []);
const isSubPage = currentPath !== "/";
// Filter out nav links whose target section doesn't exist on the page
// On sub-pages, show all links — hash links point back to landing (e.g. /#about)
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => {
const path = window.location.pathname;
if (path !== "/") {
// Sub-page: show all links, prefix hash links with /
setVisibleLinks(
NAV_LINKS.map((l) =>
l.href.startsWith("#") ? { ...l, href: `/${l.href}` } : l
)
);
return;
}
// Landing page: filter by existing DOM sections
setVisibleLinks(
NAV_LINKS
.filter((l) => document.getElementById(l.href.replace("#", "")))
.filter((l) => l.href.startsWith("/") || document.getElementById(l.href.replace("#", "")))
.map((l) => {
if (l.href.startsWith("/")) return l;
const section = document.getElementById(l.href.replace("#", ""));
const heading = section?.querySelector("h2");
if (heading?.textContent && l.href !== "#hero") {
@@ -78,7 +103,7 @@ export function Header() {
}, []);
useEffect(() => {
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
const sectionIds = visibleLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.replace("#", ""));
const observers: IntersectionObserver[] = [];
// Observe hero — when visible, clear active section
@@ -125,7 +150,7 @@ export function Header() {
return (
<header
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
scrolled || menuOpen
scrolled || menuOpen || isSubPage
? "backdrop-blur-xl border-b border-white/[0.08]"
: "bg-transparent"
}`}
@@ -155,7 +180,7 @@ export function Header() {
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex" aria-label="Основная навигация">
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
@@ -164,7 +189,7 @@ export function Header() {
className={`relative whitespace-nowrap py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
isActive
? "text-gold after:w-full"
: scrolled
: scrolled || isSubPage
? "text-neutral-600 after:w-0 hover:text-neutral-900 hover:after:w-full dark:text-neutral-400 dark:hover:text-white"
: "text-white/80 after:w-0 hover:text-white hover:after:w-full"
}`}
@@ -199,7 +224,7 @@ export function Header() {
>
<nav className="border-t border-neutral-200/60 dark:border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
{visibleLinks.map((link, index) => {
const isActive = activeSection === link.href.replace("#", "");
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
@@ -221,7 +246,17 @@ export function Header() {
</div>
<SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
<SignupModal
open={bookingOpen}
onClose={closeBooking}
subtitle={popups?.bookingSubtitle || undefined}
endpoint="/api/group-booking"
successMessage={popups?.successMessage}
waitingMessage={popups?.waitingListText}
errorMessage={popups?.errorMessage}
instagramHint={popups?.instagramHint}
instagramUrl={popups?.contactInstagram}
/>
</header>
);
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, X } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { TeamProfile } from "@/components/sections/team/TeamProfile";
import type { TeamMember, ScheduleLocation, SiteContent } from "@/types/content";
interface TeamPreviewProps {
title: string;
members: TeamMember[];
schedule?: ScheduleLocation[];
scheduleConfig?: SiteContent["scheduleConfig"];
}
export function TeamPreview({ title, members, schedule, scheduleConfig }: TeamPreviewProps) {
if (!members.length) return null;
const [activeMember, setActiveMember] = useState<TeamMember | null>(null);
const openMember = useCallback((member: TeamMember) => {
setActiveMember(member);
document.body.style.overflow = "hidden";
}, []);
const closeMember = useCallback(() => {
setActiveMember(null);
document.body.style.overflow = "";
}, []);
// Close on Escape
useEffect(() => {
if (!activeMember) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") closeMember();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [activeMember, closeMember]);
// Close on browser back
useEffect(() => {
if (!activeMember) return;
history.pushState({ trainerModal: true }, "");
function onPop() { closeMember(); }
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [activeMember, closeMember]);
// Double the list for seamless infinite scroll
const strip = [...members, ...members];
return (
<>
<section
id="team"
className="section-glow relative py-16 sm:py-24 bg-neutral-50 dark:bg-[#050505] overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
<Reveal>
<div className="text-center mb-10 sm:mb-14 px-6">
<SectionHeading centered>{title}</SectionHeading>
</div>
</Reveal>
{/* ── Photo Marquee ── */}
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 w-16 sm:w-32 z-10 bg-gradient-to-r from-neutral-50 dark:from-[#050505] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-16 sm:w-32 z-10 bg-gradient-to-l from-neutral-50 dark:from-[#050505] to-transparent" />
<MarqueeStrip members={strip} onMemberClick={openMember} />
</div>
{/* ── CTA to full team page ── */}
<Reveal>
<div className="mt-10 sm:mt-14 text-center px-6">
<Link
href="/team"
className="group inline-flex items-center gap-3 text-sm font-medium text-gold transition-all duration-300 hover:gap-4"
>
<span className="relative">
Познакомиться с командой
<span className="absolute -bottom-1 left-0 w-full h-px bg-gold/30 group-hover:bg-gold transition-colors duration-300" />
</span>
<ArrowRight size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
</Link>
</div>
</Reveal>
</section>
{/* ── Trainer Profile Modal ── */}
{activeMember && (
<div
className="fixed inset-0 z-50 bg-neutral-50/95 dark:bg-[#050505]/95 backdrop-blur-sm overflow-y-auto"
onClick={(e) => { if (e.target === e.currentTarget) closeMember(); }}
>
<button
onClick={closeMember}
aria-label="Закрыть"
className="fixed top-5 right-5 z-50 rounded-full bg-neutral-200 p-2.5 text-neutral-600 hover:text-neutral-900 hover:bg-neutral-300 transition-colors cursor-pointer
dark:bg-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-white/20"
>
<X size={20} />
</button>
<div className="px-4 sm:px-6 lg:px-8 pt-16 pb-10 min-h-screen"
style={{ animation: "modal-content-in 0.4s cubic-bezier(0.16, 1, 0.3, 1)" }}>
<TeamProfile
member={activeMember}
onBack={closeMember}
schedule={schedule}
scheduleConfig={scheduleConfig}
/>
</div>
</div>
)}
</>
);
}
/* ── Marquee Strip ─────────────────────────────────────────────────── */
function MarqueeStrip({ members, onMemberClick }: { members: TeamMember[]; onMemberClick: (m: TeamMember) => void }) {
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
function pause() { track!.style.animationPlayState = "paused"; }
function play() { track!.style.animationPlayState = "running"; }
track.addEventListener("mouseenter", pause);
track.addEventListener("mouseleave", play);
return () => {
track.removeEventListener("mouseenter", pause);
track.removeEventListener("mouseleave", play);
};
}, []);
const duration = `${members.length * 2.5}s`;
return (
<div className="overflow-hidden">
<div
ref={trackRef}
className="flex gap-3 sm:gap-4 w-max"
style={{ animation: `team-marquee-left ${duration} linear infinite` }}
>
{members.map((m, i) => (
<button
key={`${m.name}-${i}`}
onClick={() => onMemberClick(m)}
className="group relative flex-shrink-0 w-36 sm:w-44 lg:w-52 overflow-hidden rounded-xl cursor-pointer text-left"
>
<div className="relative aspect-[3/4]">
<Image
src={m.image}
alt={m.name}
fill
sizes="(min-width: 1024px) 208px, (min-width: 640px) 176px, 144px"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent
opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute bottom-0 left-0 right-0 p-3
translate-y-1 group-hover:translate-y-0
opacity-0 group-hover:opacity-100
transition-all duration-300">
<p className="font-display text-xs sm:text-sm font-semibold text-white uppercase tracking-wide leading-tight">
{m.name}
</p>
<p className="text-[10px] sm:text-[11px] text-gold-light/80 mt-0.5">{m.role}</p>
</div>
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/25 transition-colors duration-300" />
</div>
</button>
))}
</div>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import { Search, X } from "lucide-react";
import { TeamProfile } from "./TeamProfile";
import type { SiteContent, ScheduleLocation, TeamMember } from "@/types/content";
/* ── Types ─────────────────────────────────────────────────────────── */
interface TeamGridProps {
data: SiteContent["team"];
schedule?: ScheduleLocation[];
scheduleConfig?: SiteContent["scheduleConfig"];
}
/* ── Helpers ───────────────────────────────────────────────────────── */
function extractStyles(members: TeamMember[]): string[] {
const set = new Set<string>();
for (const m of members) {
for (const part of m.role.split(" · ")) {
const trimmed = part.trim();
if (trimmed) set.add(trimmed);
}
}
return Array.from(set).sort();
}
function toColumns<T>(items: T[], count: number): T[][] {
const cols: T[][] = Array.from({ length: count }, () => []);
items.forEach((item, i) => cols[i % count].push(item));
return cols;
}
function useColumnCount(ref: React.RefObject<HTMLDivElement | null>): number {
const [cols, setCols] = useState(4);
useEffect(() => {
const el = ref.current;
if (!el) return;
function calc() {
const w = el!.clientWidth;
if (w >= 1200) setCols(5);
else if (w >= 900) setCols(4);
else if (w >= 600) setCols(3);
else setCols(2);
}
calc();
const ro = new ResizeObserver(calc);
ro.observe(el);
return () => ro.disconnect();
}, [ref]);
return cols;
}
/* ── Main Component ────────────────────────────────────────────────── */
export function TeamGrid({ data, schedule, scheduleConfig }: TeamGridProps) {
const [search, setSearch] = useState("");
const [activeStyle, setActiveStyle] = useState<string | null>(null);
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const columnCount = useColumnCount(gridRef);
const searchParams = useSearchParams();
const members = data?.members ?? [];
useEffect(() => {
const trainerName = searchParams.get("trainer");
if (trainerName) {
const found = members.find((m) => m.name === trainerName);
if (found) setSelectedMember(found);
}
}, [searchParams, members]);
const styles = useMemo(() => extractStyles(members), [members]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return members.filter((m) => {
if (q && !m.name.toLowerCase().includes(q)) return false;
if (activeStyle && !m.role.split(" · ").map((s) => s.trim()).includes(activeStyle)) return false;
return true;
});
}, [members, search, activeStyle]);
const columns = useMemo(() => toColumns(filtered, columnCount), [filtered, columnCount]);
const openProfile = useCallback((member: TeamMember) => {
setSelectedMember(member);
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const closeProfile = useCallback(() => setSelectedMember(null), []);
const resetFilters = useCallback(() => {
setSearch("");
setActiveStyle(null);
}, []);
return (
<div className="w-full min-h-screen bg-neutral-50 dark:bg-[#050505]">
{/* Profile view — shown on top, grid stays mounted but hidden */}
{selectedMember && (
<div className="px-4 sm:px-6 lg:px-8 pt-4 pb-10">
<TeamProfile
member={selectedMember}
onBack={closeProfile}
schedule={schedule}
scheduleConfig={scheduleConfig}
/>
</div>
)}
{/* Grid + filters — hidden while profile is open so ResizeObserver stays alive */}
<div className={selectedMember ? "hidden" : ""}>
{/* Filter Bar */}
<div className="sticky top-16 z-30 border-b border-neutral-200/50 dark:border-white/[0.04] bg-neutral-50/80 dark:bg-[#050505]/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1600px] px-4 sm:px-6">
<div className="flex items-center gap-2 py-3 overflow-x-auto scrollbar-hide">
<div className="relative flex-shrink-0">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500 pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск…"
className="w-32 sm:w-40 rounded-full bg-neutral-200/60 pl-8 pr-3 py-1.5 text-sm text-neutral-800 placeholder-neutral-400 outline-none transition-all
focus:w-48 focus:bg-neutral-200
dark:bg-white/[0.06] dark:text-white dark:placeholder-neutral-500
dark:focus:bg-white/[0.1]"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-white cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
<div className="w-px h-5 bg-neutral-300 dark:bg-white/[0.08] flex-shrink-0" />
<Chip active={!activeStyle} onClick={() => setActiveStyle(null)}>Все</Chip>
{styles.map((s) => (
<Chip
key={s}
active={activeStyle === s}
onClick={() => setActiveStyle(activeStyle === s ? null : s)}
>
{s}
</Chip>
))}
</div>
</div>
</div>
{/* Masonry Grid */}
<div ref={gridRef} className="mx-auto max-w-[1600px] px-2 sm:px-4 py-4">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-neutral-400 dark:text-neutral-500 text-sm mb-4">Тренеры не найдены</p>
<button
onClick={resetFilters}
className="rounded-full border border-gold/40 px-5 py-2 text-xs font-medium text-gold hover:bg-gold/10 transition-colors cursor-pointer"
>
Сбросить фильтры
</button>
</div>
) : (
<div className="flex gap-1.5 sm:gap-2">
{columns.map((col, ci) => (
<div key={ci} className="flex-1 flex flex-col gap-1.5 sm:gap-2">
{col.map((member, mi) => (
<PinCard
key={member.name}
member={member}
index={ci * 100 + mi}
onClick={openProfile}
/>
))}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
/* ── Filter Chip ───────────────────────────────────────────────────── */
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={[
"flex-shrink-0 rounded-full px-3.5 py-1.5 text-xs font-medium transition-all duration-200 cursor-pointer whitespace-nowrap",
active
? "bg-gold text-black shadow-[0_0_12px_rgba(201,169,110,0.25)]"
: "bg-neutral-200/60 text-neutral-600 hover:bg-neutral-300/60 dark:bg-white/[0.06] dark:text-neutral-400 dark:hover:bg-white/[0.1] dark:hover:text-white",
].join(" ")}
>
{children}
</button>
);
}
/* ── Pin Card ──────────────────────────────────────────────────────── */
function PinCard({ member, index, onClick }: { member: TeamMember; index: number; onClick: (m: TeamMember) => void }) {
const delay = Math.min((index % 100) * 50, 400);
return (
<article
onClick={() => onClick(member)}
className="group relative overflow-hidden rounded-xl cursor-pointer"
style={{
animation: `team-grid-card-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms both`,
}}
>
<Image
src={member.image}
alt={member.name}
width={400}
height={600}
sizes="(min-width: 1200px) 20vw, (min-width: 900px) 25vw, (min-width: 600px) 33vw, 50vw"
className="w-full h-auto block rounded-xl transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 rounded-xl bg-gradient-to-t from-black/80 via-black/10 to-transparent
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4
translate-y-2 group-hover:translate-y-0
opacity-0 group-hover:opacity-100
transition-all duration-300 ease-out">
<p className="font-display text-sm sm:text-base font-semibold text-white leading-tight tracking-wide uppercase">
{member.name}
</p>
<p className="mt-0.5 text-[11px] sm:text-xs text-gold-light/80 leading-snug">
{member.role}
</p>
</div>
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/20 transition-colors duration-300 pointer-events-none" />
</article>
);
}
-478
View File
@@ -1,478 +0,0 @@
import type { SiteContent } from "@/types";
export const siteContent: SiteContent = {
meta: {
title: "BLACK HEART DANCE HOUSE | Школа танцев",
description:
"Школа танцев BLACK HEART DANCE HOUSE — pole dance, exotic, body plastic и другие направления в Минске",
},
hero: {
headline: "BLACK HEART DANCE HOUSE",
subheadline:
"Открой для себя яркий, завораживающий и незабываемый мир танцев на пилоне!",
ctaText: "Записаться",
ctaHref: "#contact",
videos: ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"],
},
about: {
title: "О нас",
paragraphs: [
"Топовые тренеры, стильные залы, чувственные хореографии и мощная спортивная подготовка.",
"Обучаем с нуля до профи!",
],
},
team: {
title: "Настоящие профи!",
members: [
{
name: "Виктор Артёмов",
role: "Pole Fitness · Exotic · Strip",
image: "/images/team/viktor-artyomov.webp",
instagram: "https://instagram.com/viktor.artyomov/",
description:
"Я тренер со специальной методикой для подготовки учеников в Pole Fitness, Pole Exotic и Strip хореографии. Научу вас базовым стойкам, перекатам, а также более сложным комбинациям и трюкам. В спорте более 30 лет — спортивная гимнастика, тайский бокс, артистическая деятельность. Призёр внутренних и международных чемпионатов по пилону и фитнесу. Судья чемпионатов по пилону и танцам. Основатель студии Black Heart Dance House.",
},
{
name: "Анна Тарыба",
role: "Exotic Pole Dance",
image: "/images/team/anna-taryba.webp",
instagram: "https://instagram.com/annataryba/",
description:
"Мощь и сила в каждой связке. Мои акцентные хореографии созданы для продвинутого уровня, где вы сможете раскрыть свой потенциал и почувствовать себя настоящей королевой танца. Готовьтесь к интенсивному погружению в мир уверенных движений и сложных элементов, где каждое занятие — это новый вызов и триумф!",
},
{
name: "Анастасия Чалей",
role: "Exotic Pole Dance",
image: "/images/team/anastasia-chaley.webp",
instagram: "https://instagram.com/nastya_chaley/",
description:
"Вас ждут креативные хореографии, акцент на музыкальность и подачу, развитие уверенности и раскрытие вашей индивидуальности. Присоединяйтесь к тренировкам, где царит атмосфера радости и танцевального вдохновения! Мой вайб — «танцы — это радость».",
},
{
name: "Ольга Демидова",
role: "Pole Dance",
image: "/images/team/olga-demidova.webp",
instagram: "https://instagram.com/don_olga_red/",
description:
"Я вдохновляющий лидер, который открывает двери в мир удивительного Pole Dance. С каждым занятием помогаю своим ученикам преодолевать собственные границы и достигать результатов, которые казались недостижимыми.",
},
{
name: "Ирина Третьякович",
role: "Exotic Pole Dance",
image: "/images/team/irina-tretyukovich.webp",
instagram: "https://instagram.com/irkatretya/",
description:
"Вас ждёт калейдоскоп эмоций: от сексуальной связки до нежной лирики и даже мистического драйва. Мои хореографии всегда энергичны и непредсказуемы, пробуждают самые смелые ваши стороны. Приготовьтесь к скоростному погружению в мир танца, где каждое движение — это вызов и откровение!",
},
{
name: "Надежда Сыч",
role: "Exotic Pole Dance · Body Plastic",
image: "/images/team/nadezhda-sukh.webp",
instagram: "https://instagram.com/nadja.dance/",
description:
"Со мной вы научитесь кайфовать от себя и раскрывать свою сексуальность. Помогу развить силу, баланс и пластику, а главное — почувствовать себя желанной и привлекательной.",
},
{
name: "Ирина Карпусь",
role: "Exotic Pole Dance",
image: "/images/team/irina-karpus.webp",
instagram: "https://instagram.com/karpus_iri/",
description:
"Я проводник в мир чувственного Exotic Pole Dance. Мои хореографии проникают в самое сердце, а занятия — идеальный старт для тех, кто хочет раскрыть свою женственность и уверенность в себе.",
},
{
name: "Юлия Книга",
role: "Erotic Pole Dance",
image: "/images/team/yuliya-kniga.webp",
instagram: "https://instagram.com/knigynzel/",
description:
"Я не просто инструктор, я настоящий вдохновитель и проводник в мир Erotic Pole Dance. Мои тренировки — это не просто набор упражнений, это целое искусство, в котором каждая из вас чувствует себя особенной и ценной.",
},
{
name: "Алёна Чигилейчик",
role: "Exotic Pole Dance",
image: "/images/team/elena-chigileychik.webp",
instagram: "https://instagram.com/alenachygi/",
description:
"Создаю атмосферу, где каждая деталь имеет значение. Мои занятия — это разнообразие стилей, где внимание уделяется каждому движению, а дружелюбная атмосфера помогает раскрыться и почувствовать себя уверенно.",
},
{
name: "Елена Тарасевич",
role: "Body Plastic",
image: "/images/team/elena-tarasevic.webp",
instagram: "https://instagram.com/cerceia/",
description:
"Ваш ключ к здоровому, гибкому и гармоничному телу. Знаю каждую связку, каждую клеточку вашего тела. Чувствую ваши ограничения, предугадываю ваши возможности и бережно веду вас к границам вашей гибкости.",
},
{
name: "Кристина Войтович",
role: "Exotic Pole Dance",
image: "/images/team/kristina-voytovich.webp",
instagram: "https://instagram.com/chris_voytovich/",
description:
"В моих танцах кипит безумная смесь силы и чувственности. Обожаю переключаться между разными хореографиями: чувственными, дерзкими, меланхоличными, сексуальными... Каждая из них — это взрыв эмоций.",
},
{
name: "Екатерина Матлахова",
role: "Exotic · Pole Dance",
image: "/images/team/ekaterina-matlakhova.webp",
description:
"Создаю чувственные хореографии, где женственность расцветает в сексуальных движениях, изящных линиях и плавных переходах, подкреплённых эстетичными силовыми элементами. В моих танцах рождаются богини!",
},
{
name: "Лилия Огурцова",
role: "Exotic · Pole Dance",
image: "/images/team/liliya-ogurtsova.webp",
description:
"Я проведу вас в мир акцентных и чарующих хореографий. Мои занятия наполнены мистическим вайбом, драйвом и энергией. Уделяю особое внимание развитию силы, прокачке тела и чистоте движений, а также эмоциональной подаче в танце.",
},
{
name: "Наталья Анцух",
role: "Exotic Pole Dance",
image: "/images/team/natalya-antsukh.webp",
description:
"Каждое занятие — это праздник для тела и души, где стиль, грация и внутренняя сила объединяются воедино. Новичок или профессионал — я научу вас танцевать с уверенностью, раскрывать свою женственность и получать удовольствие от каждого движения.",
},
{
name: "Яна Артюкевич",
role: "Pole Dance",
image: "/images/team/yana-artyukevich.webp",
description:
"На моих занятиях вы научитесь красиво и уверенно владеть своим телом, освоите базовые трюки и элементы на пилоне — шаг за шагом, в уютной и вдохновляющей атмосфере. Укрепим мышцы, улучшим растяжку и осанку, а в процессе — почувствуете невероятную уверенность, сексуальность и внутреннюю силу.",
},
{
name: "Анжела Бобко",
role: "Pole Dance",
image: "/images/team/anzhela-bobko.webp",
description:
"Мой индивидуальный подход и внимательное отношение к каждому ученику создают атмосферу доверия и поддержки. Со мной вы не просто осваиваете технику — вы преодолеваете себя и становитесь лучшей версией себя.",
},
],
},
classes: {
title: "Направления",
items: [
{
name: "Exotic Pole Dance",
description:
"Чувственная хореография с элементами pole dance в каблуках.",
icon: "sparkles",
detailedDescription:
"Стиль танца на пилоне, где акцент делается на чувственность, пластику. В Exotic Pole Dance используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны,\n— вы учитесь наслаждаться собой.",
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
},
{
name: "Pole Dance",
description:
"Искусство на пилоне: акробатические трюки, силовые элементы и грация.",
icon: "flame",
detailedDescription:
"Вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и уровня технического мастерства.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
images: ["/images/classes/pole-dance.webp"],
},
{
name: "Body Plastic",
description:
"Пластичность, гибкость и осознанность тела в каждом движении.",
icon: "wind",
detailedDescription:
"Тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро.\n\nВместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
images: ["/images/classes/body-plastic.webp"],
},
{
name: "Трюковые комбинации с пилоном",
description:
"Яркие трюки, акробатические элементы и впечатляющие комбинации.",
icon: "zap",
detailedDescription:
"Направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
},
{
name: "Мастер классы",
description:
"Уникальные занятия с приглашёнными топовыми тренерами.",
icon: "star",
detailedDescription:
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\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: "Онлайн занятия",
description: "Тренировки в удобное время из любой точки мира.",
icon: "monitor",
detailedDescription:
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram.",
images: ["/images/classes/online-classes.webp"],
},
],
},
faq: {
title: "Частые вопросы",
items: [
{
question: "Что такое Exotic Pole Dance, Pole Dance и Body Plastic?",
answer:
"Exotic Pole Dance — стиль танца на пилоне, где акцент делается на чувственность, пластику. Используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nPole Dance — вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и технического мастерства.\n\nBody Plastic — тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением.",
},
{
question: "Нужно ли иметь специальную подготовку, чтобы начать заниматься?",
answer:
"Нет, специальная подготовка не требуется. Уровень физической подготовки будет расти постепенно в процессе тренировок. Важно иметь желание и готовность к обучению.",
},
{
question: "Какая одежда нужна для занятий?",
answer:
"Pole Dance: важны шорты и топ, чтобы кожа на бёдрах и животе соприкасалась с пилоном для сцепления.\n\nExotic Pole Dance: на начальных этапах лучше шорты, можно леггинсы, топ/лиф, наколенники и желательно стрипы. На начальном этапе можно начинать без стрипов в носочках.",
},
{
question: "Какие группы по уровню существуют в вашей студии?",
answer:
"У нас есть группы для начинающих — «С нуля», где вы можете освоить базовые движения и технику. Также есть группы для продолжающих и для любого уровня подготовки — чтобы все могли развиваться и совершенствоваться в приятной и поддерживающей атмосфере.",
},
{
question: "Можно ли начать заниматься Exotic Pole Dance в любом возрасте?",
answer:
"Да, конечно! Возраст не имеет значения — этот вид спорта подходит для всех желающих развивать силу, гибкость и уверенность в себе. Единственное ограничение — от 18 лет.",
},
{
question: "Я чувствую себя скованно. Как раскрепоститься на тренировках Exotic Pole Dance?",
answer:
"Exotic Pole Dance — это про самовыражение и принятие себя. Не бойтесь проявлять свои эмоции, экспериментировать с движениями. Постепенно вы почувствуете себя увереннее и свободнее. Наши тренеры создают на занятиях комфортную и поддерживающую атмосферу.",
},
{
question: "Как быстро я смогу делать трюки на пилоне?",
answer:
"Это индивидуально и зависит от вашей физической подготовки, регулярности тренировок и способностей к обучению. Первые простые трюки обычно осваиваются в течение нескольких недель.",
},
{
question: "Body Plastic — это растяжка?",
answer:
"Body Plastic — это не только про растяжку. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро. Вместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
},
{
question: "Что включает направление «Трюковые комбинации с пилоном»?",
answer:
"Трюковые комбинации с пилоном — это направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Это направление идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
},
{
question: "Сколько раз в неделю нужно заниматься?",
answer:
"Для новичков рекомендуется начинать с 2–3 раз в неделю. По мере развития физической формы и навыков можно увеличивать количество тренировок.",
},
{
question: "Участие в чемпионатах: обязательно ли это?",
answer:
"Нет, участие в чемпионатах — это не обязательно. Это скорее вопрос вашего личного желания и готовности. Если вы чувствуете в себе силы, мотивацию и хотите попробовать что-то новое, то не стесняйтесь сообщить об этом своему тренеру! Он поможет оценить ваши возможности и подготовиться к чемпионату наилучшим образом.",
},
],
},
pricing: {
title: "Стоимость",
subtitle: "Все абонементы идут с привязкой к группе, кроме безлимитного",
items: [
{ name: "Абонемент 8 × 90 мин", price: "175 BYN" },
{ name: "Абонемент 4 × 90 мин", price: "105 BYN" },
{ name: "Абонемент 8 × 60 мин", price: "145 BYN" },
{ name: "Абонемент 4 × 60 мин", price: "105 BYN" },
{ name: "Разовое занятие 1,5 часа", price: "30 BYN" },
{ name: "Разовое занятие 1 час", price: "25 BYN" },
{ name: "Пробное занятие", price: "25 BYN", note: "1,5 часа или 1 час" },
{
name: "Безлимитный абонемент",
price: "240 / 410 BYN",
note: "2 недели / месяц (обязательна предварительная запись)",
},
],
rentalTitle: "Аренда зала",
rentalItems: [
{ name: "С абонементом", price: "20 BYN", note: "+5 BYN за каждого доп. человека" },
{
name: "Без абонемента (Машерова 17/4, 6 этаж + Притыцкого 62/М)",
price: "35 BYN",
note: "+5 BYN за каждого доп. человека",
},
{
name: "Без абонемента (Машерова 17/4, 2 этаж)",
price: "25 BYN",
note: "+5 BYN за каждого доп. человека",
},
],
rules: [
"Абонемент является персональным и не подлежит передаче другим лицам.",
"Абонемент необходимо предъявлять администратору перед каждым занятием.",
"Оплата абонементов и разовых посещений производится до начала занятия.",
"Компенсация за пропущенные занятия не предусмотрена.",
"Срок действия абонемента — 4 недели.",
"Абонемент можно заморозить не более двух раз в год на срок до 2 недель (на время отпуска или командировки).",
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
],
},
masterClasses: {
title: "Мастер-классы",
items: [],
},
popups: {
successMessage: "Вы записаны!",
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
instagramHint: "По вопросам пишите в Instagram",
},
schedule: {
title: "Расписание",
locations: [
{
name: "Притыцкого 62/М",
address: "г. Минск, Притыцкого, 62/М",
days: [
{
day: "Понедельник",
dayShort: "ПН",
classes: [
{ time: "11:0012:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
{ time: "18:0019:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
{ time: "19:3021:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
{ time: "21:0022:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
],
},
{
day: "Вторник",
dayShort: "ВТ",
classes: [
{ time: "10:0011:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
{ time: "18:0019:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "19:3021:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "21:0022:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
],
},
{
day: "Среда",
dayShort: "СР",
classes: [
{ time: "18:3020:00", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном", level: "Продвинутый" },
{ time: "20:0021:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
{ time: "21:3022:30", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
],
},
{
day: "Четверг",
dayShort: "ЧТ",
classes: [
{ time: "11:0012:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
{ time: "18:0019:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
{ time: "19:3021:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
{ time: "21:0022:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
],
},
{
day: "Пятница",
dayShort: "ПТ",
classes: [
{ time: "10:0011:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
{ time: "18:0019:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "19:3021:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "21:0022:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
],
},
{
day: "Суббота",
dayShort: "СБ",
classes: [
{ time: "14:0015:00", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
{ time: "15:0016:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
],
},
{
day: "Воскресенье",
dayShort: "ВС",
classes: [
{ time: "12:0013:30", trainer: "Кристина Войтович", type: "Body Plastic" },
],
},
],
},
{
name: "Машерова 17/4",
address: "г. Минск, Машерова, 17/4",
days: [
{
day: "Понедельник",
dayShort: "ПН",
classes: [
{ time: "18:0019:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
{ time: "19:0020:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
{ time: "20:3022:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
],
},
{
day: "Вторник",
dayShort: "ВТ",
classes: [
{ time: "18:3020:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
{ time: "21:3023:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
],
},
{
day: "Среда",
dayShort: "СР",
classes: [
{ time: "18:0019:30", trainer: "Ольга Демидова", type: "Pole Dance" },
{ time: "19:3021:00", trainer: "Ольга Демидова", type: "Body Plastic" },
],
},
{
day: "Четверг",
dayShort: "ЧТ",
classes: [
{ time: "18:0019:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
{ time: "19:0020:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
{ time: "20:3022:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
],
},
{
day: "Пятница",
dayShort: "ПТ",
classes: [
{ time: "18:3020:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
{ time: "21:3023:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
],
},
{
day: "Суббота",
dayShort: "СБ",
classes: [
{ time: "10:3012:00", trainer: "Елена Тарасевич", type: "Body Plastic" },
{ time: "12:0013:30", trainer: "Ольга Демидова", type: "Pole Dance" },
],
},
],
},
],
},
scheduleConfig: {
levels: [
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает заниматься" },
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
],
statuses: [
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
],
},
news: {
title: "Новости",
items: [],
},
contact: {
title: "Контакты",
addresses: [
"г. Минск, Машерова, 17/4",
"г. Минск, Притыцкого, 62/М",
],
phone: "+375 29 389-70-01",
instagram: "https://instagram.com/blackheartdancehouse/",
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",
workingHours: "Пн — Сб: 10:00 — 22:00",
},
};
-97
View File
@@ -1,97 +0,0 @@
/**
* Seed script — populates the SQLite database from content.ts
* Run: npx tsx src/data/seed.ts
*/
import Database from "better-sqlite3";
import path from "path";
import { siteContent } from "./content";
const DB_PATH =
process.env.DATABASE_PATH ||
path.join(process.cwd(), "db", "blackheart.db");
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS sections (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
role TEXT NOT NULL,
image TEXT NOT NULL,
instagram TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
// Seed sections (team members go in their own table)
const sectionData: Record<string, unknown> = {
meta: siteContent.meta,
hero: siteContent.hero,
about: siteContent.about,
classes: siteContent.classes,
masterClasses: siteContent.masterClasses,
faq: siteContent.faq,
pricing: siteContent.pricing,
schedule: siteContent.schedule,
contact: siteContent.contact,
};
// Team section stores only the title
sectionData.team = { title: siteContent.team.title };
const upsertSection = db.prepare(
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
);
const insertMember = db.prepare(
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
// Upsert all sections
for (const [key, data] of Object.entries(sectionData)) {
upsertSection.run(key, JSON.stringify(data));
}
// Clear existing team members and re-insert
db.prepare("DELETE FROM team_members").run();
siteContent.team.members.forEach((m, i) => {
insertMember.run(
m.name,
m.role,
m.image,
m.instagram ?? null,
m.description ?? null,
i
);
});
});
tx();
const sectionCount = (
db.prepare("SELECT COUNT(*) as c FROM sections").get() as { c: number }
).c;
const memberCount = (
db.prepare("SELECT COUNT(*) as c FROM team_members").get() as { c: number }
).c;
console.log(`Seeded ${sectionCount} sections and ${memberCount} team members.`);
console.log(`Database: ${DB_PATH}`);
db.close();
+1 -1
View File
@@ -15,7 +15,7 @@ export const NAV_LINKS: NavLink[] = [
{ label: "Направления", href: "#classes" },
{ label: "Команда", href: "#team" },
{ label: "День открытых дверей", href: "#open-day" },
{ label: "Расписание", href: "#schedule" },
{ label: "Расписание", href: "/schedule" },
{ label: "Стоимость", href: "#pricing" },
{ label: "Мастер-классы", href: "#master-classes" },
{ label: "Новости", href: "#news" },