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:
@@ -1,5 +1,6 @@
|
|||||||
import { Clock, User, MapPin } from "lucide-react";
|
import { Clock, User, MapPin } from "lucide-react";
|
||||||
import { shortAddress } from "./constants";
|
import { shortAddress } from "./constants";
|
||||||
|
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
|
||||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||||
|
|
||||||
interface DayCardProps {
|
interface DayCardProps {
|
||||||
@@ -34,25 +35,15 @@ function ClassRow({
|
|||||||
<Clock size={13} />
|
<Clock size={13} />
|
||||||
<span className="font-semibold">{cls.time}</span>
|
<span className="font-semibold">{cls.time}</span>
|
||||||
</div>
|
</div>
|
||||||
{cls.hasSlots && (
|
{cls.hasSlots && <ScheduleBadge>есть места</ScheduleBadge>}
|
||||||
<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">
|
{cls.recruiting && <ScheduleBadge>набор</ScheduleBadge>}
|
||||||
есть места
|
|
||||||
</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.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
|
{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">
|
<ScheduleBadge>{cls.status}</ScheduleBadge>
|
||||||
{cls.status}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFilterTrainer(cls.trainer)}
|
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"
|
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" />
|
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||||
{cls.trainer}
|
{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={`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>
|
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||||
</button>
|
</button>
|
||||||
{cls.level && (
|
{cls.level && <ScheduleBadge>{cls.level}</ScheduleBadge>}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
@@ -111,11 +98,13 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
|
|||||||
{locationGroups.map(([locName, { address, classes }], gi) => (
|
{locationGroups.map(([locName, { address, classes }], gi) => (
|
||||||
<div key={locName}>
|
<div key={locName}>
|
||||||
{/* Location sub-header */}
|
{/* 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]" : ""}`}>
|
<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/60" />
|
<MapPin size={11} className="shrink-0 text-gold" />
|
||||||
<span className="text-[11px] font-medium text-neutral-600 dark:text-white/50">
|
<span className="text-[11px] font-medium text-white">
|
||||||
{locName}
|
{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>
|
</span>
|
||||||
</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]">
|
||||||
|
|||||||
@@ -195,13 +195,13 @@ export function GroupView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{/* Name — clicks to filter */}
|
{/* Name — clicks to open bio */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFilterTrainer(trainer)}
|
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }))}
|
||||||
className="cursor-pointer group"
|
className="cursor-pointer group"
|
||||||
>
|
>
|
||||||
<span className={`text-base font-semibold transition-colors ${
|
<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}
|
{trainer}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { User, X, MapPin } from "lucide-react";
|
import { User, X, MapPin } from "lucide-react";
|
||||||
import { shortAddress } from "./constants";
|
import { shortAddress } from "./constants";
|
||||||
|
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
|
||||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||||
|
|
||||||
interface MobileScheduleProps {
|
interface MobileScheduleProps {
|
||||||
@@ -46,31 +47,17 @@ function ClassRow({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFilterTrainer(cls.trainer)}
|
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"
|
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}
|
{cls.trainer}
|
||||||
</button>
|
</button>
|
||||||
{cls.hasSlots && (
|
{cls.hasSlots && <ScheduleBadge size="sm">места</ScheduleBadge>}
|
||||||
<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">
|
{cls.recruiting && <ScheduleBadge size="sm">набор</ScheduleBadge>}
|
||||||
места
|
|
||||||
</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.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
|
{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">
|
<ScheduleBadge size="sm">{cls.status}</ScheduleBadge>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
{cls.level && <ScheduleBadge size="sm">{cls.level}</ScheduleBadge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function ScheduleFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* 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 */}
|
{/* Class types — gold border, white text; gold bg when active */}
|
||||||
<FilterSection title="Направления">
|
<FilterSection title="Направления">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -158,135 +158,100 @@ export function ScheduleFilters({
|
|||||||
{/* Status — gold tags */}
|
{/* Status — gold tags */}
|
||||||
{availableStatuses.length > 0 && (
|
{availableStatuses.length > 0 && (
|
||||||
<FilterSection title="Статус">
|
<FilterSection title="Статус">
|
||||||
{/* Desktop — card layout */}
|
<div className="flex gap-2">
|
||||||
<div className="hidden sm:block space-y-2">
|
|
||||||
{availableStatuses.map((statusKey) => {
|
{availableStatuses.map((statusKey) => {
|
||||||
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
|
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
|
||||||
const label = cfg?.label || statusKey;
|
const label = cfg?.label || statusKey;
|
||||||
const desc = cfg?.description;
|
const desc = cfg?.description;
|
||||||
const active = filterStatusSet.has(statusKey);
|
const active = filterStatusSet.has(statusKey);
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={statusKey} className="flex-1 relative">
|
||||||
key={statusKey}
|
<button
|
||||||
onClick={() => toggleFilterStatus(statusKey)}
|
onClick={() => toggleFilterStatus(statusKey)}
|
||||||
className={`flex items-center gap-3 w-full rounded-xl p-3 border-l-[3px] transition-all cursor-pointer ${
|
className={`w-full rounded-xl px-3 py-3 text-center text-xs font-semibold transition-all cursor-pointer border ${
|
||||||
active
|
active
|
||||||
? "border-l-gold bg-gold/10"
|
? "border-gold bg-gold/10 text-white"
|
||||||
: "border-l-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
|
: "border-white/[0.08] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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"}`}>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</button>
|
||||||
{desc && (
|
{desc && (
|
||||||
<span className="group relative ml-auto" onClick={(e) => e.stopPropagation()}>
|
<span className="absolute -top-1.5 -right-1.5 z-10">
|
||||||
<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>
|
<InfoTip text={desc} />
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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>
|
</FilterSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Level — radio cards */}
|
{/* Level — radio list */}
|
||||||
{levels.length > 0 && (
|
{levels.length > 0 && (
|
||||||
<FilterSection title="Опыт">
|
<FilterSection title="Опыт">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-1">
|
||||||
{levels.map((level) => {
|
{levels.map((level) => {
|
||||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||||
const active = filterLevel === level;
|
const active = filterLevel === level;
|
||||||
return (
|
return (
|
||||||
<span key={level} className="relative inline-flex items-center gap-1">
|
<button
|
||||||
<button
|
key={level}
|
||||||
onClick={() => setFilterLevel(active ? null : level)}
|
onClick={() => setFilterLevel(active ? null : level)}
|
||||||
className={`rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer border ${
|
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
|
||||||
active
|
active
|
||||||
? "border-gold bg-gold/10 text-white"
|
? "bg-gold/10"
|
||||||
: "border-white/[0.06] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]"
|
: "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}
|
{level}
|
||||||
</button>
|
</span>
|
||||||
{desc && (
|
{desc && <InfoTip text={desc} />}
|
||||||
<span className="group relative">
|
</button>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</FilterSection>
|
</FilterSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* When — days + time combined */}
|
{/* When — days + time inline */}
|
||||||
<FilterSection title="Когда">
|
<FilterSection title="Когда">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
{availableDays.map(({ day, dayShort }) => (
|
<div className="flex gap-1">
|
||||||
<button
|
{availableDays.map(({ day, dayShort }) => (
|
||||||
key={day}
|
<button
|
||||||
onClick={() => toggleDay(day)}
|
key={day}
|
||||||
className={`flex items-center justify-center rounded-lg w-9 h-9 text-[11px] font-semibold transition-all cursor-pointer ${
|
onClick={() => toggleDay(day)}
|
||||||
filterDaySet.has(day)
|
className={`flex items-center justify-center rounded-md w-8 h-8 text-[10px] font-semibold transition-all cursor-pointer ${
|
||||||
? "bg-gold text-black"
|
filterDaySet.has(day)
|
||||||
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
|
? "bg-gold text-black"
|
||||||
}`}
|
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
|
||||||
>
|
}`}
|
||||||
{dayShort}
|
>
|
||||||
</button>
|
{dayShort}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
<div className="flex items-center gap-2 mt-3">
|
</div>
|
||||||
|
<span className="h-6 w-px bg-white/[0.08] shrink-0" />
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={filterTime.from}
|
value={filterTime.from}
|
||||||
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
|
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
|
||||||
placeholder="С"
|
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]"
|
||||||
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]"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-neutral-500 text-xs">—</span>
|
<span className="text-neutral-500 text-[10px]">—</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={filterTime.to}
|
value={filterTime.to}
|
||||||
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
|
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
|
||||||
placeholder="До"
|
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]"
|
||||||
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]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FilterSection>
|
</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({
|
function TrainerMultiSelect({
|
||||||
trainers,
|
trainers,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
|
|
||||||
// Extract trainer's groups from schedule using groupId
|
// Extract trainer's groups from schedule using groupId
|
||||||
const uniqueGroups = useMemo(() => {
|
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 => {
|
schedule?.forEach(location => {
|
||||||
location.days.forEach(day => {
|
location.days.forEach(day => {
|
||||||
day.classes
|
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 });
|
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
|
||||||
if (c.level && !existing.level) existing.level = c.level;
|
if (c.level && !existing.level) existing.level = c.level;
|
||||||
if (c.recruiting) existing.recruiting = true;
|
if (c.recruiting) existing.recruiting = true;
|
||||||
|
if (c.hasSlots) existing.hasSlots = true;
|
||||||
|
if (c.status && !existing.status) existing.status = c.status;
|
||||||
} else {
|
} else {
|
||||||
groupMap.set(key, {
|
groupMap.set(key, {
|
||||||
type: c.type,
|
type: c.type,
|
||||||
@@ -52,6 +54,8 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
||||||
level: c.level,
|
level: c.level,
|
||||||
recruiting: c.recruiting,
|
recruiting: c.recruiting,
|
||||||
|
hasSlots: c.hasSlots,
|
||||||
|
status: c.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -183,6 +187,8 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
type={g.type}
|
type={g.type}
|
||||||
level={g.level}
|
level={g.level}
|
||||||
recruiting={g.recruiting}
|
recruiting={g.recruiting}
|
||||||
|
hasSlots={g.hasSlots}
|
||||||
|
status={g.status}
|
||||||
address={g.address}
|
address={g.address}
|
||||||
location={g.location}
|
location={g.location}
|
||||||
merged={g.merged}
|
merged={g.merged}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { shortAddress } from "@/components/sections/schedule/constants";
|
import { shortAddress } from "@/components/sections/schedule/constants";
|
||||||
|
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
|
||||||
|
|
||||||
export interface GroupCardSlot {
|
export interface GroupCardSlot {
|
||||||
days: string[];
|
days: string[];
|
||||||
@@ -50,14 +51,11 @@ export function GroupCard({
|
|||||||
const typeCls = compact ? "text-xs" : "text-sm";
|
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 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 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 locSize = compact ? "px-2 py-0.5 text-[9px]" : "px-2.5 py-0.5 text-[10px]";
|
||||||
const locIcon = compact ? 8 : 9;
|
const locIcon = compact ? 8 : 9;
|
||||||
|
|
||||||
const levelBadge = level ? (
|
const levelBadge = level ? (
|
||||||
<span className={`shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 ${badgeSize} font-semibold text-rose-400`}>
|
<ScheduleBadge size={compact ? "sm" : "md"}>{level}</ScheduleBadge>
|
||||||
{level}
|
|
||||||
</span>
|
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const typeContent = (
|
const typeContent = (
|
||||||
@@ -69,7 +67,7 @@ export function GroupCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col flex-1 gap-1.5">
|
||||||
{/* Type + level + status badges + extras */}
|
{/* Type + level + status badges + extras */}
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{onTypeClick ? (
|
{onTypeClick ? (
|
||||||
@@ -79,27 +77,17 @@ export function GroupCard({
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5">{typeContent}</span>
|
<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) && (
|
{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`}>
|
<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} />
|
<MapPin size={locIcon} className="text-gold" />
|
||||||
{shortAddress(address || location || "")}
|
{shortAddress(address || location || "")}
|
||||||
</span>
|
</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}
|
{extraBadges}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +110,7 @@ export function GroupCard({
|
|||||||
compact ? (
|
compact ? (
|
||||||
<button
|
<button
|
||||||
onClick={onBook}
|
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>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user