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:
@@ -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">
|
||||||
|
|||||||
@@ -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 ?? []}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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
@@ -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,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user