feat: shared GroupCard component, admin status select, schedule level filter

- Extract shared GroupCard component used by both Schedule GroupView and TeamProfile
- Admin schedule: replace hasSlots/recruiting toggles with single Status select
- User schedule: add level filter pills (Начинающий/Без опыта, Продвинутый)
- Consistent group card styling across schedule and trainer bio views
This commit is contained in:
2026-03-26 23:38:51 +03:00
parent c4c3a7ab0d
commit 035f68776a
6 changed files with 219 additions and 107 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
import { InputField, SelectField, TimeRangeField } from "../_components/FormField";
import { Plus, X, Trash2 } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
@@ -32,6 +32,13 @@ const LEVELS = [
{ value: "Продвинутый", label: "Продвинутый" },
];
const STATUS_OPTIONS = [
{ value: "", label: "Без статуса" },
{ value: "hasSlots", label: "Есть места" },
{ value: "recruiting", label: "Набор открыт" },
{ value: "both", label: "Есть места + Набор" },
];
const GROUP_PALETTE = [
"bg-rose-500/80 border-rose-400",
"bg-orange-500/80 border-orange-400",
@@ -477,18 +484,16 @@ function ClassModal({
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
options={LEVELS}
/>
<div className="flex gap-6">
<ToggleField
label="Есть места"
checked={draft.hasSlots ?? false}
onChange={(v) => setDraft({ ...draft, hasSlots: v })}
<SelectField
label="Статус"
value={draft.hasSlots && draft.recruiting ? "both" : draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : ""}
onChange={(v) => setDraft({
...draft,
hasSlots: v === "hasSlots" || v === "both",
recruiting: v === "recruiting" || v === "both",
})}
options={STATUS_OPTIONS}
/>
<ToggleField
label="Набор открыт"
checked={draft.recruiting ?? false}
onChange={(v) => setDraft({ ...draft, recruiting: v })}
/>
</div>
</div>
{/* Overlap warning */}

View File

@@ -22,6 +22,7 @@ interface ScheduleState {
filterTrainer: string | null;
filterTypes: Set<string>;
filterStatusSet: Set<StatusTag>;
filterLevel: string | null;
filterTime: TimeFilter;
filterDaySet: Set<string>;
bookingGroup: string | null;
@@ -33,6 +34,7 @@ type ScheduleAction =
| { type: "SET_TRAINER"; value: string | null }
| { type: "TOGGLE_TYPE"; value: string }
| { type: "TOGGLE_STATUS"; value: StatusTag }
| { type: "SET_LEVEL"; value: string | null }
| { type: "SET_TIME"; value: TimeFilter }
| { type: "TOGGLE_DAY"; day: string }
| { type: "SET_BOOKING"; value: string | null }
@@ -44,6 +46,7 @@ const initialState: ScheduleState = {
filterTrainer: null,
filterTypes: new Set(),
filterStatusSet: new Set(),
filterLevel: null,
filterTime: "all",
filterDaySet: new Set(),
bookingGroup: null,
@@ -69,6 +72,8 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
else next.add(action.value);
return { ...state, filterStatusSet: next };
}
case "SET_LEVEL":
return { ...state, filterLevel: action.value };
case "SET_TIME":
return { ...state, filterTime: action.value };
case "TOGGLE_DAY": {
@@ -80,7 +85,7 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
case "SET_BOOKING":
return { ...state, bookingGroup: action.value };
case "CLEAR_FILTERS":
return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterTime: "all", filterDaySet: new Set() };
return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: "all", filterDaySet: new Set() };
}
}
@@ -92,7 +97,7 @@ interface ScheduleProps {
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
const [state, dispatch] = useReducer(scheduleReducer, initialState);
const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterTime, filterDaySet, bookingGroup } = state;
const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
const isAllMode = locationMode === "all";
@@ -104,6 +109,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
const setFilterLevel = useCallback((value: string | null) => dispatch({ type: "SET_LEVEL", value }), []);
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
@@ -170,8 +176,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
.map((d) => dayMap.get(d)!);
}, [locationMode, schedule.locations]);
const { types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
const { types, hasAnySlots, hasAnyRecruiting, levels } = useMemo(() => {
const typeSet = new Set<string>();
const levelSet = new Set<string>();
let slots = false;
let recruiting = false;
for (const day of activeDays) {
@@ -179,12 +186,14 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
typeSet.add(cls.type);
if (cls.hasSlots) slots = true;
if (cls.recruiting) recruiting = true;
if (cls.level) levelSet.add(cls.level);
}
}
return {
types: Array.from(typeSet).sort(),
hasAnySlots: slots,
hasAnyRecruiting: recruiting,
levels: Array.from(levelSet).sort(),
};
}, [activeDays]);
@@ -194,7 +203,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
: null;
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && filterTime === "all" && filterDaySet.size === 0;
const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && filterTime === "all" && filterDaySet.size === 0;
if (noFilter) return activeDays;
// First filter by day names if any selected
@@ -212,6 +221,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
(filterStatusSet.size === 0 ||
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
(!filterLevel || cls.level === filterLevel) &&
(!activeTimeRange || (() => {
const m = startTimeMinutes(cls.time);
return m >= activeTimeRange[0] && m < activeTimeRange[1];
@@ -219,9 +229,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
),
}))
.filter((day) => day.classes.length > 0);
}, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterTime, activeTimeRange, filterDaySet]);
}, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterTime !== "all" || filterDaySet.size > 0);
const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || filterTime !== "all" || filterDaySet.size > 0);
function clearFilters() {
dispatch({ type: "CLEAR_FILTERS" });
@@ -346,11 +356,14 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
types={types}
hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
setFilterLevel={setFilterLevel}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}

