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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user