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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
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 { Plus, X, Trash2 } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||||
@@ -32,6 +32,13 @@ const LEVELS = [
|
|||||||
{ value: "Продвинутый", label: "Продвинутый" },
|
{ value: "Продвинутый", label: "Продвинутый" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "", label: "Без статуса" },
|
||||||
|
{ value: "hasSlots", label: "Есть места" },
|
||||||
|
{ value: "recruiting", label: "Набор открыт" },
|
||||||
|
{ value: "both", label: "Есть места + Набор" },
|
||||||
|
];
|
||||||
|
|
||||||
const GROUP_PALETTE = [
|
const GROUP_PALETTE = [
|
||||||
"bg-rose-500/80 border-rose-400",
|
"bg-rose-500/80 border-rose-400",
|
||||||
"bg-orange-500/80 border-orange-400",
|
"bg-orange-500/80 border-orange-400",
|
||||||
@@ -477,18 +484,16 @@ function ClassModal({
|
|||||||
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
|
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
|
||||||
options={LEVELS}
|
options={LEVELS}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-6">
|
<SelectField
|
||||||
<ToggleField
|
label="Статус"
|
||||||
label="Есть места"
|
value={draft.hasSlots && draft.recruiting ? "both" : draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : ""}
|
||||||
checked={draft.hasSlots ?? false}
|
onChange={(v) => setDraft({
|
||||||
onChange={(v) => setDraft({ ...draft, hasSlots: v })}
|
...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>
|
</div>
|
||||||
|
|
||||||
{/* Overlap warning */}
|
{/* Overlap warning */}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ScheduleState {
|
|||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
filterTypes: Set<string>;
|
filterTypes: Set<string>;
|
||||||
filterStatusSet: Set<StatusTag>;
|
filterStatusSet: Set<StatusTag>;
|
||||||
|
filterLevel: string | null;
|
||||||
filterTime: TimeFilter;
|
filterTime: TimeFilter;
|
||||||
filterDaySet: Set<string>;
|
filterDaySet: Set<string>;
|
||||||
bookingGroup: string | null;
|
bookingGroup: string | null;
|
||||||
@@ -33,6 +34,7 @@ type ScheduleAction =
|
|||||||
| { type: "SET_TRAINER"; value: string | null }
|
| { type: "SET_TRAINER"; value: string | null }
|
||||||
| { type: "TOGGLE_TYPE"; value: string }
|
| { type: "TOGGLE_TYPE"; value: string }
|
||||||
| { type: "TOGGLE_STATUS"; value: StatusTag }
|
| { type: "TOGGLE_STATUS"; value: StatusTag }
|
||||||
|
| { type: "SET_LEVEL"; value: string | null }
|
||||||
| { type: "SET_TIME"; value: TimeFilter }
|
| { type: "SET_TIME"; value: TimeFilter }
|
||||||
| { type: "TOGGLE_DAY"; day: string }
|
| { type: "TOGGLE_DAY"; day: string }
|
||||||
| { type: "SET_BOOKING"; value: string | null }
|
| { type: "SET_BOOKING"; value: string | null }
|
||||||
@@ -44,6 +46,7 @@ const initialState: ScheduleState = {
|
|||||||
filterTrainer: null,
|
filterTrainer: null,
|
||||||
filterTypes: new Set(),
|
filterTypes: new Set(),
|
||||||
filterStatusSet: new Set(),
|
filterStatusSet: new Set(),
|
||||||
|
filterLevel: null,
|
||||||
filterTime: "all",
|
filterTime: "all",
|
||||||
filterDaySet: new Set(),
|
filterDaySet: new Set(),
|
||||||
bookingGroup: null,
|
bookingGroup: null,
|
||||||
@@ -69,6 +72,8 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
|||||||
else next.add(action.value);
|
else next.add(action.value);
|
||||||
return { ...state, filterStatusSet: next };
|
return { ...state, filterStatusSet: next };
|
||||||
}
|
}
|
||||||
|
case "SET_LEVEL":
|
||||||
|
return { ...state, filterLevel: action.value };
|
||||||
case "SET_TIME":
|
case "SET_TIME":
|
||||||
return { ...state, filterTime: action.value };
|
return { ...state, filterTime: action.value };
|
||||||
case "TOGGLE_DAY": {
|
case "TOGGLE_DAY": {
|
||||||
@@ -80,7 +85,7 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
|||||||
case "SET_BOOKING":
|
case "SET_BOOKING":
|
||||||
return { ...state, bookingGroup: action.value };
|
return { ...state, bookingGroup: action.value };
|
||||||
case "CLEAR_FILTERS":
|
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) {
|
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
|
||||||
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
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";
|
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 setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
||||||
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
|
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
|
||||||
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", 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 setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
||||||
|
|
||||||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||||
@@ -170,8 +176,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
|||||||
.map((d) => dayMap.get(d)!);
|
.map((d) => dayMap.get(d)!);
|
||||||
}, [locationMode, schedule.locations]);
|
}, [locationMode, schedule.locations]);
|
||||||
|
|
||||||
const { types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
|
const { types, hasAnySlots, hasAnyRecruiting, levels } = useMemo(() => {
|
||||||
const typeSet = new Set<string>();
|
const typeSet = new Set<string>();
|
||||||
|
const levelSet = new Set<string>();
|
||||||
let slots = false;
|
let slots = false;
|
||||||
let recruiting = false;
|
let recruiting = false;
|
||||||
for (const day of activeDays) {
|
for (const day of activeDays) {
|
||||||
@@ -179,12 +186,14 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
|||||||
typeSet.add(cls.type);
|
typeSet.add(cls.type);
|
||||||
if (cls.hasSlots) slots = true;
|
if (cls.hasSlots) slots = true;
|
||||||
if (cls.recruiting) recruiting = true;
|
if (cls.recruiting) recruiting = true;
|
||||||
|
if (cls.level) levelSet.add(cls.level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
types: Array.from(typeSet).sort(),
|
types: Array.from(typeSet).sort(),
|
||||||
hasAnySlots: slots,
|
hasAnySlots: slots,
|
||||||
hasAnyRecruiting: recruiting,
|
hasAnyRecruiting: recruiting,
|
||||||
|
levels: Array.from(levelSet).sort(),
|
||||||
};
|
};
|
||||||
}, [activeDays]);
|
}, [activeDays]);
|
||||||
|
|
||||||
@@ -194,7 +203,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
|
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;
|
if (noFilter) return activeDays;
|
||||||
|
|
||||||
// First filter by day names if any selected
|
// First filter by day names if any selected
|
||||||
@@ -212,6 +221,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
|||||||
(filterStatusSet.size === 0 ||
|
(filterStatusSet.size === 0 ||
|
||||||
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
|
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
|
||||||
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
|
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
|
||||||
|
(!filterLevel || cls.level === filterLevel) &&
|
||||||
(!activeTimeRange || (() => {
|
(!activeTimeRange || (() => {
|
||||||
const m = startTimeMinutes(cls.time);
|
const m = startTimeMinutes(cls.time);
|
||||||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
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);
|
.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() {
|
function clearFilters() {
|
||||||
dispatch({ type: "CLEAR_FILTERS" });
|
dispatch({ type: "CLEAR_FILTERS" });
|
||||||
@@ -346,11 +356,14 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
|||||||
types={types}
|
types={types}
|
||||||
hasAnySlots={hasAnySlots}
|
hasAnySlots={hasAnySlots}
|
||||||
hasAnyRecruiting={hasAnyRecruiting}
|
hasAnyRecruiting={hasAnyRecruiting}
|
||||||
|
levels={levels}
|
||||||
filterTypes={filterTypes}
|
filterTypes={filterTypes}
|
||||||
toggleFilterType={toggleFilterType}
|
toggleFilterType={toggleFilterType}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
filterStatusSet={filterStatusSet}
|
filterStatusSet={filterStatusSet}
|
||||||
toggleFilterStatus={toggleFilterStatus}
|
toggleFilterStatus={toggleFilterStatus}
|
||||||
|
filterLevel={filterLevel}
|
||||||
|
setFilterLevel={setFilterLevel}
|
||||||
filterTime={filterTime}
|
filterTime={filterTime}
|
||||||
setFilterTime={setFilterTime}
|
setFilterTime={setFilterTime}
|
||||||
availableDays={availableDays}
|
availableDays={availableDays}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { User, MapPin, Calendar } from "lucide-react";
|
import { User, Calendar } from "lucide-react";
|
||||||
import { shortAddress } from "./constants";
|
import { GroupCard } from "@/components/ui/GroupCard";
|
||||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||||
|
|
||||||
interface ScheduleGroup {
|
interface ScheduleGroup {
|
||||||
@@ -211,6 +211,13 @@ export function GroupView({
|
|||||||
|
|
||||||
const hasToday = group.slots.some(s => s.day === todayName);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${type}-${gi}`}
|
key={`${type}-${gi}`}
|
||||||
@@ -221,63 +228,20 @@ export function GroupView({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-3 sm:p-4">
|
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||||
{/* Left: type dot + info */}
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<GroupCard
|
||||||
{/* Type name */}
|
type={type}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
level={group.level}
|
||||||
<button
|
recruiting={group.recruiting}
|
||||||
onClick={() => toggleFilterType(type)}
|
hasSlots={group.hasSlots}
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
address={group.locationAddress}
|
||||||
>
|
location={group.location}
|
||||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
merged={merged}
|
||||||
<span className="text-sm font-semibold text-white/90">{type}</span>
|
dotColor={dotColor}
|
||||||
</button>
|
showLocation={showLocation && !!group.location}
|
||||||
{group.level && (
|
extraBadges={todayBadge}
|
||||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400">
|
onTypeClick={() => toggleFilterType(type)}
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
{/* Right: book button */}
|
{/* Right: book button */}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ interface ScheduleFiltersProps {
|
|||||||
types: string[];
|
types: string[];
|
||||||
hasAnySlots: boolean;
|
hasAnySlots: boolean;
|
||||||
hasAnyRecruiting: boolean;
|
hasAnyRecruiting: boolean;
|
||||||
|
levels: string[];
|
||||||
filterTypes: Set<string>;
|
filterTypes: Set<string>;
|
||||||
toggleFilterType: (type: string) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
filterStatusSet: Set<StatusTag>;
|
filterStatusSet: Set<StatusTag>;
|
||||||
toggleFilterStatus: (status: StatusTag) => void;
|
toggleFilterStatus: (status: StatusTag) => void;
|
||||||
|
filterLevel: string | null;
|
||||||
|
setFilterLevel: (level: string | null) => void;
|
||||||
filterTime: TimeFilter;
|
filterTime: TimeFilter;
|
||||||
setFilterTime: (time: TimeFilter) => void;
|
setFilterTime: (time: TimeFilter) => void;
|
||||||
availableDays: { day: string; dayShort: string }[];
|
availableDays: { day: string; dayShort: string }[];
|
||||||
@@ -35,11 +38,14 @@ export function ScheduleFilters({
|
|||||||
types,
|
types,
|
||||||
hasAnySlots,
|
hasAnySlots,
|
||||||
hasAnyRecruiting,
|
hasAnyRecruiting,
|
||||||
|
levels,
|
||||||
filterTypes,
|
filterTypes,
|
||||||
toggleFilterType,
|
toggleFilterType,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
filterStatusSet,
|
filterStatusSet,
|
||||||
toggleFilterStatus,
|
toggleFilterStatus,
|
||||||
|
filterLevel,
|
||||||
|
setFilterLevel,
|
||||||
filterTime,
|
filterTime,
|
||||||
setFilterTime,
|
setFilterTime,
|
||||||
availableDays,
|
availableDays,
|
||||||
@@ -90,6 +96,23 @@ export function ScheduleFilters({
|
|||||||
</button>
|
</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 */}
|
{/* Divider */}
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import { GroupCard } from "@/components/ui/GroupCard";
|
||||||
|
|
||||||
interface TeamProfileProps {
|
interface TeamProfileProps {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
@@ -176,35 +177,17 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
</h4>
|
</h4>
|
||||||
<ScrollRow>
|
<ScrollRow>
|
||||||
{uniqueGroups.map((g, i) => (
|
{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">
|
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 flex flex-col">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
<GroupCard
|
||||||
<div className="space-y-0.5">
|
compact
|
||||||
{g.merged.map((m, mi) => (
|
type={g.type}
|
||||||
<div key={mi} className="flex items-center gap-1.5 text-xs text-white/50">
|
level={g.level}
|
||||||
<Clock size={11} className="shrink-0" />
|
recruiting={g.recruiting}
|
||||||
<span className="font-medium text-white/70">{m.days.join(", ")}</span>
|
address={g.address}
|
||||||
<span>{m.times.join(", ")}</span>
|
location={g.location}
|
||||||
</div>
|
merged={g.merged}
|
||||||
))}
|
onBook={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
|
||||||
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ScrollRow>
|
</ScrollRow>
|
||||||
|
|||||||
124
src/components/ui/GroupCard.tsx
Normal file
124
src/components/ui/GroupCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user