feat: UI improvements — scrollbar, multi-filters, pricing fix, routing, modals

- Global page scrollbar styled with gold theme
- Schedule: multi-select for class types and status tags
- Pricing: fix tab switch blink (display toggle vs conditional render)
- OpenDay: trainer name more prominent, section divider added
- Team: browser back button closes trainer bio (history API)
- Modals: block scroll + compensate scrollbar width to prevent layout shift
- Header: remove booking button from desktop nav
This commit is contained in:
2026-03-26 13:23:03 +03:00
parent 228e547e10
commit 8088b99a43
17 changed files with 275 additions and 229 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -83,3 +83,27 @@ body {
.admin-scrollbar::-webkit-scrollbar-thumb:hover { .admin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* ===== Global page scrollbar ===== */
html {
scrollbar-width: thin;
scrollbar-color: rgba(201, 169, 110, 0.3) #0a0a0a;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0a0a0a;
}
::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(201, 169, 110, 0.5);
}

View File

@@ -124,12 +124,6 @@ export function Header() {
</a> </a>
); );
})} })}
<button
onClick={() => setBookingOpen(true)}
className="rounded-full bg-gold px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
>
Записаться
</button>
</nav> </nav>
<div className="flex items-center gap-2 lg:hidden"> <div className="flex items-center gap-2 lg:hidden">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Calendar, Users, Sparkles } from "lucide-react"; import { Calendar, Sparkles } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
@@ -48,7 +48,8 @@ export function OpenDay({ data, popups }: OpenDayProps) {
if (classes.length === 0) return null; if (classes.length === 0) return null;
return ( return (
<section id="open-day" className="py-10 sm:py-14"> <section id="open-day" className="section-glow relative py-10 sm:py-14">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="mx-auto max-w-6xl px-4"> <div className="mx-auto max-w-6xl px-4">
<Reveal> <Reveal>
<SectionHeading centered>{event.title}</SectionHeading> <SectionHeading centered>{event.title}</SectionHeading>
@@ -179,11 +180,8 @@ function ClassCard({
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span> <span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span>
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p> <p className="text-sm font-bold text-white mt-1">{cls.trainer}</p>
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5"> <p className="text-xs text-neutral-400 mt-0.5">{cls.style}</p>
<Users size={10} />
{cls.trainer}
</p>
{maxParticipants > 0 && ( {maxParticipants > 0 && (
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}> <p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
{cls.bookingCount}/{maxParticipants} мест {cls.bookingCount}/{maxParticipants} мест

View File

@@ -53,136 +53,128 @@ export function Pricing({ data: pricing }: PricingProps) {
</Reveal> </Reveal>
{/* Prices tab */} {/* Prices tab */}
{activeTab === "prices" && ( <div className={activeTab === "prices" ? "block" : "hidden"}>
<Reveal> <div className="mx-auto mt-10 max-w-4xl">
<div className="mx-auto mt-10 max-w-4xl"> <p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400"> {pricing.subtitle}
{pricing.subtitle} </p>
</p>
{/* Cards grid */} {/* Cards grid */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{regularItems.map((item, i) => { {regularItems.map((item, i) => {
const isPopular = item.popular ?? false; const isPopular = item.popular ?? false;
return ( return (
<div <div
key={i} key={i}
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${ className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
isPopular isPopular
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10" ? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]" : "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`} }`}
> >
{/* Popular badge */} {/* Popular badge */}
{isPopular && ( {isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> <div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30"> <span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
<Sparkles size={10} /> <Sparkles size={10} />
Популярный Популярный
</span> </span>
</div> </div>
)}
<div className={isPopular ? "mt-1" : ""}>
{/* Name */}
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
{item.name}
</p>
{/* Note */}
{item.note && (
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
{item.note}
</p>
)} )}
<div className={isPopular ? "mt-1" : ""}> {/* Price */}
{/* Name */} <p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}> {item.price}
{item.name} </p>
</p>
{/* Note */}
{item.note && (
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
{item.note}
</p>
)}
{/* Price */}
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
{item.price}
</p>
</div>
</div> </div>
);
})}
</div>
{/* Featured — big card */}
{featuredItem && (
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="text-center sm:text-left">
<div className="flex items-center justify-center gap-2 sm:justify-start">
<Crown size={18} className="text-gold" />
<p className="text-lg font-bold text-neutral-900 dark:text-white">
{featuredItem.name}
</p>
</div>
{featuredItem.note && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{featuredItem.note}
</p>
)}
</div>
<p className="shrink-0 font-display text-3xl font-bold text-gold">
{featuredItem.price}
</p>
</div> </div>
</div> );
)} })}
</div>
</div> {/* Featured — big card */}
</Reveal> {featuredItem && (
)} <div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
{/* Rental tab */} <div className="text-center sm:text-left">
{activeTab === "rental" && ( <div className="flex items-center justify-center gap-2 sm:justify-start">
<Reveal> <Crown size={18} className="text-gold" />
<div className="mx-auto mt-10 max-w-2xl space-y-3"> <p className="text-lg font-bold text-neutral-900 dark:text-white">
{pricing.rentalItems.map((item, i) => ( {featuredItem.name}
<div </p>
key={i} </div>
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]" {featuredItem.note && (
> <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
<div> {featuredItem.note}
<p className="font-medium text-neutral-900 dark:text-white">
{item.name}
</p>
{item.note && (
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
{item.note}
</p> </p>
)} )}
</div> </div>
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light"> <p className="shrink-0 font-display text-3xl font-bold text-gold">
{item.price} {featuredItem.price}
</span>
</div>
))}
</div>
</Reveal>
)}
{/* Rules tab */}
{activeTab === "rules" && (
<Reveal>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rules.map((rule, i) => (
<div
key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{i + 1}
</span>
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{rule}
</p> </p>
</div> </div>
))} </div>
</div> )}
</Reveal> </div>
)} </div>
{/* Rental tab */}
<div className={activeTab === "rental" ? "block" : "hidden"}>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rentalItems.map((item, i) => (
<div
key={i}
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
<div>
<p className="font-medium text-neutral-900 dark:text-white">
{item.name}
</p>
{item.note && (
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
{item.note}
</p>
)}
</div>
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
{item.price}
</span>
</div>
))}
</div>
</div>
{/* Rules tab */}
<div className={activeTab === "rules" ? "block" : "hidden"}>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rules.map((rule, i) => (
<div
key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{i + 1}
</span>
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{rule}
</p>
</div>
))}
</div>
</div>
</div> </div>
</section> </section>
); );

