- BookingModal now accepts optional groupInfo for pre-filled message - Trainer profile: each group card has Записаться button - Schedule group view: each group card has Записаться button - Message includes class type, trainer, days, and time Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
7.9 KiB
TypeScript
197 lines
7.9 KiB
TypeScript
"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;
|
||
onBook?: (groupInfo: string) => void;
|
||
}
|
||
|
||
export function GroupView({
|
||
typeDots,
|
||
filteredDays,
|
||
filterType,
|
||
setFilterType,
|
||
filterTrainer,
|
||
setFilterTrainer,
|
||
showLocation,
|
||
onBook,
|
||
}: 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>
|
||
{onBook && (
|
||
<button
|
||
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
||
className="w-full mt-3 rounded-xl bg-gold/15 border border-gold/25 py-2 text-xs font-semibold text-gold-dark dark:text-gold hover:bg-gold/25 transition-colors cursor-pointer"
|
||
>
|
||
Записаться
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|