feat: unified badges, filter UI overhaul, trainer bio link

- ScheduleBadge: shared gold badge component for all status/level tags
- Replace hardcoded emerald/sky/rose badge colors with unified gold style
- DayCard, GroupCard, MobileSchedule all use ScheduleBadge
- Location tag moved before status badges, gold styling
- GroupCard: flex-col with mt-auto button alignment
- TeamProfile: pass hasSlots/status to GroupCard

Filter modal redesign:
- Status: horizontal Airbnb-style toggle cards with ? info tips
- Experience: vertical radio list with gold dots
- When: days + time inputs on single row
- Click-to-toggle InfoTip replacing hover tooltips
- Gold speech bubble tooltip design

Schedule cards:
- Trainer name click opens bio instead of filtering
- DayCard location header: gold background, no duplicate address
This commit is contained in:
2026-03-29 17:04:40 +03:00
parent bdeedcfcc8
commit 024424c578
7 changed files with 155 additions and 162 deletions
+13 -24
View File
@@ -1,5 +1,6 @@
import { Clock, User, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface DayCardProps {
@@ -34,25 +35,15 @@ function ClassRow({
<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>
)}
{cls.hasSlots && <ScheduleBadge>есть места</ScheduleBadge>}
{cls.recruiting && <ScheduleBadge>набор</ScheduleBadge>}
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
<span className="shrink-0 rounded-full bg-gold/15 border border-gold/25 px-2 py-0.5 text-[10px] font-semibold text-gold">
{cls.status}
</span>
<ScheduleBadge>{cls.status}</ScheduleBadge>
)}
</div>
<button
onClick={() => toggleFilterTrainer(cls.trainer)}
className="mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 text-neutral-800 dark:text-white/80"
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }))}
className="mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 text-neutral-800 dark:text-white/80 hover:text-gold transition-colors"
>
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
{cls.trainer}
@@ -65,11 +56,7 @@ function ClassRow({
<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>
</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>
)}
{cls.level && <ScheduleBadge>{cls.level}</ScheduleBadge>}
</div>
</div>
);
@@ -111,11 +98,13 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
{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-gold/60" />
<span className="text-[11px] font-medium text-neutral-600 dark:text-white/50">
<div className={`flex items-center gap-1.5 px-5 py-2 bg-gold/10 ${gi > 0 ? "border-t border-gold/10" : ""}`}>
<MapPin size={11} className="shrink-0 text-gold" />
<span className="text-[11px] font-medium text-white">
{locName}
{address && <span className="text-neutral-400 dark:text-white/30"> · {shortAddress(address)}</span>}
{address && shortAddress(address) !== locName && (
<span className="text-white/50"> · {shortAddress(address)}</span>
)}
</span>
</div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
@@ -195,13 +195,13 @@ export function GroupView({
</div>
)}
</button>
{/* Name — clicks to filter */}
{/* Name — clicks to open bio */}
<button
onClick={() => toggleFilterTrainer(trainer)}
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }))}
className="cursor-pointer group"
>
<span className={`text-base font-semibold transition-colors ${
isActive ? "text-gold" : "text-white/90 group-hover:text-white"
isActive ? "text-gold" : "text-white/90 group-hover:text-gold"
}`}>
{trainer}
</span>
@@ -2,6 +2,7 @@
import { User, X, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface MobileScheduleProps {
@@ -46,31 +47,17 @@ function ClassRow({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => toggleFilterTrainer(cls.trainer)}
className="truncate text-sm font-medium text-left active:opacity-60 text-neutral-800 dark:text-white/80"
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }))}
className="truncate text-sm font-medium text-left active:opacity-60 text-neutral-800 dark:text-white/80 hover:text-gold transition-colors"
>
{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.hasSlots && <ScheduleBadge size="sm">места</ScheduleBadge>}
{cls.recruiting && <ScheduleBadge size="sm">набор</ScheduleBadge>}
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
<span className="shrink-0 rounded-full bg-gold/15 border border-gold/25 px-1.5 py-px text-[9px] font-semibold text-gold">
{cls.status}
</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>
<ScheduleBadge size="sm">{cls.status}</ScheduleBadge>
)}
{cls.level && <ScheduleBadge size="sm">{cls.level}</ScheduleBadge>}
</div>
<div className="mt-0.5 flex items-center gap-2">
<button
@@ -125,7 +125,7 @@ export function ScheduleFilters({
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-4 space-y-5">
{/* Class types — gold border, white text; gold bg when active */}
<FilterSection title="Направления">
<div className="flex flex-wrap gap-2">
@@ -158,135 +158,100 @@ export function ScheduleFilters({
{/* Status — gold tags */}
{availableStatuses.length > 0 && (
<FilterSection title="Статус">
{/* Desktop — card layout */}
<div className="hidden sm:block space-y-2">
<div className="flex gap-2">
{availableStatuses.map((statusKey) => {
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
const label = cfg?.label || statusKey;
const desc = cfg?.description;
const active = filterStatusSet.has(statusKey);
return (
<button
key={statusKey}
onClick={() => toggleFilterStatus(statusKey)}
className={`flex items-center gap-3 w-full rounded-xl p-3 border-l-[3px] transition-all cursor-pointer ${
active
? "border-l-gold bg-gold/10"
: "border-l-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors ${
active ? "bg-gold" : "border border-white/20"
}`}>
{active && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 6L5 8.5L9.5 3.5" stroke="black" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
)}
</span>
<span className={`text-sm font-medium ${active ? "text-white" : "text-neutral-300"}`}>
<div key={statusKey} className="flex-1 relative">
<button
onClick={() => toggleFilterStatus(statusKey)}
className={`w-full rounded-xl px-3 py-3 text-center text-xs font-semibold transition-all cursor-pointer border ${
active
? "border-gold bg-gold/10 text-white"
: "border-white/[0.08] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
>
{label}
</span>
</button>
{desc && (
<span className="group relative ml-auto" onClick={(e) => e.stopPropagation()}>
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
<span className="absolute right-0 bottom-full mb-2 z-10 max-w-[200px] rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" style={{ backgroundColor: "#1a1a1a" }}>
{desc}
</span>
<span className="absolute -top-1.5 -right-1.5 z-10">
<InfoTip text={desc} />
</span>
)}
</button>
);
})}
</div>
{/* Mobile — compact pills */}
<div className="flex flex-wrap gap-2 sm:hidden">
{availableStatuses.map((statusKey) => {
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
const label = cfg?.label || statusKey;
const active = filterStatusSet.has(statusKey);
return (
<button
key={statusKey}
onClick={() => toggleFilterStatus(statusKey)}
className={`rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer border ${
active
? "border-gold bg-gold/10 text-white"
: "border-white/[0.06] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15]"
}`}
>
{label}
</button>
</div>
);
})}
</div>
</FilterSection>
)}
{/* Level — radio cards */}
{/* Level — radio list */}
{levels.length > 0 && (
<FilterSection title="Опыт">
<div className="flex flex-wrap gap-2">
<div className="space-y-1">
{levels.map((level) => {
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
const active = filterLevel === level;
return (
<span key={level} className="relative inline-flex items-center gap-1">
<button
onClick={() => setFilterLevel(active ? null : level)}
className={`rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer border ${
active
? "border-gold bg-gold/10 text-white"
: "border-white/[0.06] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
>
<button
key={level}
onClick={() => setFilterLevel(active ? null : level)}
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
active
? "bg-gold/10"
: "hover:bg-white/[0.03]"
}`}
>
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
active ? "border-gold" : "border-white/20"
}`}>
{active && <span className="h-2 w-2 rounded-full bg-gold" />}
</span>
<span className={`text-xs font-medium ${active ? "text-white" : "text-neutral-400"}`}>
{level}
</button>
{desc && (
<span className="group relative">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
<span className="absolute right-0 bottom-full mb-2 z-10 max-w-[200px] rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" style={{ backgroundColor: "#1a1a1a" }}>
{desc}
</span>
</span>
)}
</span>
</span>
{desc && <InfoTip text={desc} />}
</button>
);
})}
</div>
</FilterSection>
)}
{/* When — days + time combined */}
{/* When — days + time inline */}
<FilterSection title="Когда">
<div className="flex flex-wrap gap-1.5">
{availableDays.map(({ day, dayShort }) => (
<button
key={day}
onClick={() => toggleDay(day)}
className={`flex items-center justify-center rounded-lg w-9 h-9 text-[11px] font-semibold transition-all cursor-pointer ${
filterDaySet.has(day)
? "bg-gold text-black"
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
}`}
>
{dayShort}
</button>
))}
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex items-center gap-2">
<div className="flex gap-1">
{availableDays.map(({ day, dayShort }) => (
<button
key={day}
onClick={() => toggleDay(day)}
className={`flex items-center justify-center rounded-md w-8 h-8 text-[10px] font-semibold transition-all cursor-pointer ${
filterDaySet.has(day)
? "bg-gold text-black"
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
}`}
>
{dayShort}
</button>
))}
</div>
<span className="h-6 w-px bg-white/[0.08] shrink-0" />
<input
type="time"
value={filterTime.from}
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
placeholder="С"
className="flex-1 rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1.5 text-xs text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
/>
<span className="text-neutral-500 text-xs"></span>
<span className="text-neutral-500 text-[10px]"></span>
<input
type="time"
value={filterTime.to}
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
placeholder="До"
className="flex-1 rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1.5 text-xs text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
/>
</div>
</FilterSection>
@@ -355,6 +320,44 @@ function FilterSection({ title, hint, children }: { title: string; hint?: string
}
function InfoTip({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return (
<span ref={ref} className="relative" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => setOpen(!open)}
className={`flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors cursor-pointer ${
open ? "border-gold/40 text-gold" : "border-white/15 text-neutral-500 hover:text-white hover:border-white/30"
}`}
>
?
</button>
{open && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-20 w-52">
{/* Tail behind body — always centered */}
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px] w-2.5 h-2.5 rotate-45 bg-gold" />
{/* Body on top */}
<div className="relative z-10 rounded-lg bg-gold px-3 py-2 text-[11px] leading-relaxed text-black shadow-lg">
{text}
</div>
</div>
)}
</span>
);
}
function TrainerMultiSelect({
trainers,
selected,
+7 -1
View File
@@ -30,7 +30,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
// Extract trainer's groups from schedule using groupId
const uniqueGroups = useMemo(() => {
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean; hasSlots?: boolean; status?: string }>();
schedule?.forEach(location => {
location.days.forEach(day => {
day.classes
@@ -44,6 +44,8 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
if (c.level && !existing.level) existing.level = c.level;
if (c.recruiting) existing.recruiting = true;
if (c.hasSlots) existing.hasSlots = true;
if (c.status && !existing.status) existing.status = c.status;
} else {
groupMap.set(key, {
type: c.type,
@@ -52,6 +54,8 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
level: c.level,
recruiting: c.recruiting,
hasSlots: c.hasSlots,
status: c.status,
});
}
});
@@ -183,6 +187,8 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
type={g.type}
level={g.level}
recruiting={g.recruiting}
hasSlots={g.hasSlots}
status={g.status}
address={g.address}
location={g.location}
merged={g.merged}
+11 -23
View File
@@ -1,5 +1,6 @@
import { MapPin } from "lucide-react";
import { shortAddress } from "@/components/sections/schedule/constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
export interface GroupCardSlot {
days: string[];
@@ -50,14 +51,11 @@ export function GroupCard({
const typeCls = compact ? "text-xs" : "text-sm";
const dayPad = compact ? "px-1.5 py-px text-[10px] min-w-[40px]" : "px-2 py-0.5 text-[11px] min-w-[52px]";
const timeCls = compact ? "text-xs" : "text-sm font-medium";
const badgeSize = compact ? "px-2 py-0.5 text-[9px]" : "px-2.5 py-0.5 text-[10px]";
const locSize = compact ? "px-2 py-0.5 text-[9px]" : "px-2.5 py-0.5 text-[10px]";
const locIcon = compact ? 8 : 9;
const levelBadge = level ? (
<span className={`shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 ${badgeSize} font-semibold text-rose-400`}>
{level}
</span>
<ScheduleBadge size={compact ? "sm" : "md"}>{level}</ScheduleBadge>
) : null;
const typeContent = (
@@ -69,7 +67,7 @@ export function GroupCard({
);
return (
<div className="space-y-1.5">
<div className="flex flex-col flex-1 gap-1.5">
{/* Type + level + status badges + extras */}
<div className="flex items-center gap-1.5 flex-wrap">
{onTypeClick ? (
@@ -79,27 +77,17 @@ export function GroupCard({
) : (
<span className="inline-flex items-center gap-1.5">{typeContent}</span>
)}
{hasSlots && (
<span className={`rounded-full bg-emerald-500/15 border border-emerald-500/25 ${badgeSize} font-semibold text-emerald-400`}>
есть места
</span>
)}
{recruiting && (
<span className={`rounded-full bg-sky-500/15 border border-sky-500/25 ${badgeSize} font-semibold text-sky-400`}>
набор
</span>
)}
{status && status !== "hasSlots" && status !== "recruiting" && (
<span className={`rounded-full bg-gold/15 border border-gold/25 ${badgeSize} font-semibold text-gold`}>
{statusLabel || status}
</span>
)}
{showLocation && (address || location) && (
<span className={`inline-flex items-center gap-1 rounded-full bg-white/[0.04] border border-white/[0.08] ${locSize} font-medium text-white/40`}>
<MapPin size={locIcon} />
<span className={`inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 ${locSize} font-medium text-white`}>
<MapPin size={locIcon} className="text-gold" />
{shortAddress(address || location || "")}
</span>
)}
{hasSlots && <ScheduleBadge size={compact ? "sm" : "md"}>есть места</ScheduleBadge>}
{recruiting && <ScheduleBadge size={compact ? "sm" : "md"}>набор</ScheduleBadge>}
{status && status !== "hasSlots" && status !== "recruiting" && (
<ScheduleBadge size={compact ? "sm" : "md"}>{statusLabel || status}</ScheduleBadge>
)}
{extraBadges}
</div>
@@ -122,7 +110,7 @@ export function GroupCard({
compact ? (
<button
onClick={onBook}
className="w-full rounded-lg bg-gold/15 border border-gold/25 py-1.5 text-[11px] font-semibold text-gold hover:bg-gold/25 transition-colors cursor-pointer"
className="w-full mt-auto rounded-lg bg-gold/15 border border-gold/25 py-1.5 text-[11px] font-semibold text-gold hover:bg-gold/25 transition-colors cursor-pointer"
>
Записаться
</button>
+20
View File
@@ -0,0 +1,20 @@
interface ScheduleBadgeProps {
children: React.ReactNode;
size?: "sm" | "md";
}
/**
* Unified badge for schedule status and level tags.
* Single gold style used consistently across DayCard, GroupCard, MobileSchedule.
*/
export function ScheduleBadge({ children, size = "md" }: ScheduleBadgeProps) {
const sizeClass = size === "sm"
? "px-1.5 py-px text-[9px]"
: "px-2.5 py-0.5 text-[10px]";
return (
<span className={`shrink-0 rounded-full bg-gold/15 border border-gold/25 ${sizeClass} font-semibold text-gold`}>
{children}
</span>
);
}