feat: upgrade schedule with cross-location views, day/time filters, and clickable trainers
- Add "Все студии" tab merging all locations by weekday with location sub-headers - Location tabs show hall name + address subtitle for clarity - Add day multi-select and time-of-day preset filters (Утро/День/Вечер) behind collapsible "Когда" button - Make trainer and type names clickable in day cards for inline filtering - Add group view clustering classes by trainer+type+location - Remove trainer dropdown from filter bar — filter by clicking names in schedule - Add searchable icon picker and lucide-react icon rendering for classes admin/section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,98 @@
|
||||
import { Clock, User } from "lucide-react";
|
||||
import type { ScheduleDay } from "@/types/content";
|
||||
import { Clock, User, MapPin } from "lucide-react";
|
||||
import { shortAddress } from "./constants";
|
||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||
|
||||
interface DayCardProps {
|
||||
day: ScheduleDayMerged;
|
||||
typeDots: Record<string, string>;
|
||||
showLocation?: boolean;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
}
|
||||
|
||||
function ClassRow({
|
||||
cls,
|
||||
typeDots,
|
||||
filterTrainer,
|
||||
setFilterTrainer,
|
||||
filterType,
|
||||
setFilterType,
|
||||
}: {
|
||||
cls: ScheduleClassWithLocation;
|
||||
typeDots: Record<string, string>;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div 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>
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
||||
className={`mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 ${
|
||||
filterTrainer === cls.trainer
|
||||
? "text-gold underline underline-offset-2"
|
||||
: "text-neutral-800 dark:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||
{cls.trainer}
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
||||
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={`text-xs ${
|
||||
filterType === cls.type
|
||||
? "text-gold underline underline-offset-2"
|
||||
: "text-neutral-500 dark:text-white/40"
|
||||
}`}>{cls.type}</span>
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterType, setFilterType }: DayCardProps) {
|
||||
// Group classes by location when showLocation is true
|
||||
const locationGroups = showLocation
|
||||
? Array.from(
|
||||
day.classes.reduce((map, cls) => {
|
||||
const loc = cls.locationName ?? "";
|
||||
if (!map.has(loc)) {
|
||||
map.set(loc, { address: cls.locationAddress, classes: [] });
|
||||
}
|
||||
map.get(loc)!.classes.push(cls);
|
||||
return map;
|
||||
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
|
||||
)
|
||||
: null;
|
||||
|
||||
export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record<string, string> }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
||||
{/* Day header */}
|
||||
@@ -17,43 +108,35 @@ export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record<
|
||||
</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>
|
||||
{locationGroups ? (
|
||||
// Split by location
|
||||
<div>
|
||||
{locationGroups.map(([locName, { address, classes }], gi) => (
|
||||
<div key={locName}>
|
||||
{/* Location sub-header */}
|
||||
<div className={`flex items-center gap-1.5 px-5 py-2 bg-neutral-100/60 dark:bg-white/[0.03] ${gi > 0 ? "border-t border-neutral-200 dark:border-white/[0.06]" : ""}`}>
|
||||
<MapPin size={11} className="shrink-0 text-neutral-400 dark:text-white/25" />
|
||||
<span className="text-[11px] font-medium text-neutral-400 dark:text-white/30">
|
||||
{locName}
|
||||
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
|
||||
</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 ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||
{classes.map((cls, i) => (
|
||||
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
|
||||
))}
|
||||
</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>
|
||||
) : (
|
||||
// Single location — no sub-headers
|
||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||
{day.classes.map((cls, i) => (
|
||||
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
186
src/components/sections/schedule/GroupView.tsx
Normal file
186
src/components/sections/schedule/GroupView.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { User, Clock, CalendarDays, MapPin } from "lucide-react";
|
||||
import { shortAddress } from "./constants";
|
||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||
|
||||
interface ScheduleGroup {
|
||||
trainer: string;
|
||||
type: string;
|
||||
level?: string;
|
||||
hasSlots: boolean;
|
||||
recruiting: boolean;
|
||||
location?: string;
|
||||
locationAddress?: string;
|
||||
slots: { day: string; dayShort: string; time: string }[];
|
||||
}
|
||||
|
||||
function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
|
||||
const map = new Map<string, ScheduleGroup>();
|
||||
|
||||
for (const day of days) {
|
||||
for (const cls of day.classes as ScheduleClassWithLocation[]) {
|
||||
// Include location in key so same trainer+type at different locations = separate groups
|
||||
const locPart = cls.locationName ?? "";
|
||||
const key = `${cls.trainer}||${cls.type}||${locPart}`;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
|
||||
if (cls.hasSlots) existing.hasSlots = true;
|
||||
if (cls.recruiting) existing.recruiting = true;
|
||||
if (cls.level && !existing.level) existing.level = cls.level;
|
||||
} else {
|
||||
map.set(key, {
|
||||
trainer: cls.trainer,
|
||||
type: cls.type,
|
||||
level: cls.level,
|
||||
hasSlots: !!cls.hasSlots,
|
||||
recruiting: !!cls.recruiting,
|
||||
location: cls.locationName,
|
||||
locationAddress: cls.locationAddress,
|
||||
slots: [{ day: day.day, dayShort: day.dayShort, time: cls.time }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
interface GroupViewProps {
|
||||
typeDots: Record<string, string>;
|
||||
filteredDays: ScheduleDayMerged[];
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
showLocation?: boolean;
|
||||
}
|
||||
|
||||
export function GroupView({
|
||||
typeDots,
|
||||
filteredDays,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTrainer,
|
||||
setFilterTrainer,
|
||||
showLocation,
|
||||
}: GroupViewProps) {
|
||||
const groups = buildGroups(filteredDays);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
Нет занятий по выбранным фильтрам
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6 lg:px-8 xl:px-6">
|
||||
{groups.map((group) => {
|
||||
const dotColor = typeDots[group.type] ?? "bg-white/30";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${group.trainer}||${group.type}||${group.location ?? ""}`}
|
||||
className={`rounded-2xl border overflow-hidden transition-colors ${
|
||||
group.hasSlots
|
||||
? "border-emerald-500/25 bg-white dark:border-emerald-500/15 dark:bg-[#0a0a0a]"
|
||||
: group.recruiting
|
||||
? "border-sky-500/25 bg-white dark:border-sky-500/15 dark:bg-[#0a0a0a]"
|
||||
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`px-5 py-4 border-b ${
|
||||
group.hasSlots
|
||||
? "border-emerald-500/15 bg-emerald-500/5 dark:border-emerald-500/10 dark:bg-emerald-500/[0.03]"
|
||||
: group.recruiting
|
||||
? "border-sky-500/15 bg-sky-500/5 dark:border-sky-500/10 dark:bg-sky-500/[0.03]"
|
||||
: "border-neutral-100 bg-neutral-50 dark:border-white/[0.04] dark:bg-white/[0.02]"
|
||||
}`}>
|
||||
{/* Type + badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === group.type ? null : group.type)}
|
||||
className={`flex items-center gap-2 active:opacity-60 ${
|
||||
filterType === group.type ? "opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
|
||||
{group.type}
|
||||
</span>
|
||||
</button>
|
||||
{group.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">
|
||||
{group.level}
|
||||
</span>
|
||||
)}
|
||||
{group.hasSlots && (
|
||||
<span className="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>
|
||||
)}
|
||||
{group.recruiting && (
|
||||
<span className="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>
|
||||
|
||||
{/* Trainer */}
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === group.trainer ? null : group.trainer)}
|
||||
className={`mt-2 flex items-center gap-1.5 text-sm active:opacity-60 ${
|
||||
filterTrainer === group.trainer
|
||||
? "text-gold underline underline-offset-2"
|
||||
: "text-neutral-500 dark:text-white/50"
|
||||
}`}
|
||||
>
|
||||
<User size={13} className="shrink-0" />
|
||||
{group.trainer}
|
||||
</button>
|
||||
|
||||
{/* Location badge — only in "all" mode */}
|
||||
{showLocation && group.location && (
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] text-neutral-400 dark:text-white/30">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
{group.location}
|
||||
{group.locationAddress && (
|
||||
<span className="text-neutral-300 dark:text-white/15"> · {shortAddress(group.locationAddress)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule slots */}
|
||||
<div className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5 text-[11px] font-medium text-neutral-400 dark:text-white/25 uppercase tracking-wider">
|
||||
<CalendarDays size={12} />
|
||||
Расписание
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{group.slots.map((slot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-lg bg-neutral-50 px-3 py-2 dark:bg-white/[0.03]"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-[10px] font-bold text-gold-dark dark:text-gold-light">
|
||||
{slot.dayShort}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-white/60">
|
||||
<Clock size={12} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||
<span className="font-medium tabular-nums">{slot.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { User, X } from "lucide-react";
|
||||
import type { ScheduleDay } from "@/types/content";
|
||||
import { User, X, MapPin } from "lucide-react";
|
||||
import { shortAddress } from "./constants";
|
||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||
|
||||
interface MobileScheduleProps {
|
||||
typeDots: Record<string, string>;
|
||||
filteredDays: ScheduleDay[];
|
||||
filteredDays: ScheduleDayMerged[];
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
hasActiveFilter: boolean;
|
||||
clearFilters: () => void;
|
||||
showLocation?: boolean;
|
||||
}
|
||||
|
||||
function ClassRow({
|
||||
cls,
|
||||
typeDots,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTrainer,
|
||||
setFilterTrainer,
|
||||
showLocation,
|
||||
}: {
|
||||
cls: ScheduleClassWithLocation;
|
||||
typeDots: Record<string, string>;
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
showLocation?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
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-gold 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>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
||||
className={`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 ${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>
|
||||
</button>
|
||||
{showLocation && cls.locationName && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20">
|
||||
<MapPin size={8} className="shrink-0" />
|
||||
{cls.locationName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSchedule({
|
||||
@@ -23,6 +96,7 @@ export function MobileSchedule({
|
||||
setFilterTrainer,
|
||||
hasActiveFilter,
|
||||
clearFilters,
|
||||
showLocation,
|
||||
}: MobileScheduleProps) {
|
||||
return (
|
||||
<div className="mt-6 px-4 sm:hidden">
|
||||
@@ -55,68 +129,78 @@ export function MobileSchedule({
|
||||
|
||||
{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-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||
{day.dayShort}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
|
||||
{day.day}
|
||||
</span>
|
||||
</div>
|
||||
{filteredDays.map((day) => {
|
||||
// Group classes by location when showLocation is true
|
||||
const locationGroups = showLocation
|
||||
? Array.from(
|
||||
day.classes.reduce((map, cls) => {
|
||||
const loc = cls.locationName ?? "";
|
||||
if (!map.has(loc)) {
|
||||
map.set(loc, { address: cls.locationAddress, classes: [] });
|
||||
}
|
||||
map.get(loc)!.classes.push(cls);
|
||||
return map;
|
||||
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
|
||||
)
|
||||
: null;
|
||||
|
||||
{/* 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>
|
||||
return (
|
||||
<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-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||
{day.dayShort}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
|
||||
{day.day}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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-gold 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">
|
||||
места
|
||||
{/* Class rows */}
|
||||
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
|
||||
{locationGroups ? (
|
||||
// Split by location
|
||||
locationGroups.map(([locName, { address, classes }]) => (
|
||||
<div key={locName}>
|
||||
{/* Location sub-header */}
|
||||
<div className="ml-3 flex items-center gap-1 px-3 py-1.5">
|
||||
<MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" />
|
||||
<span className="text-[10px] font-medium text-neutral-400 dark:text-white/25">
|
||||
{locName}
|
||||
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
|
||||
</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>
|
||||
{classes.map((cls, i) => (
|
||||
<ClassRow
|
||||
key={i}
|
||||
cls={cls}
|
||||
typeDots={typeDots}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainer}
|
||||
/>
|
||||
))}
|
||||
</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 ${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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
// Single location — no sub-headers
|
||||
day.classes.map((cls, i) => (
|
||||
<ClassRow
|
||||
key={i}
|
||||
cls={cls}
|
||||
typeDots={typeDots}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainer}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { User, X, ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { User, X, ChevronDown, Clock, Calendar } from "lucide-react";
|
||||
import {
|
||||
pillBase,
|
||||
pillActive,
|
||||
pillInactive,
|
||||
TIME_PRESETS,
|
||||
type StatusFilter,
|
||||
type TimeFilter,
|
||||
} from "./constants";
|
||||
|
||||
interface ScheduleFiltersProps {
|
||||
typeDots: Record<string, string>;
|
||||
types: string[];
|
||||
trainers: string[];
|
||||
hasAnySlots: boolean;
|
||||
hasAnyRecruiting: boolean;
|
||||
filterType: string | null;
|
||||
setFilterType: (type: string | null) => void;
|
||||
filterTrainer: string | null;
|
||||
setFilterTrainer: (trainer: string | null) => void;
|
||||
filterStatus: StatusFilter;
|
||||
setFilterStatus: (status: StatusFilter) => void;
|
||||
showTrainers: boolean;
|
||||
setShowTrainers: (show: boolean) => void;
|
||||
filterTime: TimeFilter;
|
||||
setFilterTime: (time: TimeFilter) => void;
|
||||
availableDays: { day: string; dayShort: string }[];
|
||||
filterDaySet: Set<string>;
|
||||
toggleDay: (day: string) => void;
|
||||
hasActiveFilter: boolean;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
@@ -29,22 +33,27 @@ interface ScheduleFiltersProps {
|
||||
export function ScheduleFilters({
|
||||
typeDots,
|
||||
types,
|
||||
trainers,
|
||||
hasAnySlots,
|
||||
hasAnyRecruiting,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTrainer,
|
||||
setFilterTrainer,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
showTrainers,
|
||||
setShowTrainers,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
availableDays,
|
||||
filterDaySet,
|
||||
toggleDay,
|
||||
hasActiveFilter,
|
||||
clearFilters,
|
||||
}: ScheduleFiltersProps) {
|
||||
const [showWhen, setShowWhen] = useState(false);
|
||||
const hasTimeFilter = filterDaySet.size > 0 || filterTime !== "all";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Single row: type + status + when + active trainer indicator + clear */}
|
||||
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
|
||||
{/* Class types */}
|
||||
{types.map((type) => (
|
||||
@@ -84,16 +93,24 @@ export function ScheduleFilters({
|
||||
{/* Divider */}
|
||||
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
||||
|
||||
{/* Trainer dropdown toggle */}
|
||||
{/* When dropdown toggle */}
|
||||
<button
|
||||
onClick={() => setShowTrainers(!showTrainers)}
|
||||
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`}
|
||||
onClick={() => setShowWhen(!showWhen)}
|
||||
className={`${pillBase} ${hasTimeFilter ? pillActive : pillInactive}`}
|
||||
>
|
||||
<User size={11} />
|
||||
{filterTrainer ?? "Тренер"}
|
||||
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} />
|
||||
<Clock size={11} />
|
||||
Когда
|
||||
<ChevronDown size={10} className={`transition-transform duration-200 ${showWhen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Active trainer indicator (set by clicking trainer in cards) */}
|
||||
{filterTrainer && (
|
||||
<span className={`${pillBase} ${pillActive}`}>
|
||||
<User size={11} />
|
||||
{filterTrainer}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear */}
|
||||
{hasActiveFilter && (
|
||||
<button
|
||||
@@ -105,19 +122,29 @@ export function ScheduleFilters({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trainer pills — expandable */}
|
||||
{showTrainers && (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5">
|
||||
{trainers.map((trainer) => (
|
||||
{/* When panel — expandable: days + time presets */}
|
||||
{showWhen && (
|
||||
<div className="mt-2 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
|
||||
<Calendar size={11} className="text-neutral-400 dark:text-white/25" />
|
||||
{availableDays.map(({ day, dayShort }) => (
|
||||
<button
|
||||
key={trainer}
|
||||
onClick={() => {
|
||||
setFilterTrainer(filterTrainer === trainer ? null : trainer);
|
||||
setShowTrainers(false);
|
||||
}}
|
||||
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
|
||||
key={day}
|
||||
onClick={() => toggleDay(day)}
|
||||
className={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`}
|
||||
>
|
||||
{trainer}
|
||||
{dayShort}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
||||
|
||||
{TIME_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)}
|
||||
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,44 @@ export function buildTypeDots(
|
||||
}
|
||||
|
||||
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
||||
export type TimeFilter = "all" | "morning" | "afternoon" | "evening";
|
||||
|
||||
export const TIME_PRESETS: { value: TimeFilter; label: string; range: [number, number] }[] = [
|
||||
{ value: "morning", label: "Утро", range: [0, 12 * 60] },
|
||||
{ value: "afternoon", label: "День", range: [12 * 60, 18 * 60] },
|
||||
{ value: "evening", label: "Вечер", range: [18 * 60, 24 * 60] },
|
||||
];
|
||||
|
||||
/** Parse start time from "HH:MM–HH:MM" to minutes since midnight */
|
||||
export function startTimeMinutes(time: string): number {
|
||||
const start = time.split("–")[0]?.trim() ?? "";
|
||||
const [h, m] = start.split(":").map(Number);
|
||||
if (isNaN(h) || isNaN(m)) return 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Extended class type with location info for cross-location views */
|
||||
export interface ScheduleClassWithLocation {
|
||||
time: string;
|
||||
trainer: string;
|
||||
type: string;
|
||||
level?: string;
|
||||
hasSlots?: boolean;
|
||||
recruiting?: boolean;
|
||||
locationName?: string;
|
||||
locationAddress?: string;
|
||||
}
|
||||
|
||||
/** Strip "г. Минск, " prefix from address for compact display */
|
||||
export function shortAddress(address: string): string {
|
||||
return address.replace(/^г\.\s*Минск,?\s*/i, "").trim();
|
||||
}
|
||||
|
||||
export interface ScheduleDayMerged {
|
||||
day: string;
|
||||
dayShort: string;
|
||||
classes: ScheduleClassWithLocation[];
|
||||
}
|
||||
|
||||
export 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";
|
||||
|
||||
Reference in New Issue
Block a user