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) {
if (start && newEnd && newEnd <= start) return;
// Always allow the change — validation handles the error display
update(start, newEnd);
}
@@ -28,6 +28,7 @@ export function SectionEditor<T>({
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -68,6 +69,7 @@ export function SectionEditor<T>({
return;
}
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (validate && !validate(data)) return;
@@ -79,6 +81,41 @@ export function SectionEditor<T>({
};
}, [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) {
return (
<div className="flex items-center gap-2 text-neutral-400">
-6
View File
@@ -230,12 +230,6 @@ export default function ContactEditorPage() {
/>
</div>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<CollapsibleSection title="Адреса">
<AddressList
items={data.addresses ?? []}
+82 -39
View File
@@ -5,7 +5,7 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField";
import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
import { PriceField } from "../_components/PriceField";
// --- Types ---
@@ -104,16 +104,13 @@ function EventSettings({
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
<textarea
value={event.description || ""}
onChange={(e) => onChange({ description: e.target.value || undefined })}
rows={2}
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>
<RichTextarea
label="Описание"
value={event.description || ""}
onChange={(v) => onChange({ description: v || undefined })}
rows={3}
placeholder="Описание мероприятия..."
/>
<div className="sm:max-w-xs">
<PriceField
@@ -153,8 +150,8 @@ function EventSettings({
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<input
type="number"
value={event.discountThreshold}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
value={event.discountThreshold || ""}
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"
/>
</div>
@@ -244,10 +241,10 @@ function NewClassForm({
<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={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
<div className="flex gap-2 justify-end mt-2">
<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}
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>
);
@@ -260,6 +257,8 @@ function ClassCell({
minBookings,
trainers,
styles,
editing,
onEdit,
onUpdate,
onDelete,
onCancel,
@@ -268,11 +267,12 @@ function ClassCell({
minBookings: number;
trainers: string[];
styles: string[];
editing: boolean;
onEdit: (id: number | null) => void;
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void;
onCancel: (id: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style);
@@ -281,7 +281,7 @@ function ClassCell({
function save() {
if (trainer.trim() && 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">
<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="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
<div className="flex gap-2 justify-end mt-2">
<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 onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
<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">
Сохранить
</button>
</div>
</div>
@@ -311,7 +311,7 @@ function ClassCell({
? "bg-red-500/5 border border-red-500/20"
: "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">
<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>}
</div>
{/* 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
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 ? "Восстановить" : "Отменить"}
>
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
{cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
</button>
<button
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="Удалить"
>
<Trash2 size={10} />
<Trash2 size={14} />
</button>
</div>
</div>
@@ -374,6 +374,7 @@ function ScheduleGrid({
onClassesChange: () => void;
}) {
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
const [editingClassId, setEditingClassId] = useState<number | null>(null);
const timeSlots = generateTimeSlots(10, 22);
// Build lookup: time -> class for selected hall
@@ -469,6 +470,8 @@ function ScheduleGrid({
minBookings={minBookings}
trainers={trainers}
styles={styles}
editing={editingClassId === cls.id}
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
@@ -483,7 +486,7 @@ function ScheduleGrid({
/>
) : (
<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"
>
<Plus size={12} className="mx-auto" />
@@ -512,7 +515,8 @@ export default function OpenDayAdminPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = 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
useEffect(() => {
@@ -543,8 +547,11 @@ export default function OpenDayAdminPage() {
}
// Auto-save event changes
const eventRef = useRef<OpenDayEvent | null>(null);
const saveEvent = useCallback(
(updated: OpenDayEvent) => {
eventRef.current = updated;
pendingSaveRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
setSaving(true);
@@ -555,6 +562,7 @@ export default function OpenDayAdminPage() {
body: JSON.stringify(updated),
});
setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
} catch {
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>) {
if (!event) return;
const updated = { ...event, ...patch };
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);
}
async function createEvent() {
const today = new Date().toISOString().split("T")[0];
const res = await adminFetch("/api/admin/open-day", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: today }),
body: JSON.stringify({ date: "" }),
});
const { id } = await res.json();
setEvent({
id,
date: today,
title: "День открытых дверей",
pricePerClass: 30,
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
date: "",
title: "",
pricePerClass: 0,
discountPrice: 0,
discountThreshold: 0,
minBookings: 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)" }
: {}),
}}
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" : ""
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
} ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
>
<div className="font-semibold truncate leading-tight">
{parts[0]?.trim()}{parts[1]?.trim()}
@@ -235,6 +235,11 @@ function ClassBlock({
{height > 48 && (
<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 && (
<div className="text-red-200 font-bold leading-tight"> Пересечение</div>
)}
@@ -876,10 +881,10 @@ function CalendarGrid({
const y = e.clientY - rect.top;
const rawMin = yToMinutes(y);
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 endTime = formatMinutes(snapped + 60);
const endTime = formatMinutes(snapped + 90);
setHover(null);
setNewClass({
@@ -1005,8 +1010,8 @@ function CalendarGrid({
{sortedDays.map((day, di) => {
const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass;
const hoverTop = showHover ? minutesToY(hover.startMin) : 0;
const hoverHeight = HOUR_HEIGHT; // 1 hour
const hoverEndMin = showHover ? hover.startMin + 60 : 0;
const hoverHeight = HOUR_HEIGHT * 1.5; // 1.5 hours
const hoverEndMin = showHover ? hover.startMin + 90 : 0;
return (
<div
@@ -1026,7 +1031,7 @@ function CalendarGrid({
const rawMin = yToMinutes(y);
// Snap to 15-min and offset so the block is centered on cursor
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 });
}}
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 Image from "next/image";
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 { adminFetch } from "@/lib/csrf";
import type { RichListItem } from "@/types/content";
@@ -397,7 +397,7 @@ function TeamMemberEditor() {
rows={2}
placeholder="1-2 предложения для карусели"
/>
<TextareaField
<RichTextarea
label="Полное описание (для страницы тренера)"
value={data.description}
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)
const titleChangeCount = useRef(0);
const pendingSaveRef = useRef(false);
useEffect(() => {
if (!titleLoadedRef.current) return;
titleChangeCount.current++;
// Skip the first change (initial load setting the value)
if (titleChangeCount.current <= 1) return;
pendingSaveRef.current = true;
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
titleTimerRef.current = setTimeout(async () => {
const res = await adminFetch("/api/admin/sections/team", {
@@ -45,11 +46,40 @@ export default function TeamEditorPage() {
body: JSON.stringify({ title: sectionTitle }),
});
setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
setTimeout(() => setSaveStatus("idle"), 2000);
}, 800);
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
}, [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[]) => {
setMembers(updated);
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) {
try {
const body = await request.json();
if (!body.date || typeof body.date !== "string") {
return NextResponse.json({ error: "date is required" }, { status: 400 });
if (body.date === undefined) {
return NextResponse.json({ error: "date field is required" }, { status: 400 });
}
const id = createOpenDayEvent(body);
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?.team && <Team data={content.team} schedule={content.schedule?.locations} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} />}
{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 ?? []} locations={content.schedule?.locations} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.pricing && <Pricing data={content.pricing} />}
{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 { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
@@ -37,11 +37,6 @@ export function Contact({ data: contact }: ContactProps) {
</a>
</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="group flex items-center gap-4">
<IconBadge><Instagram size={18} /></IconBadge>
+37 -8
View File
@@ -2,12 +2,13 @@
import { useState, useMemo } from "react";
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 { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
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 {
data: {
@@ -16,6 +17,7 @@ interface OpenDayProps {
};
popups?: SiteContent["popups"];
teamMembers?: { name: string; image: string }[];
locations?: ScheduleLocation[];
}
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 [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);
// 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;
return (
@@ -93,9 +106,9 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
{event.description && (
<Reveal>
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
{event.description}
</p>
<div className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
{formatMarkup(event.description)}
</div>
</Reveal>
)}
@@ -105,7 +118,15 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
// Single hall — simple list
<Reveal>
<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) => (
<ClassCard
key={cls.id}
@@ -123,7 +144,15 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
{halls.map((hall) => (
<Reveal key={hall}>
<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">
{hallGroups[hall].map((cls) => (
<ClassCard
+20 -14
View File
@@ -2,7 +2,7 @@
import { useReducer, useMemo, useCallback } from "react";
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 { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard";
@@ -102,7 +102,10 @@ interface ScheduleProps {
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
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 isAllMode = locationMode === "all";
@@ -313,17 +316,19 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
{/* Location tabs */}
<Reveal>
<div className="mt-8 flex justify-center gap-2 flex-wrap">
{/* "All studios" tab */}
<button
onClick={() => switchLocation("all")}
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>
<span className="sm:hidden">Все</span>
</button>
{/* "All studios" tab — only when multiple locations */}
{schedule.locations.length > 1 && (
<button
onClick={() => switchLocation("all")}
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>
<span className="sm:hidden">Все</span>
</button>
)}
{/* Per-location tabs */}
{schedule.locations.map((loc, i) => (
@@ -450,6 +455,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
showLocation={isAllMode}
statuses={scheduleConfig?.statuses}
/>
</Reveal>
@@ -464,7 +470,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
key={day.day}
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>
))}
+3 -1
View File
@@ -11,9 +11,10 @@ import type { SiteContent, ScheduleLocation } from "@/types/content";
interface TeamProps {
data: SiteContent["team"];
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;
const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false);
@@ -106,6 +107,7 @@ export function Team({ data: team, schedule }: TeamProps) {
member={team.members[activeIndex]}
onBack={() => { history.back(); }}
schedule={schedule}
scheduleConfig={scheduleConfig}
/>
)}
</div>
+15 -11
View File
@@ -1,5 +1,5 @@
import { Clock, User, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import { shortAddress, findStatusConfig } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -11,6 +11,7 @@ interface DayCardProps {
toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
statuses?: { key: string; label: string; description: string }[];
}
function ClassRow({
@@ -20,6 +21,7 @@ function ClassRow({
toggleFilterTrainer,
filterTypes,
toggleFilterType,
statuses,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
@@ -27,19 +29,22 @@ function ClassRow({
toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
statuses?: { key: string; label: string; description: string }[];
}) {
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 gap-2 text-sm text-neutral-500 dark:text-white/40">
<Clock size={13} />
<span className="font-semibold">{cls.time}</span>
</div>
{cls.hasSlots && <ScheduleBadge>есть места</ScheduleBadge>}
{cls.recruiting && <ScheduleBadge>набор</ScheduleBadge>}
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
<ScheduleBadge>{cls.status}</ScheduleBadge>
)}
<div className="flex items-center gap-1.5">
{cls.status && (() => {
const cfg = findStatusConfig(statuses, cls.status);
return <ScheduleBadge>{cfg?.label || cls.status}</ScheduleBadge>;
})()}
{cls.level && <ScheduleBadge>{cls.level}</ScheduleBadge>}
</div>
</div>
<button
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="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
</button>
{cls.level && <ScheduleBadge>{cls.level}</ScheduleBadge>}
</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
const locationGroups = showLocation
? Array.from(
@@ -109,7 +113,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
</div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{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>
@@ -119,7 +123,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
// Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{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>
)}
+14 -9
View File
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import Image from "next/image";
import { User, Calendar } from "lucide-react";
import { User } from "lucide-react";
import { GroupCard } from "@/components/ui/GroupCard";
import { findStatusConfig } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -153,6 +153,14 @@ export function GroupView({
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) {
return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
@@ -163,6 +171,8 @@ export function GroupView({
return (
<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]) => {
const byType = groupByType(trainerGroups);
const isActive = filterTrainerSet.has(trainer);
@@ -218,18 +228,13 @@ export function GroupView({
const hasToday = group.slots.some(s => s.day === todayName);
const todayBadge = hasToday ? (
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 px-2.5 py-0.5 text-[10px] font-semibold text-gold">
<Calendar size={9} />
Сегодня
</span>
) : null;
const todayBadge = null;
return (
<div
key={`${type}-${gi}`}
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-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}
merged={merged}
dotColor={dotColor}
showLocation={showLocation && !!group.location}
showLocation={showLocationPerCard && !!group.location}
extraBadges={todayBadge}
onTypeClick={() => toggleFilterType(type)}
/>
@@ -1,7 +1,7 @@
"use client";
import { User, X, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import { shortAddress, findStatusConfig } from "./constants";
import { ScheduleBadge } from "@/components/ui/ScheduleBadge";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -15,6 +15,7 @@ interface MobileScheduleProps {
hasActiveFilter: boolean;
clearFilters: () => void;
showLocation?: boolean;
statuses?: { key: string; label: string; description: string }[];
}
function ClassRow({
@@ -25,6 +26,7 @@ function ClassRow({
filterTrainerSet,
toggleFilterTrainer,
showLocation,
statuses,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
@@ -33,10 +35,11 @@ function ClassRow({
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
statuses?: { key: string; label: string; description: string }[];
}) {
return (
<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 */}
<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}
</button>
{cls.hasSlots && <ScheduleBadge size="sm">места</ScheduleBadge>}
{cls.recruiting && <ScheduleBadge size="sm">набор</ScheduleBadge>}
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
<ScheduleBadge size="sm">{cls.status}</ScheduleBadge>
)}
{cls.status && (() => {
const cfg = findStatusConfig(statuses, cls.status);
return <ScheduleBadge size="sm">{cfg?.label || cls.status}</ScheduleBadge>;
})()}
{cls.level && <ScheduleBadge size="sm">{cls.level}</ScheduleBadge>}
</div>
<div className="mt-0.5 flex items-center gap-2">
@@ -89,6 +91,7 @@ export function MobileSchedule({
hasActiveFilter,
clearFilters,
showLocation,
statuses,
}: MobileScheduleProps) {
return (
<div className="mt-6 px-4 sm:hidden">
@@ -171,6 +174,7 @@ export function MobileSchedule({
toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
statuses={statuses}
/>
))}
</div>
@@ -186,6 +190,7 @@ export function MobileSchedule({
toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
statuses={statuses}
/>
))
)}
@@ -345,7 +345,7 @@ function InfoTip({ text }: { text: string }) {
?
</button>
{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 */}
<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 */}
+9 -5
View File
@@ -1,17 +1,20 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
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 type { TeamMember, RichListItem, ScheduleLocation, SiteContent } from "@/types/content";
import { findStatusConfig } from "@/components/sections/schedule/constants";
import { SignupModal } from "@/components/ui/SignupModal";
import { formatMarkup } from "@/lib/markup";
import { GroupCard } from "@/components/ui/GroupCard";
interface TeamProfileProps {
member: TeamMember;
onBack: () => void;
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 [bookingGroup, setBookingGroup] = useState<string | null>(null);
@@ -189,6 +192,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
recruiting={g.recruiting}
hasSlots={g.hasSlots}
status={g.status}
statusLabel={findStatusConfig(scheduleConfig?.statuses, g.status ?? "")?.label}
address={g.address}
location={g.location}
merged={g.merged}
@@ -202,9 +206,9 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
{/* Description */}
{member.description && (
<p className="text-sm leading-relaxed text-white/50">
{member.description}
</p>
<div className="text-sm leading-relaxed text-white/50">
{formatMarkup(member.description)}
</div>
)}
{/* Education — collapsible */}
+3 -5
View File
@@ -62,13 +62,12 @@ export function GroupCard({
<>
<span className={`${dot} shrink-0 rounded-full ${dotColor}`} />
<span className={`${typeCls} font-semibold text-white/90`}>{type}</span>
{levelBadge}
</>
);
return (
<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">
{onTypeClick ? (
<button onClick={onTypeClick} className="flex items-center gap-1.5 cursor-pointer">
@@ -83,11 +82,10 @@ export function GroupCard({
{shortAddress(address || location || "")}
</span>
)}
{hasSlots && <ScheduleBadge size={compact ? "sm" : "md"}>есть места</ScheduleBadge>}
{recruiting && <ScheduleBadge size={compact ? "sm" : "md"}>набор</ScheduleBadge>}
{status && status !== "hasSlots" && status !== "recruiting" && (
{status && (
<ScheduleBadge size={compact ? "sm" : "md"}>{statusLabel || status}</ScheduleBadge>
)}
{levelBadge}
{extraBadges}
</div>