View File

@@ -10,7 +10,7 @@ import { ScheduleFilters } from "./schedule/ScheduleFilters";
import { MobileSchedule } from "./schedule/MobileSchedule"; import { MobileSchedule } from "./schedule/MobileSchedule";
import { GroupView } from "./schedule/GroupView"; import { GroupView } from "./schedule/GroupView";
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants"; import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
import type { StatusFilter, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants"; import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
type ViewMode = "days" | "groups"; type ViewMode = "days" | "groups";
@@ -20,8 +20,8 @@ interface ScheduleState {
locationMode: LocationMode; locationMode: LocationMode;
viewMode: ViewMode; viewMode: ViewMode;
filterTrainer: string | null; filterTrainer: string | null;
filterType: string | null; filterTypes: Set<string>;
filterStatus: StatusFilter; filterStatusSet: Set<StatusTag>;
filterTime: TimeFilter; filterTime: TimeFilter;
filterDaySet: Set<string>; filterDaySet: Set<string>;
bookingGroup: string | null; bookingGroup: string | null;
@@ -31,8 +31,8 @@ type ScheduleAction =
| { type: "SET_LOCATION"; mode: LocationMode } | { type: "SET_LOCATION"; mode: LocationMode }
| { type: "SET_VIEW"; mode: ViewMode } | { type: "SET_VIEW"; mode: ViewMode }
| { type: "SET_TRAINER"; value: string | null } | { type: "SET_TRAINER"; value: string | null }
| { type: "SET_TYPE"; value: string | null } | { type: "TOGGLE_TYPE"; value: string }
| { type: "SET_STATUS"; value: StatusFilter } | { type: "TOGGLE_STATUS"; value: StatusTag }
| { type: "SET_TIME"; value: TimeFilter } | { type: "SET_TIME"; value: TimeFilter }
| { type: "TOGGLE_DAY"; day: string } | { type: "TOGGLE_DAY"; day: string }
| { type: "SET_BOOKING"; value: string | null } | { type: "SET_BOOKING"; value: string | null }
@@ -42,8 +42,8 @@ const initialState: ScheduleState = {
locationMode: "all", locationMode: "all",
viewMode: "groups", viewMode: "groups",
filterTrainer: null, filterTrainer: null,
filterType: null, filterTypes: new Set(),
filterStatus: "all", filterStatusSet: new Set(),
filterTime: "all", filterTime: "all",
filterDaySet: new Set(), filterDaySet: new Set(),
bookingGroup: null, bookingGroup: null,
@@ -57,10 +57,18 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
return { ...state, viewMode: action.mode }; return { ...state, viewMode: action.mode };
case "SET_TRAINER": case "SET_TRAINER":
return { ...state, filterTrainer: action.value }; return { ...state, filterTrainer: action.value };
case "SET_TYPE": case "TOGGLE_TYPE": {
return { ...state, filterType: action.value }; const next = new Set(state.filterTypes);
case "SET_STATUS": if (next.has(action.value)) next.delete(action.value);
return { ...state, filterStatus: action.value }; else next.add(action.value);
return { ...state, filterTypes: next };
}
case "TOGGLE_STATUS": {
const next = new Set(state.filterStatusSet);
if (next.has(action.value)) next.delete(action.value);
else next.add(action.value);
return { ...state, filterStatusSet: next };
}
case "SET_TIME": case "SET_TIME":
return { ...state, filterTime: action.value }; return { ...state, filterTime: action.value };
case "TOGGLE_DAY": { case "TOGGLE_DAY": {
@@ -72,7 +80,7 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
case "SET_BOOKING": case "SET_BOOKING":
return { ...state, bookingGroup: action.value }; return { ...state, bookingGroup: action.value };
case "CLEAR_FILTERS": case "CLEAR_FILTERS":
return { ...state, filterTrainer: null, filterType: null, filterStatus: "all", filterTime: "all", filterDaySet: new Set() }; return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterTime: "all", filterDaySet: new Set() };
} }
} }
@@ -84,7 +92,7 @@ interface ScheduleProps {
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) { export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
const [state, dispatch] = useReducer(scheduleReducer, initialState); const [state, dispatch] = useReducer(scheduleReducer, initialState);
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state; const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterTime, filterDaySet, bookingGroup } = state;
const isAllMode = locationMode === "all"; const isAllMode = locationMode === "all";
@@ -94,8 +102,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
}, []); }, []);
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []); const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
const setFilterType = useCallback((value: string | null) => dispatch({ type: "SET_TYPE", value }), []); const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
const setFilterStatus = useCallback((value: StatusFilter) => dispatch({ type: "SET_STATUS", value }), []); const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []); const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
const setFilterTrainerFromCard = useCallback((trainer: string | null) => { const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
@@ -103,9 +111,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
if (trainer) scrollToSchedule(); if (trainer) scrollToSchedule();
}, [scrollToSchedule]); }, [scrollToSchedule]);
const setFilterTypeFromCard = useCallback((type: string | null) => { const toggleFilterTypeFromCard = useCallback((type: string) => {
dispatch({ type: "SET_TYPE", value: type }); dispatch({ type: "TOGGLE_TYPE", value: type });
if (type) scrollToSchedule(); scrollToSchedule();
}, [scrollToSchedule]); }, [scrollToSchedule]);
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]); const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
@@ -186,7 +194,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
: null; : null;
const filteredDays: ScheduleDayMerged[] = useMemo(() => { const filteredDays: ScheduleDayMerged[] = useMemo(() => {
const noFilter = !filterTrainer && !filterType && filterStatus === "all" && filterTime === "all" && filterDaySet.size === 0; const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && filterTime === "all" && filterDaySet.size === 0;
if (noFilter) return activeDays; if (noFilter) return activeDays;
// First filter by day names if any selected // First filter by day names if any selected
@@ -200,10 +208,10 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
classes: day.classes.filter( classes: day.classes.filter(
(cls) => (cls) =>
(!filterTrainer || cls.trainer === filterTrainer) && (!filterTrainer || cls.trainer === filterTrainer) &&
(!filterType || cls.type === filterType) && (filterTypes.size === 0 || filterTypes.has(cls.type)) &&
(filterStatus === "all" || (filterStatusSet.size === 0 ||
(filterStatus === "hasSlots" && cls.hasSlots) || (filterStatusSet.has("hasSlots") && cls.hasSlots) ||
(filterStatus === "recruiting" && cls.recruiting)) && (filterStatusSet.has("recruiting") && cls.recruiting)) &&
(!activeTimeRange || (() => { (!activeTimeRange || (() => {
const m = startTimeMinutes(cls.time); const m = startTimeMinutes(cls.time);
return m >= activeTimeRange[0] && m < activeTimeRange[1]; return m >= activeTimeRange[0] && m < activeTimeRange[1];
@@ -211,9 +219,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
), ),
})) }))
.filter((day) => day.classes.length > 0); .filter((day) => day.classes.length > 0);
}, [activeDays, filterTrainer, filterType, filterStatus, filterTime, activeTimeRange, filterDaySet]); }, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterTime, activeTimeRange, filterDaySet]);
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0); const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterTime !== "all" || filterDaySet.size > 0);
function clearFilters() { function clearFilters() {
dispatch({ type: "CLEAR_FILTERS" }); dispatch({ type: "CLEAR_FILTERS" });
@@ -338,11 +346,11 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
types={types} types={types}
hasAnySlots={hasAnySlots} hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting} hasAnyRecruiting={hasAnyRecruiting}
filterType={filterType} filterTypes={filterTypes}
setFilterType={setFilterType} toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
filterStatus={filterStatus} filterStatusSet={filterStatusSet}
setFilterStatus={setFilterStatus} toggleFilterStatus={toggleFilterStatus}
filterTime={filterTime} filterTime={filterTime}
setFilterTime={setFilterTime} setFilterTime={setFilterTime}
availableDays={availableDays} availableDays={availableDays}
@@ -361,8 +369,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
<MobileSchedule <MobileSchedule
typeDots={typeDots} typeDots={typeDots}
filteredDays={filteredDays} filteredDays={filteredDays}
filterType={filterType} filterTypes={filterTypes}
setFilterType={setFilterTypeFromCard} toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard} setFilterTrainer={setFilterTrainerFromCard}
hasActiveFilter={hasActiveFilter} hasActiveFilter={hasActiveFilter}
@@ -382,7 +390,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
key={day.day} key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""} className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
> >
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterType={filterType} setFilterType={setFilterTypeFromCard} /> <DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
</div> </div>
))} ))}
@@ -400,8 +408,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
<GroupView <GroupView
typeDots={typeDots} typeDots={typeDots}
filteredDays={filteredDays} filteredDays={filteredDays}
filterType={filterType} filterTypes={filterTypes}
setFilterType={setFilterTypeFromCard} toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard} setFilterTrainer={setFilterTrainerFromCard}
showLocation={isAllMode} showLocation={isAllMode}

