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:
@@ -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 />
|
||||
|
||||
270
src/components/sections/Schedule.tsx
Normal file
270
src/components/sections/Schedule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -307,6 +307,137 @@ export const siteContent: SiteContent = {
|
||||
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
|
||||
],
|
||||
},
|
||||
schedule: {
|
||||
title: "Расписание",
|
||||
locations: [
|
||||
{
|
||||
name: "Притыцкого 62/М",
|
||||
address: "г. Минск, Притыцкого, 62/М",
|
||||
days: [
|
||||
{
|
||||
day: "Понедельник",
|
||||
dayShort: "ПН",
|
||||
classes: [
|
||||
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Вторник",
|
||||
dayShort: "ВТ",
|
||||
classes: [
|
||||
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Среда",
|
||||
dayShort: "СР",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном", level: "Продвинутый" },
|
||||
{ time: "20:00–21:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–22:30", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Четверг",
|
||||
dayShort: "ЧТ",
|
||||
classes: [
|
||||
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Пятница",
|
||||
dayShort: "ПТ",
|
||||
classes: [
|
||||
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Суббота",
|
||||
dayShort: "СБ",
|
||||
classes: [
|
||||
{ time: "14:00–15:00", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||
{ time: "15:00–16:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Воскресенье",
|
||||
dayShort: "ВС",
|
||||
classes: [
|
||||
{ time: "12:00–13:30", trainer: "Кристина Войтович", type: "Body Plastic" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Машерова 17/4",
|
||||
address: "г. Минск, Машерова, 17/4",
|
||||
days: [
|
||||
{
|
||||
day: "Понедельник",
|
||||
dayShort: "ПН",
|
||||
classes: [
|
||||
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Вторник",
|
||||
dayShort: "ВТ",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Среда",
|
||||
dayShort: "СР",
|
||||
classes: [
|
||||
{ time: "18:00–19:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||
{ time: "19:30–21:00", trainer: "Ольга Демидова", type: "Body Plastic" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Четверг",
|
||||
dayShort: "ЧТ",
|
||||
classes: [
|
||||
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Пятница",
|
||||
dayShort: "ПТ",
|
||||
classes: [
|
||||
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
day: "Суббота",
|
||||
dayShort: "СБ",
|
||||
classes: [
|
||||
{ time: "10:30–12:00", trainer: "Елена Тарасевич", type: "Body Plastic" },
|
||||
{ time: "12:00–13:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
title: "Контакты",
|
||||
addresses: [
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user