feat: add schedule section with location tabs, filters, and status badges

Two locations (Притыцкого 62/М, Машерова 17/4) with day-grid layout,
class type/trainer/status filters, and badges for availability and recruiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 18:47:59 +03:00
parent 233c117afa
commit 8d2f482e99
5 changed files with 429 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ 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 { Schedule } from "@/components/sections/Schedule";
import { Pricing } from "@/components/sections/Pricing";
import { FAQ } from "@/components/sections/FAQ";
import { Contact } from "@/components/sections/Contact";
@@ -14,6 +15,7 @@ export default function HomePage() {
<About />
<Team />
<Classes />
<Schedule />
<Pricing />
<FAQ />
<Contact />

View File

@@ -0,0 +1,270 @@
"use client";
import { useState, useMemo } from "react";
import { MapPin, Clock, User, X, ChevronDown } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
const TYPE_DOT: Record<string, string> = {
"Exotic Pole Dance": "bg-[#c9a96e]",
"Pole Dance": "bg-rose-500",
"Body Plastic": "bg-purple-500",
"Трюковые комбинации с пилоном": "bg-amber-500",
};
type StatusFilter = "all" | "hasSlots" | "recruiting";
export function Schedule() {
const { schedule } = siteContent;
const [locationIndex, setLocationIndex] = useState(0);
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
const [showTrainers, setShowTrainers] = useState(false);
const location = schedule.locations[locationIndex];
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
const trainerSet = new Set<string>();
const typeSet = new Set<string>();
let slots = false;
let recruiting = false;
for (const day of location.days) {
for (const cls of day.classes) {
trainerSet.add(cls.trainer);
typeSet.add(cls.type);
if (cls.hasSlots) slots = true;
if (cls.recruiting) recruiting = true;
}
}
return {
trainers: Array.from(trainerSet).sort(),
types: Array.from(typeSet).sort(),
hasAnySlots: slots,
hasAnyRecruiting: recruiting,
};
}, [location]);
const filteredDays = useMemo(() => {
const noFilter = !filterTrainer && !filterType && filterStatus === "all";
if (noFilter) return location.days;
return location.days
.map((day) => ({
...day,
classes: day.classes.filter(
(cls) =>
(!filterTrainer || cls.trainer === filterTrainer) &&
(!filterType || cls.type === filterType) &&
(filterStatus === "all" ||
(filterStatus === "hasSlots" && cls.hasSlots) ||
(filterStatus === "recruiting" && cls.recruiting))
),
}))
.filter((day) => day.classes.length > 0);
}, [location.days, filterTrainer, filterType, filterStatus]);
const hasActiveFilter = filterTrainer || filterType || filterStatus !== "all";
function clearFilters() {
setFilterTrainer(null);
setFilterType(null);
setFilterStatus("all");
}
const pillBase = "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer";
const pillActive = "bg-[#c9a96e]/20 text-[#a08050] border border-[#c9a96e]/40 dark:text-[#d4b87a] dark:border-[#c9a96e]/30";
const pillInactive = "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15";
return (
<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">
<Reveal>
<SectionHeading centered>{schedule.title}</SectionHeading>
</Reveal>
{/* Location tabs */}
<Reveal>
<div className="mt-8 flex justify-center gap-2">
{schedule.locations.map((loc, i) => (
<button
key={loc.name}
onClick={() => {
setLocationIndex(i);
clearFilters();
setShowTrainers(false);
}}
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
i === locationIndex
? "bg-[#c9a96e] text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]"
: "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20"
}`}
>
<MapPin size={14} />
{loc.name}
</button>
))}
</div>
</Reveal>
{/* Compact filters */}
<Reveal>
<div className="mt-5 flex flex-wrap items-center justify-center gap-1.5">
{/* Class types */}
{types.map((type) => (
<button
key={type}
onClick={() => setFilterType(filterType === type ? null : type)}
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
{type}
</button>
))}
{/* Divider */}
<span className="mx-1 h-4 w-px bg-neutral-200 dark:bg-white/10" />
{/* Status filters */}
{hasAnySlots && (
<button
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Есть места
</button>
)}
{hasAnyRecruiting && (
<button
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-sky-500" />
Набор
</button>
)}
{/* Divider */}
<span className="mx-1 h-4 w-px bg-neutral-200 dark:bg-white/10" />
{/* Trainer dropdown toggle */}
<button
onClick={() => setShowTrainers(!showTrainers)}
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`}
>
<User size={11} />
{filterTrainer ?? "Тренер"}
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} />
</button>
{/* Clear */}
{hasActiveFilter && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer"
>
<X size={11} />
</button>
)}
</div>
{/* Trainer pills — expandable */}
{showTrainers && (
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5">
{trainers.map((trainer) => (
<button
key={trainer}
onClick={() => {
setFilterTrainer(filterTrainer === trainer ? null : trainer);
setShowTrainers(false);
}}
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
>
{trainer}
</button>
))}
</div>
)}
</Reveal>
</div>
{/* Day columns — full width */}
<Reveal>
<div
key={`${locationIndex}-${filterTrainer}-${filterType}-${filterStatus}`}
className={`mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 lg:px-8 xl:px-6 ${filteredDays.length >= 7 ? "xl:grid-cols-7" : filteredDays.length >= 4 ? "xl:grid-cols-" + filteredDays.length : "xl:grid-cols-4"}`}
style={filteredDays.length < 4 && filteredDays.length > 0 ? { maxWidth: filteredDays.length * 320 + (filteredDays.length - 1) * 12, marginInline: "auto" } : undefined}
>
{filteredDays.map((day) => (
<div
key={day.day}
className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden"
>
{/* Day header */}
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-sm font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
{day.dayShort}
</span>
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
{day.day}
</span>
</div>
</div>
{/* Classes */}
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => (
<div key={i} className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
<Clock size={13} />
<span className="font-semibold">{cls.time}</span>
</div>
{cls.hasSlots && (
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
есть места
</span>
)}
{cls.recruiting && (
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
набор
</span>
)}
</div>
<div className="mt-1.5 flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-white/80">
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
{cls.trainer}
</div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
</div>
{cls.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
{cls.level}
</span>
)}
</div>
</div>
))}
</div>
</div>
))}
{filteredDays.length === 0 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
)}
</div>
</Reveal>
</section>
);
}

View File

@@ -307,6 +307,137 @@ export const siteContent: SiteContent = {
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
],
},
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" },
],
},
],
},
],
},
contact: {
title: "Контакты",
addresses: [

View File

@@ -11,6 +11,7 @@ export const NAV_LINKS: NavLink[] = [
{ label: "О нас", href: "#about" },
{ label: "Команда", href: "#team" },
{ label: "Направления", href: "#classes" },
{ label: "Расписание", href: "#schedule" },
{ label: "Стоимость", href: "#pricing" },
{ label: "FAQ", href: "#faq" },
{ label: "Контакты", href: "#contact" },

View File

@@ -25,6 +25,27 @@ export interface PricingItem {
note?: string;
}
export interface ScheduleClass {
time: string;
trainer: string;
type: string;
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
}
export interface ScheduleDay {
day: string;
dayShort: string;
classes: ScheduleClass[];
}
export interface ScheduleLocation {
name: string;
address: string;
days: ScheduleDay[];
}
export interface ContactInfo {
title: string;
addresses: string[];
@@ -69,5 +90,9 @@ export interface SiteContent {
rentalItems: PricingItem[];
rules: string[];
};
schedule: {
title: string;
locations: ScheduleLocation[];
};
contact: ContactInfo;
}