diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index 4989b89..8c20772 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -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} /> -
- setDraft({ ...draft, hasSlots: v })} - /> - setDraft({ ...draft, recruiting: v })} - /> -
+ setDraft({ + ...draft, + hasSlots: v === "hasSlots" || v === "both", + recruiting: v === "recruiting" || v === "both", + })} + options={STATUS_OPTIONS} + /> {/* Overlap warning */} diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx index 78b8fbb..b3ea41f 100644 --- a/src/components/sections/Schedule.tsx +++ b/src/components/sections/Schedule.tsx @@ -22,6 +22,7 @@ interface ScheduleState { filterTrainer: string | null; filterTypes: Set; filterStatusSet: Set; + filterLevel: string | null; filterTime: TimeFilter; filterDaySet: Set; 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(); + const levelSet = new Set(); 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} diff --git a/src/components/sections/schedule/GroupView.tsx b/src/components/sections/schedule/GroupView.tsx index f3705b5..c403b84 100644 --- a/src/components/sections/schedule/GroupView.tsx +++ b/src/components/sections/schedule/GroupView.tsx @@ -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 ? ( + + + Сегодня + + ) : null; + return (
- {/* Left: type dot + info */} -
- {/* Type name */} -
- - {group.level && ( - - {group.level} - - )} - {hasToday && ( - - - Сегодня - - )} -
- - {/* Schedule rows */} -
- {merged.map((m, i) => ( -
- - {m.days.join(", ")} - - - {m.times.join(", ")} - -
- ))} -
- - {/* Bottom badges */} -
- {group.hasSlots && ( - - есть места - - )} - {group.recruiting && ( - - набор - - )} - {showLocation && group.location && ( - - - {shortAddress(group.locationAddress || group.location)} - - )} -
+
+ toggleFilterType(type)} + />
{/* Right: book button */} diff --git a/src/components/sections/schedule/ScheduleFilters.tsx b/src/components/sections/schedule/ScheduleFilters.tsx index 5607cf9..bd7c838 100644 --- a/src/components/sections/schedule/ScheduleFilters.tsx +++ b/src/components/sections/schedule/ScheduleFilters.tsx @@ -16,11 +16,14 @@ interface ScheduleFiltersProps { types: string[]; hasAnySlots: boolean; hasAnyRecruiting: boolean; + levels: string[]; filterTypes: Set; toggleFilterType: (type: string) => void; filterTrainer: string | null; filterStatusSet: Set; 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({ )} + {/* Level filters */} + {levels.length > 0 && ( + <> + + {levels.map((level) => ( + + ))} + + )} + {/* Divider */} diff --git a/src/components/sections/team/TeamProfile.tsx b/src/components/sections/team/TeamProfile.tsx index c5b592b..ae632c0 100644 --- a/src/components/sections/team/TeamProfile.tsx +++ b/src/components/sections/team/TeamProfile.tsx @@ -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) { {uniqueGroups.map((g, i) => ( -
-

{g.type}

-
- {g.merged.map((m, mi) => ( -
- - {m.days.join(", ")} - {m.times.join(", ")} -
- ))} -
-
- - {g.location} · {g.address.replace(/^г\.\s*\S+,\s*/, "")} -
- {g.level && ( -

{g.level}

- )} - {g.recruiting && ( - - Набор открыт - - )} - +
+ setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)} + />
))} diff --git a/src/components/ui/GroupCard.tsx b/src/components/ui/GroupCard.tsx new file mode 100644 index 0000000..1e1dea3 --- /dev/null +++ b/src/components/ui/GroupCard.tsx @@ -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 ? ( + + {level} + + ) : null; + + const typeContent = ( + <> + + {type} + {levelBadge} + + ); + + return ( +
+ {/* Type + level + status badges + extras */} +
+ {onTypeClick ? ( + + ) : ( + {typeContent} + )} + {hasSlots && ( + + есть места + + )} + {recruiting && ( + + набор + + )} + {showLocation && (address || location) && ( + + + {shortAddress(address || location || "")} + + )} + {extraBadges} +
+ + {/* Schedule rows */} +
+ {merged.map((m, i) => ( +
+ + {m.days.join(", ")} + + + {m.times.join(", ")} + +
+ ))} +
+ + {/* Book button */} + {onBook && ( + compact ? ( + + ) : null + )} +
+ ); +}