View File

@@ -17,17 +17,38 @@ export function Team({ data: team, schedule }: TeamProps) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
const openProfile = useCallback((index: number) => {
setActiveIndex(index);
setShowProfile(true);
history.pushState({ trainerProfile: true }, "");
}, []);
const closeProfile = useCallback(() => {
setShowProfile(false);
}, []);
const openTrainerByName = useCallback((name: string) => { const openTrainerByName = useCallback((name: string) => {
const idx = team.members.findIndex((m) => m.name === name); const idx = team.members.findIndex((m) => m.name === name);
if (idx >= 0) { if (idx >= 0) {
setActiveIndex(idx); openProfile(idx);
setShowProfile(true);
setTimeout(() => { setTimeout(() => {
const el = document.getElementById("team"); const el = document.getElementById("team");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50); }, 50);
} }
}, [team.members]); }, [team.members, openProfile]);
// Handle browser back button
useEffect(() => {
function onPopState(e: PopStateEvent) {
if (showProfile) {
e.preventDefault();
setShowProfile(false);
}
}
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [showProfile]);
useEffect(() => { useEffect(() => {
function handler(e: Event) { function handler(e: Event) {
@@ -75,14 +96,14 @@ export function Team({ data: team, schedule }: TeamProps) {
members={team.members} members={team.members}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={setActiveIndex} onSelect={setActiveIndex}
onOpenBio={() => setShowProfile(true)} onOpenBio={() => openProfile(activeIndex)}
/> />
</div> </div>
</> </>
) : ( ) : (
<TeamProfile <TeamProfile
member={team.members[activeIndex]} member={team.members[activeIndex]}
onBack={() => setShowProfile(false)} onBack={() => { history.back(); }}
schedule={schedule} schedule={schedule}
/> />
)} )}

View File

@@ -8,8 +8,8 @@ interface DayCardProps {
showLocation?: boolean; showLocation?: boolean;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
} }
function ClassRow({ function ClassRow({
@@ -17,15 +17,15 @@ function ClassRow({
typeDots, typeDots,
filterTrainer, filterTrainer,
setFilterTrainer, setFilterTrainer,
filterType, filterTypes,
setFilterType, toggleFilterType,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
}) { }) {
return ( return (
<div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}> <div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
@@ -58,12 +58,12 @@ function ClassRow({
</button> </button>
<div className="mt-2 flex items-center gap-2 flex-wrap"> <div className="mt-2 flex items-center gap-2 flex-wrap">
<button <button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)} onClick={() => toggleFilterType(cls.type)}
className="flex items-center gap-2 cursor-pointer active:opacity-60" className="flex items-center gap-2 cursor-pointer active:opacity-60"
> >
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} /> <span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-xs ${ <span className={`text-xs ${
filterType === cls.type filterTypes.has(cls.type)
? "text-gold underline underline-offset-2" ? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/40" : "text-neutral-500 dark:text-white/40"
}`}>{cls.type}</span> }`}>{cls.type}</span>
@@ -78,7 +78,7 @@ function ClassRow({
); );
} }
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterType, setFilterType }: DayCardProps) { export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterTypes, toggleFilterType }: DayCardProps) {
// Group classes by location when showLocation is true // Group classes by location when showLocation is true
const locationGroups = showLocation const locationGroups = showLocation
? Array.from( ? Array.from(
@@ -123,7 +123,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
</div> </div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{classes.map((cls, i) => ( {classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
))} ))}
</div> </div>
</div> </div>
@@ -133,7 +133,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
// Single location — no sub-headers // Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => ( {day.classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
))} ))}
</div> </div>
)} )}

View File

@@ -119,8 +119,8 @@ function groupByType(groups: ScheduleGroup[]): { type: string; groups: ScheduleG
interface GroupViewProps { interface GroupViewProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[]; filteredDays: ScheduleDayMerged[];
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
@@ -133,8 +133,8 @@ const WEEKDAY_NAMES = ["Воскресенье", "Понедельник", "Вт
export function GroupView({ export function GroupView({
typeDots, typeDots,
filteredDays, filteredDays,
filterType, filterTypes,
setFilterType, toggleFilterType,
filterTrainer, filterTrainer,
setFilterTrainer, setFilterTrainer,
showLocation, showLocation,
@@ -226,11 +226,11 @@ export function GroupView({
{/* Type name */} {/* Type name */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => setFilterType(filterType === type ? null : type)} onClick={() => toggleFilterType(type)}
className="flex items-center gap-2 cursor-pointer" className="flex items-center gap-2 cursor-pointer"
> >
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
<span className="text-sm font-semibold text-white/90">{type}</span> <span className={`text-sm font-semibold ${filterTypes.has(type) ? "text-gold underline underline-offset-2" : "text-white/90"}`}>{type}</span>
</button> </button>
{group.level && ( {group.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400"> <span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400">

View File

@@ -7,8 +7,8 @@ import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface MobileScheduleProps { interface MobileScheduleProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[]; filteredDays: ScheduleDayMerged[];
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
hasActiveFilter: boolean; hasActiveFilter: boolean;
@@ -19,16 +19,16 @@ interface MobileScheduleProps {
function ClassRow({ function ClassRow({
cls, cls,
typeDots, typeDots,
filterType, filterTypes,
setFilterType, toggleFilterType,
filterTrainer, filterTrainer,
setFilterTrainer, setFilterTrainer,
showLocation, showLocation,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
@@ -69,11 +69,11 @@ function ClassRow({
</div> </div>
<div className="mt-0.5 flex items-center gap-2"> <div className="mt-0.5 flex items-center gap-2">
<button <button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)} onClick={() => toggleFilterType(cls.type)}
className={`flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`} className={`flex items-center gap-1.5 active:opacity-60 ${filterTypes.has(cls.type) ? "opacity-100" : ""}`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span> <span className={`text-[11px] ${filterTypes.has(cls.type) ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
</button> </button>
{showLocation && cls.locationName && ( {showLocation && cls.locationName && (
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20"> <span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20">
@@ -90,8 +90,8 @@ function ClassRow({
export function MobileSchedule({ export function MobileSchedule({
typeDots, typeDots,
filteredDays, filteredDays,
filterType, filterTypes,
setFilterType, toggleFilterType,
filterTrainer, filterTrainer,
setFilterTrainer, setFilterTrainer,
hasActiveFilter, hasActiveFilter,
@@ -110,12 +110,12 @@ export function MobileSchedule({
{filterTrainer} {filterTrainer}
</span> </span>
)} )}
{filterType && ( {filterTypes.size > 0 && Array.from(filterTypes).map((type) => (
<span className="flex items-center gap-1"> <span key={type} className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[filterType] ?? "bg-white/30"}`} /> <span className={`h-1.5 w-1.5 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
{filterType} {type}
</span> </span>
)} ))}
</div> </div>
<button <button
onClick={clearFilters} onClick={clearFilters}
@@ -175,8 +175,8 @@ export function MobileSchedule({
key={i} key={i}
cls={cls} cls={cls}
typeDots={typeDots} typeDots={typeDots}
filterType={filterType} filterTypes={filterTypes}
setFilterType={setFilterType} toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer} setFilterTrainer={setFilterTrainer}
/> />
@@ -190,8 +190,8 @@ export function MobileSchedule({
key={i} key={i}
cls={cls} cls={cls}
typeDots={typeDots} typeDots={typeDots}
filterType={filterType} filterTypes={filterTypes}
setFilterType={setFilterType} toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer} setFilterTrainer={setFilterTrainer}
/> />

View File

@@ -7,7 +7,7 @@ import {
pillActive, pillActive,
pillInactive, pillInactive,
TIME_PRESETS, TIME_PRESETS,
type StatusFilter, type StatusTag,
type TimeFilter, type TimeFilter,
} from "./constants"; } from "./constants";
@@ -16,11 +16,11 @@ interface ScheduleFiltersProps {
types: string[]; types: string[];
hasAnySlots: boolean; hasAnySlots: boolean;
hasAnyRecruiting: boolean; hasAnyRecruiting: boolean;
filterType: string | null; filterTypes: Set<string>;
setFilterType: (type: string | null) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainer: string | null;
filterStatus: StatusFilter; filterStatusSet: Set<StatusTag>;
setFilterStatus: (status: StatusFilter) => void; toggleFilterStatus: (status: StatusTag) => void;
filterTime: TimeFilter; filterTime: TimeFilter;
setFilterTime: (time: TimeFilter) => void; setFilterTime: (time: TimeFilter) => void;
availableDays: { day: string; dayShort: string }[]; availableDays: { day: string; dayShort: string }[];
@@ -35,11 +35,11 @@ export function ScheduleFilters({
types, types,
hasAnySlots, hasAnySlots,
hasAnyRecruiting, hasAnyRecruiting,
filterType, filterTypes,
setFilterType, toggleFilterType,
filterTrainer, filterTrainer,
filterStatus, filterStatusSet,
setFilterStatus, toggleFilterStatus,
filterTime, filterTime,
setFilterTime, setFilterTime,
availableDays, availableDays,
@@ -59,8 +59,8 @@ export function ScheduleFilters({
{types.map((type) => ( {types.map((type) => (
<button <button
key={type} key={type}
onClick={() => setFilterType(filterType === type ? null : type)} onClick={() => toggleFilterType(type)}
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`} className={`${pillBase} ${filterTypes.has(type) ? pillActive : pillInactive}`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
{type} {type}
@@ -73,8 +73,8 @@ export function ScheduleFilters({
{/* Status filters */} {/* Status filters */}
{hasAnySlots && ( {hasAnySlots && (
<button <button
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")} onClick={() => toggleFilterStatus("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}`} className={`${pillBase} ${filterStatusSet.has("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" /> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
Есть места Есть места
@@ -82,8 +82,8 @@ export function ScheduleFilters({
)} )}
{hasAnyRecruiting && ( {hasAnyRecruiting && (
<button <button
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")} onClick={() => toggleFilterStatus("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}`} className={`${pillBase} ${filterStatusSet.has("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" /> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
Набор Набор

View File

@@ -62,6 +62,8 @@ export function buildTypeDots(
return map; return map;
} }
export type StatusTag = "hasSlots" | "recruiting";
/** @deprecated Use Set<StatusTag> instead */
export type StatusFilter = "all" | "hasSlots" | "recruiting"; export type StatusFilter = "all" | "hasSlots" | "recruiting";
export type TimeFilter = "all" | "morning" | "afternoon" | "evening"; export type TimeFilter = "all" | "morning" | "afternoon" | "evening";

View File

@@ -41,13 +41,14 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
useEffect(() => { useEffect(() => {
if (item) { if (item) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else { } else {
document.body.style.overflow = ""; document.body.style.overflow = "";
document.body.style.paddingRight = "";
} }
return () => { return () => { document.body.style.overflow = ""; document.body.style.paddingRight = ""; };
document.body.style.overflow = "";
};
}, [item]); }, [item]);
if (!item) return null; if (!item) return null;

View File

@@ -74,9 +74,15 @@ export function SignupModal({
}, [open, onClose]); }, [open, onClose]);
useEffect(() => { useEffect(() => {
if (open) document.body.style.overflow = "hidden"; if (open) {
else document.body.style.overflow = ""; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
return () => { document.body.style.overflow = ""; }; document.body.style.overflow = "hidden";
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
document.body.style.overflow = "";
document.body.style.paddingRight = "";
}
return () => { document.body.style.overflow = ""; document.body.style.paddingRight = ""; };
}, [open]); }, [open]);
const handleSubmit = useCallback(async (e: React.FormEvent) => { const handleSubmit = useCallback(async (e: React.FormEvent) => {