refactor: centralize gold tokens, extract sub-components, clean up unused code
- Replace hardcoded hex colors with gold/gold-light/gold-dark Tailwind tokens - Extract Schedule into DayCard, ScheduleFilters, MobileSchedule sub-components - Extract Team into TeamCarousel, TeamMemberInfo sub-components - Add UI_CONFIG for centralized magic numbers (timings, thresholds) - Add reusable IconBadge component, simplify Contact section - Convert Pricing clickable divs to semantic buttons for a11y - Remove unused SocialLinks, btn-outline, btn-ghost, nav-link CSS classes - Fix React setState-during-render error in TeamCarousel (deferred update pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,77 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { MapPin, Clock, User, X, ChevronDown } from "lucide-react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { siteContent } from "@/data/content";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import type { ScheduleDay } from "@/types/content";
|
||||
|
||||
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";
|
||||
|
||||
function DayCard({ day }: { day: ScheduleDay }) {
|
||||
return (
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
import { DayCard } from "./schedule/DayCard";
|
||||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||
import type { StatusFilter } from "./schedule/constants";
|
||||
|
||||
export function Schedule() {
|
||||
const { schedule } = siteContent;
|
||||
@@ -121,7 +58,7 @@ export function Schedule() {
|
||||
.filter((day) => day.classes.length > 0);
|
||||
}, [location.days, filterTrainer, filterType, filterStatus]);
|
||||
|
||||
const hasActiveFilter = filterTrainer || filterType || filterStatus !== "all";
|
||||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all");
|
||||
|
||||
function clearFilters() {
|
||||
setFilterTrainer(null);
|
||||
@@ -129,10 +66,6 @@ export function Schedule() {
|
||||
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 whitespace-nowrap";
|
||||
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"
|
||||
@@ -158,7 +91,7 @@ export function Schedule() {
|
||||
}}
|
||||
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)]"
|
||||
? "bg-gold 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"
|
||||
}`}
|
||||
>
|
||||
@@ -171,187 +104,36 @@ export function Schedule() {
|
||||
|
||||
{/* Compact filters — desktop only */}
|
||||
<Reveal>
|
||||
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
|
||||
{/* 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 shrink-0 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 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 shrink-0 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 shrink-0 rounded-full bg-sky-500" />
|
||||
Набор
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 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 shrink-0 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>
|
||||
)}
|
||||
<ScheduleFilters
|
||||
types={types}
|
||||
trainers={trainers}
|
||||
hasAnySlots={hasAnySlots}
|
||||
hasAnyRecruiting={hasAnyRecruiting}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainer}
|
||||
filterStatus={filterStatus}
|
||||
setFilterStatus={setFilterStatus}
|
||||
showTrainers={showTrainers}
|
||||
setShowTrainers={setShowTrainers}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact agenda list with tap-to-filter */}
|
||||
<Reveal>
|
||||
<div className="mt-6 px-4 sm:hidden">
|
||||
{/* Active filter indicator */}
|
||||
{hasActiveFilter && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-xl bg-[#c9a96e]/10 px-4 py-2.5 dark:bg-[#c9a96e]/5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[#a08050] dark:text-[#d4b87a]">
|
||||
{filterTrainer && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={11} />
|
||||
{filterTrainer}
|
||||
</span>
|
||||
)}
|
||||
{filterType && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[filterType] ?? "bg-white/30"}`} />
|
||||
{filterType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] text-[#a08050] dark:text-[#d4b87a] active:bg-[#c9a96e]/20"
|
||||
>
|
||||
<X size={12} />
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredDays.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{filteredDays.map((day) => (
|
||||
<div key={day.day}>
|
||||
{/* Day header */}
|
||||
<div className="flex items-center gap-2.5 py-2.5">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#c9a96e]/10 text-xs font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
|
||||
{day.dayShort}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
|
||||
{day.day}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Class rows */}
|
||||
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
|
||||
{day.classes.map((cls, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}
|
||||
>
|
||||
{/* Time */}
|
||||
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
|
||||
{cls.time}
|
||||
</span>
|
||||
|
||||
{/* Info — tappable trainer & type */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
||||
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-[#c9a96e] underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
|
||||
>
|
||||
{cls.trainer}
|
||||
</button>
|
||||
{cls.hasSlots && (
|
||||
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] 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-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400">
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
{cls.level && (
|
||||
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
|
||||
{cls.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
||||
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className={`text-[11px] ${filterType === cls.type ? "text-[#c9a96e] underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
Нет занятий по выбранным фильтрам
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MobileSchedule
|
||||
filteredDays={filteredDays}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainer}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* Desktop: grid layout */}
|
||||
|
||||
Reference in New Issue
Block a user