fix: schedule status labels, Open Day halls, unsaved data guards

Schedule:
- Status badges use admin config labels (not hardcoded text) everywhere
- DayCard: level badge moved next to status badge
- Single location: hide "Все студии" tab, auto-select the only hall
- Group view: hide per-card address when all share same location
- Filter tooltip z-index fixed (above dropdowns)
- Trainer bio: status labels from config, not raw keys

Open Day:
- Hall name + address shown in schedule grid headers
- Only one class card editable at a time (edit/create mutually exclusive)
- Bigger action buttons (cancel/delete) on class cards
- Create as empty draft (not pre-filled with published status)
- Fix discount threshold input (allow delete to empty)
- Skip auto-save during partial date input

Admin:
- SectionEditor: unsaved data guard (force-save before navigation)
- Open Day + Team: same navigation guards
- Contact: removed working hours field
- TimeRangeField: allow end time hour changes
- Schedule cards: visible borders, 90min default duration
- Trainer bio: RichTextarea for description
- Open Day: RichTextarea for description
This commit is contained in:
2026-03-30 22:57:36 +03:00
parent 06be6b48ce
commit ae30be8f9d
19 changed files with 286 additions and 129 deletions
+1 -1
View File
@@ -585,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
} }
function handleEndChange(newEnd: string) { function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return; // Always allow the change — validation handles the error display
update(start, newEnd); update(start, newEnd);
} }
@@ -28,6 +28,7 @@ export function SectionEditor<T>({
const [error, setError] = useState(""); const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
useEffect(() => { useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`) adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -68,6 +69,7 @@ export function SectionEditor<T>({
return; return;
} }
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
if (validate && !validate(data)) return; if (validate && !validate(data)) return;
@@ -79,6 +81,41 @@ export function SectionEditor<T>({
}; };
}, [data, save]); }, [data, save]);
// Clear pending flag after save completes
useEffect(() => {
if (status === "saved") pendingSaveRef.current = false;
}, [status]);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
// Force save immediately before navigating
if (timerRef.current) clearTimeout(timerRef.current);
if (data && (!validate || validate(data))) {
e.preventDefault();
e.stopPropagation();
save(data).then(() => {
pendingSaveRef.current = false;
window.location.href = href;
});
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [data, save, validate]);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center gap-2 text-neutral-400"> <div className="flex items-center gap-2 text-neutral-400">
-6
View File
@@ -230,12 +230,6 @@ export default function ContactEditorPage() {
/> />
</div> </div>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<CollapsibleSection title="Адреса"> <CollapsibleSection title="Адреса">
<AddressList <AddressList
items={data.addresses ?? []} items={data.addresses ?? []}
+82 -39
View File
@@ -5,7 +5,7 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles, Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField"; import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
import { PriceField } from "../_components/PriceField"; import { PriceField } from "../_components/PriceField";
// --- Types --- // --- Types ---
@@ -104,16 +104,13 @@ function EventSettings({
</div> </div>
</div> </div>
<div> <RichTextarea
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label> label="Описание"
<textarea value={event.description || ""}
value={event.description || ""} onChange={(v) => onChange({ description: v || undefined })}
onChange={(e) => onChange({ description: e.target.value || undefined })} rows={3}
rows={2} placeholder="Описание мероприятия..."
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none" />
placeholder="Описание мероприятия..."
/>
</div>
<div className="sm:max-w-xs"> <div className="sm:max-w-xs">
<PriceField <PriceField
@@ -153,8 +150,8 @@ function EventSettings({
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label> <label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<input <input
type="number" type="number"
value={event.discountThreshold} value={event.discountThreshold || ""}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })} onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/> />
</div> </div>
@@ -244,10 +241,10 @@ function NewClassForm({
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg"> <div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." /> <SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." /> <SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end"> <div className="flex gap-2 justify-end mt-2">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button> <button onClick={onCancel} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave} <button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button> className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
</div> </div>
</div> </div>
); );
@@ -260,6 +257,8 @@ function ClassCell({
minBookings, minBookings,
trainers, trainers,
styles, styles,
editing,
onEdit,
onUpdate, onUpdate,
onDelete, onDelete,
onCancel, onCancel,
@@ -268,11 +267,12 @@ function ClassCell({
minBookings: number; minBookings: number;
trainers: string[]; trainers: string[];
styles: string[]; styles: string[];
editing: boolean;
onEdit: (id: number | null) => void;
onUpdate: (id: number, data: Partial<OpenDayClass>) => void; onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
onCancel: (id: number) => void; onCancel: (id: number) => void;
}) { }) {
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer); const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style); const [style, setStyle] = useState(cls.style);
@@ -281,7 +281,7 @@ function ClassCell({
function save() { function save() {
if (trainer.trim() && style.trim()) { if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() }); onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false); onEdit(null);
} }
} }
@@ -290,12 +290,12 @@ function ClassCell({
<div className="p-2 space-y-1.5 rounded-lg"> <div className="p-2 space-y-1.5 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." /> <SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." /> <SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end"> <div className="flex gap-2 justify-end mt-2">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1"> <button onClick={() => onEdit(null)} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
Отмена Отмена
</button> </button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium"> <button onClick={save} className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors">
OK Сохранить
</button> </button>
</div> </div>
</div> </div>
@@ -311,7 +311,7 @@ function ClassCell({
? "bg-red-500/5 border border-red-500/20" ? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30" : "bg-gold/5 border border-gold/15 hover:border-gold/30"
}`} }`}
onClick={() => setEditing(true)} onClick={() => onEdit(cls.id)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span> <span className="text-xs font-medium text-white truncate">{cls.style}</span>
@@ -334,20 +334,20 @@ function ClassCell({
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>} {cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
</div> </div>
{/* Actions */} {/* Actions */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5"> <div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }} onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`} className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
title={cls.cancelled ? "Восстановить" : "Отменить"} title={cls.cancelled ? "Восстановить" : "Отменить"}
> >
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />} {cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }} onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400" className="rounded-md p-1 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Удалить" title="Удалить"
> >
<Trash2 size={10} /> <Trash2 size={14} />
</button> </button>
</div> </div>
</div> </div>
@@ -374,6 +374,7 @@ function ScheduleGrid({
onClassesChange: () => void; onClassesChange: () => void;
}) { }) {
const [selectedHall, setSelectedHall] = useState(halls[0] ?? ""); const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
const [editingClassId, setEditingClassId] = useState<number | null>(null);
const timeSlots = generateTimeSlots(10, 22); const timeSlots = generateTimeSlots(10, 22);
// Build lookup: time -> class for selected hall // Build lookup: time -> class for selected hall
@@ -469,6 +470,8 @@ function ScheduleGrid({
minBookings={minBookings} minBookings={minBookings}
trainers={trainers} trainers={trainers}
styles={styles} styles={styles}
editing={editingClassId === cls.id}
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
onUpdate={updateClass} onUpdate={updateClass}
onDelete={deleteClass} onDelete={deleteClass}
onCancel={cancelClass} onCancel={cancelClass}
@@ -483,7 +486,7 @@ function ScheduleGrid({
/> />
) : ( ) : (
<button <button
onClick={() => setCreatingTime(time)} onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors" className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
> >
<Plus size={12} className="mx-auto" /> <Plus size={12} className="mx-auto" />
@@ -512,7 +515,8 @@ export default function OpenDayAdminPage() {
const [trainers, setTrainers] = useState<string[]>([]); const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]); const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]); const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null }; const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = useRef(false);
// Load data // Load data
useEffect(() => { useEffect(() => {
@@ -543,8 +547,11 @@ export default function OpenDayAdminPage() {
} }
// Auto-save event changes // Auto-save event changes
const eventRef = useRef<OpenDayEvent | null>(null);
const saveEvent = useCallback( const saveEvent = useCallback(
(updated: OpenDayEvent) => { (updated: OpenDayEvent) => {
eventRef.current = updated;
pendingSaveRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
setSaving(true); setSaving(true);
@@ -555,6 +562,7 @@ export default function OpenDayAdminPage() {
body: JSON.stringify(updated), body: JSON.stringify(updated),
}); });
setSaveStatus(res.ok ? "saved" : "error"); setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
} catch { } catch {
setSaveStatus("error"); setSaveStatus("error");
} }
@@ -565,31 +573,66 @@ export default function OpenDayAdminPage() {
[] []
); );
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
e.preventDefault();
e.stopPropagation();
// Force save then navigate
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const data = eventRef.current;
if (data) {
adminFetch("/api/admin/open-day", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).finally(() => { window.location.href = href; });
} else {
window.location.href = href;
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, []);
function handleEventChange(patch: Partial<OpenDayEvent>) { function handleEventChange(patch: Partial<OpenDayEvent>) {
if (!event) return; if (!event) return;
const updated = { ...event, ...patch }; const updated = { ...event, ...patch };
setEvent(updated); setEvent(updated);
// Skip auto-save only if date is partially typed (prevents 400 errors)
if (updated.date && updated.date.length > 0 && updated.date.length < 10) return;
saveEvent(updated); saveEvent(updated);
} }
async function createEvent() { async function createEvent() {
const today = new Date().toISOString().split("T")[0];
const res = await adminFetch("/api/admin/open-day", { const res = await adminFetch("/api/admin/open-day", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: today }), body: JSON.stringify({ date: "" }),
}); });
const { id } = await res.json(); const { id } = await res.json();
setEvent({ setEvent({
id, id,
date: today, date: "",
title: "День открытых дверей", title: "",
pricePerClass: 30, pricePerClass: 0,
discountPrice: 20, discountPrice: 0,
discountThreshold: 3, discountThreshold: 0,
minBookings: 4, minBookings: 0,
maxParticipants: 0, maxParticipants: 0,
active: true, active: false,
}); });
} }
+13 -8
View File
@@ -221,10 +221,10 @@ function ClassBlock({
? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" } ? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" }
: {}), : {}),
}} }}
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${ className={`absolute left-1 right-1 rounded-md border border-white/20 border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : "" isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`} } ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`} title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
> >
<div className="font-semibold truncate leading-tight"> <div className="font-semibold truncate leading-tight">
{parts[0]?.trim()}{parts[1]?.trim()} {parts[0]?.trim()}{parts[1]?.trim()}
@@ -235,6 +235,11 @@ function ClassBlock({
{height > 48 && ( {height > 48 && (
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div> <div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
)} )}
{height > 64 && (cls.level || cls.status) && (
<div className="truncate text-white/50 leading-tight text-[10px]">
{[cls.level, cls.status].filter(Boolean).join(" · ")}
</div>
)}
{isOverlapping && ( {isOverlapping && (
<div className="text-red-200 font-bold leading-tight"> Пересечение</div> <div className="text-red-200 font-bold leading-tight"> Пересечение</div>
)} )}
@@ -876,10 +881,10 @@ function CalendarGrid({
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
const rawMin = yToMinutes(y); const rawMin = yToMinutes(y);
snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES; snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60)); snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
} }
const startTime = formatMinutes(snapped); const startTime = formatMinutes(snapped);
const endTime = formatMinutes(snapped + 60); const endTime = formatMinutes(snapped + 90);
setHover(null); setHover(null);
setNewClass({ setNewClass({
@@ -1005,8 +1010,8 @@ function CalendarGrid({
{sortedDays.map((day, di) => { {sortedDays.map((day, di) => {
const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass; const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass;
const hoverTop = showHover ? minutesToY(hover.startMin) : 0; const hoverTop = showHover ? minutesToY(hover.startMin) : 0;
const hoverHeight = HOUR_HEIGHT; // 1 hour const hoverHeight = HOUR_HEIGHT * 1.5; // 1.5 hours
const hoverEndMin = showHover ? hover.startMin + 60 : 0; const hoverEndMin = showHover ? hover.startMin + 90 : 0;
return ( return (
<div <div
@@ -1026,7 +1031,7 @@ function CalendarGrid({
const rawMin = yToMinutes(y); const rawMin = yToMinutes(y);
// Snap to 15-min and offset so the block is centered on cursor // Snap to 15-min and offset so the block is centered on cursor
const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES; const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60)); const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
setHover({ dayIndex: di, startMin: clamped }); setHover({ dayIndex: di, startMin: clamped });
}} }}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react"; import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField"; import { InputField, TextareaField, RichTextarea, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
import { useToast } from "../../_components/Toast"; import { useToast } from "../../_components/Toast";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { RichListItem } from "@/types/content"; import type { RichListItem } from "@/types/content";
@@ -397,7 +397,7 @@ function TeamMemberEditor() {
rows={2} rows={2}
placeholder="1-2 предложения для карусели" placeholder="1-2 предложения для карусели"
/> />
<TextareaField <RichTextarea
label="Полное описание (для страницы тренера)" label="Полное описание (для страницы тренера)"
value={data.description} value={data.description}
onChange={(v) => setData({ ...data, description: v })} onChange={(v) => setData({ ...data, description: v })}
+31 -1
View File
@@ -32,11 +32,12 @@ export default function TeamEditorPage() {
// Auto-save section title with debounce (skip initial load) // Auto-save section title with debounce (skip initial load)
const titleChangeCount = useRef(0); const titleChangeCount = useRef(0);
const pendingSaveRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!titleLoadedRef.current) return; if (!titleLoadedRef.current) return;
titleChangeCount.current++; titleChangeCount.current++;
// Skip the first change (initial load setting the value)
if (titleChangeCount.current <= 1) return; if (titleChangeCount.current <= 1) return;
pendingSaveRef.current = true;
if (titleTimerRef.current) clearTimeout(titleTimerRef.current); if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
titleTimerRef.current = setTimeout(async () => { titleTimerRef.current = setTimeout(async () => {
const res = await adminFetch("/api/admin/sections/team", { const res = await adminFetch("/api/admin/sections/team", {
@@ -45,11 +46,40 @@ export default function TeamEditorPage() {
body: JSON.stringify({ title: sectionTitle }), body: JSON.stringify({ title: sectionTitle }),
}); });
setSaveStatus(res.ok ? "saved" : "error"); setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
setTimeout(() => setSaveStatus("idle"), 2000); setTimeout(() => setSaveStatus("idle"), 2000);
}, 800); }, 800);
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); }; return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
}, [sectionTitle]); }, [sectionTitle]);
// Warn before leaving with unsaved title
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#") || href.startsWith("/admin/team/")) return;
e.preventDefault();
e.stopPropagation();
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
adminFetch("/api/admin/sections/team", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: sectionTitle }),
}).finally(() => { window.location.href = href; });
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [sectionTitle]);
const saveOrder = useCallback(async (updated: Member[]) => { const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated); setMembers(updated);
const res = await adminFetch("/api/admin/team/reorder", { const res = await adminFetch("/api/admin/team/reorder", {
+2 -2
View File
@@ -24,8 +24,8 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
if (!body.date || typeof body.date !== "string") { if (body.date === undefined) {
return NextResponse.json({ error: "date is required" }, { status: 400 }); return NextResponse.json({ error: "date field is required" }, { status: 400 });
} }
const id = createOpenDayEvent(body); const id = createOpenDayEvent(body);
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id });
+2 -2
View File
@@ -45,8 +45,8 @@ export default function HomePage() {
/> />
)} )}
{content?.classes && <Classes data={content.classes} />} {content?.classes && <Classes data={content.classes} />}
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} />} {content?.team && <Team data={content.team} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} />} {openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />} {content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.pricing && <Pricing data={content.pricing} />} {content?.pricing && <Pricing data={content.pricing} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />} {content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />}
+1 -6
View File
@@ -1,4 +1,4 @@
import { MapPin, Phone, Clock, Instagram } from "lucide-react"; import { MapPin, Phone, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
@@ -37,11 +37,6 @@ export function Contact({ data: contact }: ContactProps) {
</a> </a>
</div> </div>
<div className="group flex items-center gap-4">
<IconBadge><Clock size={18} /></IconBadge>
<p className="body-text"><time>{contact.workingHours}</time></p>
</div>
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]"> <div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div className="group flex items-center gap-4"> <div className="group flex items-center gap-4">
<IconBadge><Instagram size={18} /></IconBadge> <IconBadge><Instagram size={18} /></IconBadge>
+37 -8
View File
@@ -2,12 +2,13 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { Calendar, Sparkles, User } from "lucide-react"; import { Calendar, Sparkles, User, MapPin } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay"; import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
import type { SiteContent } from "@/types"; import type { SiteContent, ScheduleLocation } from "@/types";
import { formatMarkup } from "@/lib/markup";
interface OpenDayProps { interface OpenDayProps {
data: { data: {
@@ -16,6 +17,7 @@ interface OpenDayProps {
}; };
popups?: SiteContent["popups"]; popups?: SiteContent["popups"];
teamMembers?: { name: string; image: string }[]; teamMembers?: { name: string; image: string }[];
locations?: ScheduleLocation[];
} }
function formatDateRu(dateStr: string): string { function formatDateRu(dateStr: string): string {
@@ -27,7 +29,7 @@ function formatDateRu(dateStr: string): string {
}); });
} }
export function OpenDay({ data, popups, teamMembers }: OpenDayProps) { export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps) {
const { event, classes } = data; const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null); const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
@@ -57,6 +59,17 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
const halls = Object.keys(hallGroups); const halls = Object.keys(hallGroups);
// Map hall name → address from schedule locations
const hallAddress = useMemo(() => {
const map: Record<string, string> = {};
if (locations) {
for (const loc of locations) {
if (loc.name && loc.address) map[loc.name] = loc.address;
}
}
return map;
}, [locations]);
if (classes.length === 0) return null; if (classes.length === 0) return null;
return ( return (
@@ -93,9 +106,9 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
{event.description && ( {event.description && (
<Reveal> <Reveal>
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto"> <div className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
{event.description} {formatMarkup(event.description)}
</p> </div>
</Reveal> </Reveal>
)} )}
@@ -105,7 +118,15 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
// Single hall — simple list // Single hall — simple list
<Reveal> <Reveal>
<div className="max-w-lg mx-auto space-y-3"> <div className="max-w-lg mx-auto space-y-3">
<h3 className="text-sm font-medium text-neutral-400 text-center">{halls[0]}</h3> <div className="text-center mb-4">
<h3 className="text-base font-semibold text-white">{halls[0]}</h3>
{hallAddress[halls[0]] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} />
{hallAddress[halls[0]]}
</p>
)}
</div>
{hallGroups[halls[0]].map((cls) => ( {hallGroups[halls[0]].map((cls) => (
<ClassCard <ClassCard
key={cls.id} key={cls.id}
@@ -123,7 +144,15 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
{halls.map((hall) => ( {halls.map((hall) => (
<Reveal key={hall}> <Reveal key={hall}>
<div> <div>
<h3 className="text-sm font-medium text-neutral-400 mb-3 text-center">{hall}</h3> <div className="text-center mb-4 rounded-lg bg-white/[0.03] border border-white/[0.06] py-3 px-4">
<h3 className="text-base font-semibold text-white">{hall}</h3>
{hallAddress[hall] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} />
{hallAddress[hall]}
</p>
)}
</div>
<div className="space-y-3"> <div className="space-y-3">
{hallGroups[hall].map((cls) => ( {hallGroups[hall].map((cls) => (
<ClassCard <ClassCard
+21 -15
View File
@@ -2,7 +2,7 @@
import { useReducer, useMemo, useCallback } from "react"; import { useReducer, useMemo, useCallback } from "react";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal } from "lucide-react"; import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard"; import { DayCard } from "./schedule/DayCard";
@@ -102,7 +102,10 @@ interface ScheduleProps {
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) { export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
if (!schedule?.locations?.length) return null; if (!schedule?.locations?.length) return null;
const [state, dispatch] = useReducer(scheduleReducer, initialState); const [state, dispatch] = useReducer(scheduleReducer, {
...initialState,
locationMode: schedule.locations.length === 1 ? 0 : "all",
});
const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state; const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
const isAllMode = locationMode === "all"; const isAllMode = locationMode === "all";
@@ -313,17 +316,19 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
{/* Location tabs */} {/* Location tabs */}
<Reveal> <Reveal>
<div className="mt-8 flex justify-center gap-2 flex-wrap"> <div className="mt-8 flex justify-center gap-2 flex-wrap">
{/* "All studios" tab */} {/* "All studios" tab — only when multiple locations */}
<button {schedule.locations.length > 1 && (
onClick={() => switchLocation("all")} <button
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${ onClick={() => switchLocation("all")}
isAllMode ? activeTabClass : inactiveTabClass className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
}`} isAllMode ? activeTabClass : inactiveTabClass
> }`}
<LayoutGrid size={14} /> >
<span className="hidden sm:inline">Все студии</span> <LayoutGrid size={14} />
<span className="sm:hidden">Все</span> <span className="hidden sm:inline">Все студии</span>
</button> <span className="sm:hidden">Все</span>
</button>
)}
{/* Per-location tabs */} {/* Per-location tabs */}
{schedule.locations.map((loc, i) => ( {schedule.locations.map((loc, i) => (
@@ -347,7 +352,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
</button> </button>
))} ))}
</div> </div>
</Reveal> </Reveal>
{/* Mobile filter button — visible only on small screens */} {/* Mobile filter button — visible only on small screens */}
<Reveal> <Reveal>
@@ -450,6 +455,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
hasActiveFilter={hasActiveFilter} hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters} clearFilters={clearFilters}
showLocation={isAllMode} showLocation={isAllMode}
statuses={scheduleConfig?.statuses}
/> />
</Reveal> </Reveal>
@@ -464,7 +470,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
key={day.day} key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""} className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
> >
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} /> <DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} statuses={scheduleConfig?.statuses} />
</div> </div>
))} ))}
+3 -1
View File
@@ -11,9 +11,10 @@ import type { SiteContent, ScheduleLocation } from "@/types/content";
interface TeamProps { interface TeamProps {
data: SiteContent["team"]; data: SiteContent["team"];
schedule?: ScheduleLocation[]; schedule?: ScheduleLocation[];
scheduleConfig?: SiteContent["scheduleConfig"];
} }
export function Team({ data: team, schedule }: TeamProps) { export function Team({ data: team, schedule, scheduleConfig }: TeamProps) {
if (!team?.members?.length) return null; if (!team?.members?.length) return null;
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
@@ -106,6 +107,7 @@ export function Team({ data: team, schedule }: TeamProps) {
member={team.members[activeIndex]} member={team.members[activeIndex]}
onBack={() => { history.back(); }} onBack={() => { history.back(); }}
schedule={schedule} schedule={schedule}
scheduleConfig={scheduleConfig}
/> />
)} )}
</div> </div>
+15 -11
View File
@@ -1,5 +1,5 @@
import { Clock, User, MapPin } from "lucide-react"; import { Clock, User, MapPin } from "lucide-react";
import { shortAddress } from "./constants"; import { shortAddress, findStatusConfig } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge"; import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -11,6 +11,7 @@ interface DayCardProps {
toggleFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
statuses?: { key: string; label: string; description: string }[];
} }
function ClassRow({ function ClassRow({
@@ -20,6 +21,7 @@ function ClassRow({
toggleFilterTrainer, toggleFilterTrainer,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
statuses,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
@@ -27,19 +29,22 @@ function ClassRow({
toggleFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
statuses?: { key: string; label: string; description: string }[];
}) { }) {
return ( return (
<div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}> <div className="px-5 py-3.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40"> <div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
<Clock size={13} /> <Clock size={13} />
<span className="font-semibold">{cls.time}</span> <span className="font-semibold">{cls.time}</span>
</div> </div>
{cls.hasSlots && <ScheduleBadge>есть места</ScheduleBadge>} <div className="flex items-center gap-1.5">
{cls.recruiting && <ScheduleBadge>набор</ScheduleBadge>} {cls.status && (() => {
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && ( const cfg = findStatusConfig(statuses, cls.status);
<ScheduleBadge>{cls.status}</ScheduleBadge> return <ScheduleBadge>{cfg?.label || cls.status}</ScheduleBadge>;
)} })()}
{cls.level && <ScheduleBadge>{cls.level}</ScheduleBadge>}
</div>
</div> </div>
<button <button
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }))} onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }))}
@@ -56,13 +61,12 @@ 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 && <ScheduleBadge>{cls.level}</ScheduleBadge>}
</div> </div>
</div> </div>
); );
} }
export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleFilterTrainer, filterTypes, toggleFilterType }: DayCardProps) { export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleFilterTrainer, filterTypes, toggleFilterType, statuses }: DayCardProps) {
// Group classes by location when showLocation is true // Group classes by location when showLocation is true
const locationGroups = showLocation const locationGroups = showLocation
? Array.from( ? Array.from(
@@ -109,7 +113,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
</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]">
{classes.map((cls, i) => ( {classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} statuses={statuses} />
))} ))}
</div> </div>
</div> </div>
@@ -119,7 +123,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
// Single location — no sub-headers // Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => ( {day.classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} statuses={statuses} />
))} ))}
</div> </div>
)} )}
+14 -9
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { User, Calendar } from "lucide-react"; import { User } from "lucide-react";
import { GroupCard } from "@/components/ui/GroupCard"; import { GroupCard } from "@/components/ui/GroupCard";
import { findStatusConfig } from "./constants"; import { findStatusConfig } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -153,6 +153,14 @@ export function GroupView({
const todayName = useMemo(() => WEEKDAY_NAMES[new Date().getDay()], []); const todayName = useMemo(() => WEEKDAY_NAMES[new Date().getDay()], []);
// If all groups share the same address, show it once at the top instead of per-card
const allAddresses = useMemo(() => {
const set = new Set(groups.map((g) => g.locationAddress).filter(Boolean));
return set;
}, [groups]);
const singleAddress = allAddresses.size === 1 ? [...allAddresses][0] : null;
const showLocationPerCard = showLocation && !singleAddress;
if (groups.length === 0) { if (groups.length === 0) {
return ( return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
@@ -163,6 +171,8 @@ export function GroupView({
return ( return (
<div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto"> <div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
{/* Address shown once at top only when multiple different addresses exist —
when all groups share one address, the location tab already shows it */}
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => { {Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const byType = groupByType(trainerGroups); const byType = groupByType(trainerGroups);
const isActive = filterTrainerSet.has(trainer); const isActive = filterTrainerSet.has(trainer);
@@ -218,18 +228,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 ? ( const todayBadge = null;
<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}`}
className={`rounded-xl border transition-all ${ className={`rounded-xl border transition-all ${
hasToday false
? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]"
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]" : "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
}`} }`}
@@ -247,7 +252,7 @@ export function GroupView({
location={group.location} location={group.location}
merged={merged} merged={merged}
dotColor={dotColor} dotColor={dotColor}
showLocation={showLocation && !!group.location} showLocation={showLocationPerCard && !!group.location}
extraBadges={todayBadge} extraBadges={todayBadge}
onTypeClick={() => toggleFilterType(type)} onTypeClick={() => toggleFilterType(type)}
/> />
@@ -1,7 +1,7 @@
"use client"; "use client";
import { User, X, MapPin } from "lucide-react"; import { User, X, MapPin } from "lucide-react";
import { shortAddress } from "./constants"; import { shortAddress, findStatusConfig } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge"; import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -15,6 +15,7 @@ interface MobileScheduleProps {
hasActiveFilter: boolean; hasActiveFilter: boolean;
clearFilters: () => void; clearFilters: () => void;
showLocation?: boolean; showLocation?: boolean;
statuses?: { key: string; label: string; description: string }[];
} }
function ClassRow({ function ClassRow({
@@ -25,6 +26,7 @@ function ClassRow({
filterTrainerSet, filterTrainerSet,
toggleFilterTrainer, toggleFilterTrainer,
showLocation, showLocation,
statuses,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
@@ -33,10 +35,11 @@ function ClassRow({
filterTrainerSet: Set<string>; filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
statuses?: { key: string; label: string; description: string }[];
}) { }) {
return ( return (
<div <div
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`} className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.status ? "bg-white/[0.02]" : ""}`}
> >
{/* Time */} {/* Time */}
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5"> <span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
@@ -52,11 +55,10 @@ function ClassRow({
> >
{cls.trainer} {cls.trainer}
</button> </button>
{cls.hasSlots && <ScheduleBadge size="sm">места</ScheduleBadge>} {cls.status && (() => {
{cls.recruiting && <ScheduleBadge size="sm">набор</ScheduleBadge>} const cfg = findStatusConfig(statuses, cls.status);
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && ( return <ScheduleBadge size="sm">{cfg?.label || cls.status}</ScheduleBadge>;
<ScheduleBadge size="sm">{cls.status}</ScheduleBadge> })()}
)}
{cls.level && <ScheduleBadge size="sm">{cls.level}</ScheduleBadge>} {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">
@@ -89,6 +91,7 @@ export function MobileSchedule({
hasActiveFilter, hasActiveFilter,
clearFilters, clearFilters,
showLocation, showLocation,
statuses,
}: MobileScheduleProps) { }: MobileScheduleProps) {
return ( return (
<div className="mt-6 px-4 sm:hidden"> <div className="mt-6 px-4 sm:hidden">
@@ -171,6 +174,7 @@ export function MobileSchedule({
toggleFilterType={toggleFilterType} toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet} filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer} toggleFilterTrainer={toggleFilterTrainer}
statuses={statuses}
/> />
))} ))}
</div> </div>
@@ -186,6 +190,7 @@ export function MobileSchedule({
toggleFilterType={toggleFilterType} toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet} filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer} toggleFilterTrainer={toggleFilterTrainer}
statuses={statuses}
/> />
)) ))
)} )}
@@ -345,7 +345,7 @@ function InfoTip({ text }: { text: string }) {
? ?
</button> </button>
{open && ( {open && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-20 w-52"> <div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[60] w-52">
{/* Tail behind body — always centered */} {/* 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" /> <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 */} {/* Body on top */}
+9 -5
View File
@@ -1,17 +1,20 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Image from "next/image"; 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, SiteContent } from "@/types/content";
import { findStatusConfig } from "@/components/sections/schedule/constants";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { formatMarkup } from "@/lib/markup";
import { GroupCard } from "@/components/ui/GroupCard"; import { GroupCard } from "@/components/ui/GroupCard";
interface TeamProfileProps { interface TeamProfileProps {
member: TeamMember; member: TeamMember;
onBack: () => void; onBack: () => void;
schedule?: ScheduleLocation[]; schedule?: ScheduleLocation[];
scheduleConfig?: SiteContent["scheduleConfig"];
} }
export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) { export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamProfileProps) {
const [lightbox, setLightbox] = useState<string | null>(null); const [lightbox, setLightbox] = useState<string | null>(null);
const [bookingGroup, setBookingGroup] = useState<string | null>(null); const [bookingGroup, setBookingGroup] = useState<string | null>(null);
@@ -189,6 +192,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
recruiting={g.recruiting} recruiting={g.recruiting}
hasSlots={g.hasSlots} hasSlots={g.hasSlots}
status={g.status} status={g.status}
statusLabel={findStatusConfig(scheduleConfig?.statuses, g.status ?? "")?.label}
address={g.address} address={g.address}
location={g.location} location={g.location}
merged={g.merged} merged={g.merged}
@@ -202,9 +206,9 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
{/* Description */} {/* Description */}
{member.description && ( {member.description && (
<p className="text-sm leading-relaxed text-white/50"> <div className="text-sm leading-relaxed text-white/50">
{member.description} {formatMarkup(member.description)}
</p> </div>
)} )}
{/* Education — collapsible */} {/* Education — collapsible */}
+3 -5
View File
@@ -62,13 +62,12 @@ export function GroupCard({
<> <>
<span className={`${dot} shrink-0 rounded-full ${dotColor}`} /> <span className={`${dot} shrink-0 rounded-full ${dotColor}`} />
<span className={`${typeCls} font-semibold text-white/90`}>{type}</span> <span className={`${typeCls} font-semibold text-white/90`}>{type}</span>
{levelBadge}
</> </>
); );
return ( return (
<div className="flex flex-col flex-1 gap-1.5"> <div className="flex flex-col flex-1 gap-1.5">
{/* Type + level + status badges + extras */} {/* Type + address + level + status badges */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{onTypeClick ? ( {onTypeClick ? (
<button onClick={onTypeClick} className="flex items-center gap-1.5 cursor-pointer"> <button onClick={onTypeClick} className="flex items-center gap-1.5 cursor-pointer">
@@ -83,11 +82,10 @@ export function GroupCard({
{shortAddress(address || location || "")} {shortAddress(address || location || "")}
</span> </span>
)} )}
{hasSlots && <ScheduleBadge size={compact ? "sm" : "md"}>есть места</ScheduleBadge>} {status && (
{recruiting && <ScheduleBadge size={compact ? "sm" : "md"}>набор</ScheduleBadge>}
{status && status !== "hasSlots" && status !== "recruiting" && (
<ScheduleBadge size={compact ? "sm" : "md"}>{statusLabel || status}</ScheduleBadge> <ScheduleBadge size={compact ? "sm" : "md"}>{statusLabel || status}</ScheduleBadge>
)} )}
{levelBadge}
{extraBadges} {extraBadges}
</div> </div>