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:
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 |
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} мест
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
Набор
|
Набор
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user