View File

@@ -2,8 +2,8 @@
import { useMemo } from "react";
import Image from "next/image";
import { User, MapPin, Calendar } from "lucide-react";
import { shortAddress } from "./constants";
import { User, Calendar } from "lucide-react";
import { GroupCard } from "@/components/ui/GroupCard";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface ScheduleGroup {
@@ -211,6 +211,13 @@ export function GroupView({
const hasToday = group.slots.some(s => s.day === todayName);
const todayBadge = hasToday ? (
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 px-2.5 py-0.5 text-[10px] font-semibold text-gold">
<Calendar size={9} />
Сегодня
</span>
) : null;
return (
<div
key={`${type}-${gi}`}
@@ -221,63 +228,20 @@ export function GroupView({
}`}
>
<div className="flex items-start gap-3 p-3 sm:p-4">
{/* Left: type dot + info */}
<div className="flex-1 min-w-0 space-y-2">
{/* Type name */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => toggleFilterType(type)}
className="flex items-center gap-2 cursor-pointer"
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
<span className="text-sm font-semibold text-white/90">{type}</span>
</button>
{group.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400">
{group.level}
</span>
)}
{hasToday && (
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 px-2 py-px text-[10px] font-semibold text-gold">
<Calendar size={9} />
Сегодня
</span>
)}
</div>
{/* Schedule rows */}
<div className="space-y-1">
{merged.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[52px] text-center">
{m.days.join(", ")}
</span>
<span className="text-sm font-medium tabular-nums text-white/60">
{m.times.join(", ")}
</span>
</div>
))}
</div>
{/* Bottom badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{group.hasSlots && (
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-emerald-400">
есть места
</span>
)}
{group.recruiting && (
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-sky-400">
набор
</span>
)}
{showLocation && group.location && (
<span className="flex items-center gap-1 rounded-full bg-white/[0.04] border border-white/[0.08] px-2.5 py-0.5 text-[10px] font-medium text-white/45">
<MapPin size={9} />
{shortAddress(group.locationAddress || group.location)}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<GroupCard
type={type}
level={group.level}
recruiting={group.recruiting}
hasSlots={group.hasSlots}
address={group.locationAddress}
location={group.location}
merged={merged}
dotColor={dotColor}
showLocation={showLocation && !!group.location}
extraBadges={todayBadge}
onTypeClick={() => toggleFilterType(type)}
/>
</div>
{/* Right: book button */}

View File

@@ -16,11 +16,14 @@ interface ScheduleFiltersProps {
types: string[];
hasAnySlots: boolean;
hasAnyRecruiting: boolean;
levels: string[];
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
filterTrainer: string | null;
filterStatusSet: Set<StatusTag>;
toggleFilterStatus: (status: StatusTag) => void;
filterLevel: string | null;
setFilterLevel: (level: string | null) => void;
filterTime: TimeFilter;
setFilterTime: (time: TimeFilter) => void;
availableDays: { day: string; dayShort: string }[];
@@ -35,11 +38,14 @@ export function ScheduleFilters({
types,
hasAnySlots,
hasAnyRecruiting,
levels,
filterTypes,
toggleFilterType,
filterTrainer,
filterStatusSet,
toggleFilterStatus,
filterLevel,
setFilterLevel,
filterTime,
setFilterTime,
availableDays,
@@ -90,6 +96,23 @@ export function ScheduleFilters({
</button>
)}
{/* Level filters */}
{levels.length > 0 && (
<>
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{levels.map((level) => (
<button
key={level}
onClick={() => setFilterLevel(filterLevel === level ? null : level)}
className={`${pillBase} ${filterLevel === level ? "bg-rose-500/20 text-rose-700 border border-rose-500/40 dark:text-rose-400 dark:border-rose-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-rose-500" />
{level}
</button>
))}
</>
)}
{/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />

View File

@@ -3,6 +3,7 @@ import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
import { SignupModal } from "@/components/ui/SignupModal";
import { GroupCard } from "@/components/ui/GroupCard";
interface TeamProfileProps {
member: TeamMember;
@@ -176,35 +177,17 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
</h4>
<ScrollRow>
{uniqueGroups.map((g, i) => (
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
<div className="space-y-0.5">
{g.merged.map((m, mi) => (
<div key={mi} className="flex items-center gap-1.5 text-xs text-white/50">
<Clock size={11} className="shrink-0" />
<span className="font-medium text-white/70">{m.days.join(", ")}</span>
<span>{m.times.join(", ")}</span>
</div>
))}
</div>
<div className="flex items-start gap-1.5 text-xs text-white/40">
<MapPin size={11} className="mt-0.5 shrink-0" />
<span>{g.location} · {g.address.replace(/^г\.\s*\S+,\s*/, "")}</span>
</div>
{g.level && (
<p className="text-[10px] text-gold/60">{g.level}</p>
)}
{g.recruiting && (
<span className="inline-block rounded-full bg-green-500/15 border border-green-500/30 px-2 py-0.5 text-[10px] text-green-400">
Набор открыт
</span>
)}
<button
onClick={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
className="w-full mt-1 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>
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 flex flex-col">
<GroupCard
compact
type={g.type}
level={g.level}
recruiting={g.recruiting}
address={g.address}
location={g.location}
merged={g.merged}
onBook={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
/>
</div>
))}
</ScrollRow>

View File

@@ -0,0 +1,124 @@
import { MapPin } from "lucide-react";
import { shortAddress } from "@/components/sections/schedule/constants";
export interface GroupCardSlot {
days: string[];
times: string[];
}
export interface GroupCardProps {
type: string;
level?: string;
recruiting?: boolean;
hasSlots?: boolean;
address?: string;
location?: string;
merged: GroupCardSlot[];
dotColor?: string;
/** Compact mode for small cards (e.g. trainer bio scroll row) */
compact?: boolean;
/** Show location badge */
showLocation?: boolean;
/** Extra badges (e.g. "Сегодня") rendered after level badge */
extraBadges?: React.ReactNode;
/** Click handler for type name (e.g. filter toggle in schedule) */
onTypeClick?: () => void;
/** Click handler for book button */
onBook?: () => void;
}
export function GroupCard({
type,
level,
recruiting,
hasSlots,
address,
location,
merged,
dotColor = "bg-gold",
compact = false,
showLocation = true,
extraBadges,
onTypeClick,
onBook,
}: GroupCardProps) {
const dot = compact ? "h-2 w-2" : "h-2.5 w-2.5";
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>
) : null;
const typeContent = (
<>
<span className={`${dot} shrink-0 rounded-full ${dotColor}`} />
<span className={`${typeCls} font-semibold text-white/90`}>{type}</span>
{levelBadge}
</>
);
return (
<div className="space-y-1.5">
{/* Type + level + status badges + extras */}
<div className="flex items-center gap-1.5 flex-wrap">
{onTypeClick ? (
<button onClick={onTypeClick} className="flex items-center gap-1.5 cursor-pointer">
{typeContent}
</button>
) : (
<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>
)}
{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} />
{shortAddress(address || location || "")}
</span>
)}
{extraBadges}
</div>
{/* Schedule rows */}
<div className={compact ? "space-y-0.5" : "space-y-1"}>
{merged.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className={`rounded-md bg-gold/10 ${dayPad} font-bold text-gold text-center`}>
{m.days.join(", ")}
</span>
<span className={`${timeCls} tabular-nums text-white/60`}>
{m.times.join(", ")}
</span>
</div>
))}
</div>
{/* Book button */}
{onBook && (
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"
>
Записаться
</button>
) : null
)}
</div>
);
}