fix: comprehensive UI/UX accessibility and usability improvements
Public site: skip-to-content link, mobile menu focus trap + Escape key, aria-current on nav, keyboard navigation for carousels/tabs/articles, ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog), form labels + aria-describedby, 44px touch targets, semantic HTML (<time>, <del>), prefers-reduced-motion on Hero scroll hijack, mobile schedule filters, URL hash sync on scroll for correct refresh. Admin panel: password toggle aria-label, toast aria-live regions, SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid on validation errors, sidebar hamburger aria-label/expanded, nav aria-label, ArrayEditor aria-expanded on collapsible items.
This commit is contained in:
@@ -17,6 +17,10 @@ interface ArrayEditorProps<T> {
|
||||
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||
hiddenItems?: Set<number>;
|
||||
addPosition?: "top" | "bottom";
|
||||
/** Render grip + content + delete on a single row (compact mode) */
|
||||
inline?: boolean;
|
||||
/** Hide the add button (when parent manages adding) */
|
||||
hideAdd?: boolean;
|
||||
}
|
||||
|
||||
export function ArrayEditor<T>({
|
||||
@@ -31,6 +35,8 @@ export function ArrayEditor<T>({
|
||||
getItemBadge,
|
||||
hiddenItems,
|
||||
addPosition = "bottom",
|
||||
inline = false,
|
||||
hideAdd = false,
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
@@ -167,52 +173,80 @@ export function ArrayEditor<T>({
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
} ${isHidden ? "hidden" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{inline ? (
|
||||
/* Inline: grip + content + delete on one row */
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(i)}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -243,22 +277,34 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
const isCollapsed = collapsible && collapsed.has(i);
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||
"border-white/10"
|
||||
}`}
|
||||
>
|
||||
{collapsible ? (
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
{inline ? (
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
@@ -267,18 +313,32 @@ export function ArrayEditor<T>({
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{collapsible && (
|
||||
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -312,6 +372,7 @@ export function ArrayEditor<T>({
|
||||
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
>
|
||||
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||
</button>
|
||||
@@ -320,7 +381,7 @@ export function ArrayEditor<T>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addPosition === "top" && (
|
||||
{!hideAdd && addPosition === "top" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -344,7 +405,7 @@ export function ArrayEditor<T>({
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
{addPosition === "bottom" && (
|
||||
{!hideAdd && addPosition === "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -86,16 +86,20 @@ export function ParticipantLimits({
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||||
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||||
aria-describedby="min-hint"
|
||||
aria-invalid={minEmpty || undefined}
|
||||
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||||
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||||
aria-describedby="max-hint"
|
||||
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
|
||||
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||||
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,6 +176,7 @@ export function SelectField({
|
||||
}: SelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -185,6 +190,31 @@ export function SelectField({
|
||||
|
||||
const showSearch = options.length > 3;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(0); return; }
|
||||
setHighlightIndex((prev) => (prev + 1) % filtered.length);
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
|
||||
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
}
|
||||
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||
e.preventDefault();
|
||||
onChange(filtered[highlightIndex].value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
@@ -217,8 +247,12 @@ export function SelectField({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? search : selectedLabel}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
|
||||
onFocus={() => { setOpen(true); setSearch(""); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
@@ -228,6 +262,9 @@ export function SelectField({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
@@ -237,7 +274,7 @@ export function SelectField({
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
@@ -246,16 +283,20 @@ export function SelectField({
|
||||
<button
|
||||
key={opt.value || `opt-${idx}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightIndex(idx)}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
setHighlightIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||
}`}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function SectionEditor<T>({
|
||||
|
||||
{/* Fixed toast popup */}
|
||||
{(status === "saved" || status === "error") && (
|
||||
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
status === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
<ToastContext.Provider value={{ showError, showSuccess }}>
|
||||
{children}
|
||||
{toasts.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
@@ -54,6 +54,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
aria-label="Закрыть уведомление"
|
||||
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
||||
className="shrink-0 text-neutral-400 hover:text-white"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { InputField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
|
||||
interface AboutData {
|
||||
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
|
||||
label="Параграфы"
|
||||
items={data.paragraphs}
|
||||
onChange={(paragraphs) => update({ ...data, paragraphs })}
|
||||
inline
|
||||
renderItem={(text, _i, updateItem) => (
|
||||
<TextareaField
|
||||
label={`Параграф`}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={updateItem}
|
||||
rows={3}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none"
|
||||
placeholder="Текст параграфа..."
|
||||
/>
|
||||
)}
|
||||
createItem={() => ""}
|
||||
|
||||
@@ -115,6 +115,7 @@ function VideoSlot({
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Удалить видео: ${label}`}
|
||||
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
title="Удалить"
|
||||
>
|
||||
@@ -300,7 +301,7 @@ function VideoManager({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-3 max-w-3xl">
|
||||
{SLOTS.map((slot, i) => (
|
||||
<VideoSlot
|
||||
key={slot.key}
|
||||
|
||||
@@ -107,13 +107,14 @@ export default function AdminLayout({
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Закрыть меню"
|
||||
className="lg:hidden text-neutral-400 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
<nav aria-label="Навигация панели управления" className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
@@ -131,7 +132,7 @@ export default function AdminLayout({
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
<span aria-label={`${unreadTotal} непрочитанных`} className="ml-auto rounded-full bg-red-500 text-white text-xs font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||
</span>
|
||||
)}
|
||||
@@ -165,6 +166,8 @@ export default function AdminLayout({
|
||||
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded={sidebarOpen}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -48,19 +50,31 @@ export default function AdminLoginPage() {
|
||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 pr-11 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
aria-describedby={error ? "login-error" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400 text-center">{error}</p>
|
||||
<p id="login-error" role="alert" className="text-sm text-red-400 text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -84,9 +84,23 @@ function EventSettings({
|
||||
<input
|
||||
type="date"
|
||||
value={event.date}
|
||||
onChange={(e) => onChange({ date: e.target.value })}
|
||||
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 [color-scheme:dark]"
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
|
||||
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
event.date && event.date < new Date().toISOString().slice(0, 10)
|
||||
? "border-amber-500/50"
|
||||
: "border-white/10 focus:border-gold"
|
||||
}`}
|
||||
/>
|
||||
{!event.date && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Укажите дату для публикации</p>
|
||||
)}
|
||||
{event.date && event.date < new Date().toISOString().slice(0, 10) && (
|
||||
<p className="mt-1 text-[11px] text-amber-400">Дата в прошлом — переведено в черновик</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,16 +172,28 @@ function EventSettings({
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ active: !event.active })}
|
||||
onClick={() => {
|
||||
const isPast = !event.date || event.date < new Date().toISOString().slice(0, 10);
|
||||
if (!event.active && isPast) return;
|
||||
onChange({ active: !event.active });
|
||||
}}
|
||||
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.active
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10"
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
|
||||
: !event.date || event.date < new Date().toISOString().slice(0, 10)
|
||||
? "bg-neutral-800 text-neutral-500 border border-white/5 opacity-50 cursor-not-allowed"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5"
|
||||
}`}
|
||||
>
|
||||
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
|
||||
{event.active ? "Опубликовано" : "Черновик"}
|
||||
{event.active ? (
|
||||
<><CheckCircle2 size={14} /> Опубликовано</>
|
||||
) : (
|
||||
<><Ban size={14} /> Черновик</>
|
||||
)}
|
||||
</button>
|
||||
{!event.active && (!event.date || event.date < new Date().toISOString().slice(0, 10)) && (
|
||||
<span className="text-[11px] text-amber-400">Укажите будущую дату для публикации</span>
|
||||
)}
|
||||
<span className="text-xs text-neutral-500">
|
||||
{event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`}
|
||||
</span>
|
||||
|
||||
@@ -192,8 +192,15 @@ function PricingContent({ data, update }: { data: PricingData; update: (d: Prici
|
||||
<ArrayEditor
|
||||
items={data.rules}
|
||||
onChange={(rules) => update({ ...data, rules })}
|
||||
inline
|
||||
renderItem={(rule, _i, updateItem) => (
|
||||
<InputField label="Правило" value={rule} onChange={updateItem} />
|
||||
<input
|
||||
type="text"
|
||||
value={rule}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors"
|
||||
placeholder="Текст правила..."
|
||||
/>
|
||||
)}
|
||||
createItem={() => ""}
|
||||
addLabel="Добавить правило"
|
||||
|
||||
+27
-262
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Plus, Check } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import type { TeamMember } from "@/types/content";
|
||||
|
||||
type Member = TeamMember & { id: number };
|
||||
@@ -22,13 +16,6 @@ export default function TeamEditorPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/team")
|
||||
.then((r) => r.json())
|
||||
@@ -49,117 +36,7 @@ export default function TeamEditorPage() {
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(clientX: number, clientY: number, index: number) => {
|
||||
const el = itemRefs.current[index];
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setDragIndex(index);
|
||||
setInsertAt(index);
|
||||
setMousePos({ x: clientX, y: clientY });
|
||||
setDragSize({ w: rect.width, h: rect.height });
|
||||
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleGripMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.clientY, index);
|
||||
},
|
||||
[startDrag]
|
||||
);
|
||||
|
||||
const handleCardMouseDown = useCallback(
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||
if (tag) return;
|
||||
e.preventDefault();
|
||||
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
const pendingIndex = index;
|
||||
let moved = false;
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
const dx = ev.clientX - x;
|
||||
const dy = ev.clientY - y;
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
moved = true;
|
||||
cleanup();
|
||||
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||
}
|
||||
}
|
||||
function onUp() {
|
||||
cleanup();
|
||||
if (!moved) {
|
||||
window.location.href = `/admin/team/${members[pendingIndex].id}`;
|
||||
}
|
||||
}
|
||||
function cleanup() {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[startDrag, members]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragIndex === null) return;
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
|
||||
let newInsert = members.length;
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
if (i === dragIndex) continue;
|
||||
const el = itemRefs.current[i];
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
newInsert = i > dragIndex! ? i : i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setInsertAt(newInsert);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
setDragIndex((prevDrag) => {
|
||||
setInsertAt((prevInsert) => {
|
||||
if (prevDrag !== null && prevInsert !== null) {
|
||||
let targetIndex = prevInsert;
|
||||
if (prevDrag < targetIndex) targetIndex -= 1;
|
||||
if (prevDrag !== targetIndex) {
|
||||
const updated = [...members];
|
||||
const [moved] = updated.splice(prevDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
saveOrder(updated);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dragIndex, members, saveOrder]);
|
||||
|
||||
async function deleteMember(id: number) {
|
||||
if (!confirm("Удалить этого участника?")) return;
|
||||
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
}
|
||||
@@ -173,109 +50,6 @@ export default function TeamEditorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
|
||||
|
||||
// Build the visual order: remove dragged item, insert placeholder at insertAt
|
||||
function renderList() {
|
||||
if (dragIndex === null || insertAt === null) {
|
||||
// Normal render — no drag
|
||||
return members.map((member, i) => (
|
||||
<div
|
||||
key={member.id}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// During drag: build list without the dragged item, with placeholder inserted
|
||||
const elements: React.ReactNode[] = [];
|
||||
let visualIndex = 0;
|
||||
|
||||
// Determine where to insert placeholder relative to non-dragged items
|
||||
let placeholderPos = insertAt;
|
||||
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
if (i === dragIndex) {
|
||||
// Keep a hidden ref so midpoint detection still works
|
||||
elements.push(
|
||||
<div key={`hidden-${members[i].id}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const member = members[i];
|
||||
elements.push(
|
||||
<div
|
||||
key={member.id}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
>
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
visualIndex++;
|
||||
}
|
||||
|
||||
// Placeholder at the end
|
||||
if (visualIndex === placeholderPos) {
|
||||
elements.push(
|
||||
<div
|
||||
key="placeholder"
|
||||
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||
style={{ height: dragSize.h }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -302,42 +76,33 @@ export default function TeamEditorPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{renderList()}
|
||||
</div>
|
||||
|
||||
{/* Floating card following cursor */}
|
||||
{dragIndex !== null &&
|
||||
draggedMember &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: mousePos.x - grabOffset.x,
|
||||
top: mousePos.y - grabOffset.y,
|
||||
width: dragSize.w,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
|
||||
<div className="text-rose-400">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={draggedMember.image}
|
||||
alt={draggedMember.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
/>
|
||||
<ArrayEditor
|
||||
items={members}
|
||||
onChange={saveOrder}
|
||||
createItem={() => ({ id: 0, name: "", role: "", image: "" })}
|
||||
inline
|
||||
hideAdd
|
||||
getItemTitle={(m) => m.name || "Новый участник"}
|
||||
renderItem={(member) => (
|
||||
<Link
|
||||
href={`/admin/team/${member.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
{member.image ? (
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{draggedMember.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,6 @@ export async function POST(request: NextRequest) {
|
||||
if (!body.date || typeof body.date !== "string") {
|
||||
return NextResponse.json({ error: "date is required" }, { status: 400 });
|
||||
}
|
||||
// Warn if date is in the past
|
||||
const eventDate = new Date(body.date + "T23:59:59");
|
||||
if (eventDate < new Date()) {
|
||||
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||
}
|
||||
const id = createOpenDayEvent(body);
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
@@ -45,12 +40,6 @@ export async function PUT(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
const { id, ...data } = body;
|
||||
if (data.date) {
|
||||
const eventDate = new Date(data.date + "T23:59:59");
|
||||
if (eventDate < new Date()) {
|
||||
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
updateOpenDayEvent(id, data);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function HomePage() {
|
||||
<>
|
||||
<ClientShell>
|
||||
<Header />
|
||||
<main>
|
||||
<main id="main-content">
|
||||
<Hero data={content.hero} />
|
||||
<About
|
||||
data={content.about}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||
@apply dark:bg-gold dark:text-black;
|
||||
@apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-gold-light focus-visible:ring-offset-2 focus-visible:ring-offset-black;
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
@@ -14,6 +14,8 @@ export function Header() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const { bookingOpen, openBooking, closeBooking } = useBooking();
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const firstNavLinkRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
@@ -30,6 +32,33 @@ export function Header() {
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on Escape key
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [menuOpen]);
|
||||
|
||||
// Focus management: focus first nav link when menu opens, return focus to button when it closes
|
||||
const prevMenuOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (menuOpen && !prevMenuOpenRef.current) {
|
||||
// Menu just opened — focus first nav link
|
||||
requestAnimationFrame(() => {
|
||||
firstNavLinkRef.current?.focus();
|
||||
});
|
||||
} else if (!menuOpen && prevMenuOpenRef.current) {
|
||||
// Menu just closed — return focus to menu button
|
||||
menuButtonRef.current?.focus();
|
||||
}
|
||||
prevMenuOpenRef.current = menuOpen;
|
||||
}, [menuOpen]);
|
||||
|
||||
// Filter out nav links whose target section doesn't exist on the page
|
||||
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||
useEffect(() => {
|
||||
@@ -45,7 +74,12 @@ export function Header() {
|
||||
if (hero) {
|
||||
const heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setActiveSection("");
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection("");
|
||||
if (window.location.hash) {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px" },
|
||||
);
|
||||
@@ -61,6 +95,10 @@ export function Header() {
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(id);
|
||||
// Sync URL hash so refresh returns to the current section
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, "", `#${id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-40% 0px -55% 0px" },
|
||||
@@ -80,6 +118,7 @@ export function Header() {
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:bg-gold focus:text-black focus:rounded font-medium">Перейти к содержимому</a>
|
||||
<div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16">
|
||||
<Link href="/" className="group flex items-center gap-2.5">
|
||||
<div className="relative flex h-8 w-8 items-center justify-center">
|
||||
@@ -99,14 +138,15 @@ export function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
|
||||
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`relative whitespace-nowrap py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||
isActive
|
||||
? "text-gold-light after:w-full"
|
||||
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||
@@ -120,6 +160,7 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||
aria-expanded={menuOpen}
|
||||
@@ -136,14 +177,16 @@ export function Header() {
|
||||
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<nav className="border-t border-white/[0.06] px-6 py-4 text-center sm:px-8">
|
||||
{visibleLinks.map((link) => {
|
||||
<nav className="border-t border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link, index) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
ref={index === 0 ? firstNavLinkRef : undefined}
|
||||
href={link.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`block py-3 text-base transition-colors ${
|
||||
isActive
|
||||
? "text-gold-light"
|
||||
|
||||
@@ -16,9 +16,9 @@ interface AboutProps {
|
||||
|
||||
export function About({ data: about, stats }: AboutProps) {
|
||||
const statItems = [
|
||||
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
|
||||
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
|
||||
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
|
||||
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров", ariaLabel: `${stats.trainers} тренеров` },
|
||||
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений", ariaLabel: `${stats.classes} направлений` },
|
||||
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске", ariaLabel: `${stats.locations} зала в Минске` },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -45,9 +45,10 @@ export function About({ data: about, stats }: AboutProps) {
|
||||
{statItems.map((stat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-label={stat.ariaLabel}
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light" aria-hidden="true">
|
||||
{stat.icon}
|
||||
</div>
|
||||
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
||||
|
||||
@@ -44,6 +44,7 @@ export function Classes({ data: classes }: ClassesProps) {
|
||||
activeIndex={activeIndex}
|
||||
onSelect={select}
|
||||
onHoverChange={setHovering}
|
||||
getItemLabel={(item) => item.name}
|
||||
renderDetail={(item) => (
|
||||
<div>
|
||||
{/* Hero image */}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Contact({ data: contact }: ContactProps) {
|
||||
|
||||
<div className="mt-10 space-y-5">
|
||||
{contact.addresses.map((address, i) => (
|
||||
<div key={i} className="group flex items-center gap-4">
|
||||
<div key={i} className="group flex items-center gap-4" aria-label="Наш адрес">
|
||||
<IconBadge><MapPin size={18} /></IconBadge>
|
||||
<p className="body-text">{address}</p>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@ export function Contact({ data: contact }: ContactProps) {
|
||||
|
||||
<div className="group flex items-center gap-4">
|
||||
<IconBadge><Clock size={18} /></IconBadge>
|
||||
<p className="body-text">{contact.workingHours}</p>
|
||||
<p className="body-text"><time>{contact.workingHours}</time></p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
||||
|
||||
@@ -45,8 +45,10 @@ export function FAQ({ data: faq }: FAQProps) {
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
id={`faq-button-${idx}`}
|
||||
onClick={() => toggle(idx)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-panel-${idx}`}
|
||||
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
|
||||
>
|
||||
{/* Number badge */}
|
||||
@@ -73,6 +75,9 @@ export function FAQ({ data: faq }: FAQProps) {
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={`faq-panel-${idx}`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-button-${idx}`}
|
||||
className={`grid transition-all duration-300 ease-out ${
|
||||
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
}`}
|
||||
|
||||
@@ -24,7 +24,12 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
const centerVideo = videos[Math.floor(videos.length / 2)] || videos[0];
|
||||
const totalVideos = videos.slice(0, 3).length + 1; // desktop (3) + mobile (1)
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
const prefersReducedMotion = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}, []);
|
||||
|
||||
const handleVideoReady = useCallback(() => {
|
||||
readyCount.current += 1;
|
||||
@@ -48,6 +53,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
if (!el) return;
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (prefersReducedMotion.current) return;
|
||||
if (e.deltaY <= 0 || scrolledRef.current) return;
|
||||
if (window.scrollY > 10) return;
|
||||
scrolledRef.current = true;
|
||||
@@ -60,6 +66,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (prefersReducedMotion.current) return;
|
||||
const startY = Number((el as HTMLElement).dataset.touchY);
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
if (startY - endY > 50 && !scrolledRef.current && window.scrollY < 10) {
|
||||
@@ -80,7 +87,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
}, [scrollToNext]);
|
||||
|
||||
return (
|
||||
<section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
|
||||
<section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
|
||||
{/* Videos render only after hydration to avoid SSR mismatch */}
|
||||
{mounted && (
|
||||
<>
|
||||
|
||||
@@ -127,11 +127,11 @@ function MasterClassCard({
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
|
||||
{/* Tags row */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
|
||||
{item.style}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-[11px] text-white/60 backdrop-blur-md">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/60 backdrop-blur-md">
|
||||
<Clock size={10} />
|
||||
{duration}
|
||||
</span>
|
||||
@@ -168,7 +168,7 @@ function MasterClassCard({
|
||||
|
||||
{/* Spots info */}
|
||||
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
|
||||
<div className="mb-3 flex items-center gap-3 text-[11px]">
|
||||
<div className="mb-3 flex items-center gap-3 text-xs">
|
||||
{maxP > 0 && (
|
||||
<span className={isFull ? "text-amber-400" : "text-white/40"}>
|
||||
{currentRegs}/{maxP} мест
|
||||
@@ -186,6 +186,7 @@ function MasterClassCard({
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onSignup}
|
||||
aria-label={`Записаться на ${item.title}`}
|
||||
className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
|
||||
isFull
|
||||
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
|
||||
|
||||
@@ -20,8 +20,18 @@ function FeaturedArticle({
|
||||
item: NewsItem;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group relative overflow-hidden rounded-3xl cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -47,7 +57,7 @@ function FeaturedArticle({
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm">
|
||||
<Calendar size={12} />
|
||||
{formatDateRu(item.date)}
|
||||
<time dateTime={item.date}>{formatDateRu(item.date)}</time>
|
||||
</span>
|
||||
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight">
|
||||
{item.title}
|
||||
@@ -67,8 +77,18 @@ function CompactArticle({
|
||||
item: NewsItem;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-4 items-start py-5 border-b border-neutral-200/60 last:border-0 dark:border-white/[0.06] cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -89,9 +109,9 @@ function CompactArticle({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-neutral-400 dark:text-white/30">
|
||||
<time dateTime={item.date} className="text-xs text-neutral-400 dark:text-white/30">
|
||||
{formatDateRu(item.date)}
|
||||
</span>
|
||||
</time>
|
||||
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
@@ -153,6 +173,7 @@ export function News({ data }: NewsProps) {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
aria-label="Предыдущая страница"
|
||||
onClick={() => {
|
||||
setPage((p) => Math.max(0, p - 1));
|
||||
const el = document.getElementById("news");
|
||||
@@ -171,7 +192,9 @@ export function News({ data }: NewsProps) {
|
||||
const el = document.getElementById("news");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}}
|
||||
className={`h-8 w-8 rounded-full text-sm font-medium transition-colors cursor-pointer ${
|
||||
aria-label={`Страница ${i + 1}`}
|
||||
aria-current={i === page ? "page" : undefined}
|
||||
className={`h-10 w-10 rounded-full text-sm font-medium transition-colors cursor-pointer ${
|
||||
i === page
|
||||
? "bg-gold text-black"
|
||||
: "border border-white/10 text-neutral-400 hover:text-white hover:border-white/25"
|
||||
@@ -181,6 +204,7 @@ export function News({ data }: NewsProps) {
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
aria-label="Следующая страница"
|
||||
onClick={() => {
|
||||
setPage((p) => Math.min(totalPages - 1, p + 1));
|
||||
const el = document.getElementById("news");
|
||||
|
||||
@@ -71,7 +71,7 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
|
||||
<div className="mt-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
|
||||
<Calendar size={16} />
|
||||
{formatDateRu(event.date)}
|
||||
<time dateTime={event.date}>{formatDateRu(event.date)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -177,12 +177,12 @@ function ClassCard({
|
||||
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-[11px] font-bold text-neutral-500">
|
||||
{cls.startTime}–{cls.endTime}
|
||||
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs font-bold text-neutral-500">
|
||||
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}–{cls.endTime}</time>
|
||||
</span>
|
||||
<p className="text-sm text-neutral-500 line-through">{cls.trainer} · {cls.style}</p>
|
||||
<p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p>
|
||||
</div>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium">
|
||||
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium">
|
||||
Отменено
|
||||
</span>
|
||||
</div>
|
||||
@@ -205,11 +205,11 @@ function ClassCard({
|
||||
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
|
||||
}}
|
||||
aria-label={`Профиль тренера: ${cls.trainer}`}
|
||||
className="relative flex items-center justify-center h-10 w-10 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
|
||||
className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
|
||||
title={`Подробнее о ${cls.trainer}`}
|
||||
>
|
||||
{trainerPhoto ? (
|
||||
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="40px" />
|
||||
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="44px" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full bg-white/[0.06]">
|
||||
<User size={16} className="text-white/40" />
|
||||
@@ -231,8 +231,8 @@ function ClassCard({
|
||||
{/* Time + style */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[80px] text-center">
|
||||
{cls.startTime}–{cls.endTime}
|
||||
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-xs font-bold text-gold min-w-[80px] text-center">
|
||||
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}–{cls.endTime}</time>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white/60">{cls.style}</span>
|
||||
</div>
|
||||
@@ -241,7 +241,7 @@ function ClassCard({
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{maxParticipants > 0 && (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
isFull
|
||||
? "bg-amber-500/15 border border-amber-500/25 text-amber-400"
|
||||
: "bg-white/[0.04] border border-white/[0.08] text-white/45"
|
||||
@@ -255,7 +255,7 @@ function ClassCard({
|
||||
{/* Book button */}
|
||||
<button
|
||||
onClick={() => onSignup({ classId: cls.id, label })}
|
||||
className={`shrink-0 self-center rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer ${
|
||||
className={`shrink-0 self-center rounded-xl px-4 py-2.5 text-xs font-semibold transition-all cursor-pointer ${
|
||||
isFull
|
||||
? "bg-amber-500/10 border border-amber-500/25 text-amber-400 hover:bg-amber-500/20 hover:border-amber-500/40"
|
||||
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
@@ -14,12 +14,27 @@ interface PricingProps {
|
||||
|
||||
export function Pricing({ data: pricing }: PricingProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
|
||||
{ id: "rules", label: "Правила", icon: <ScrollText size={16} /> },
|
||||
];
|
||||
|
||||
function handleTabKeyDown(e: React.KeyboardEvent, index: number) {
|
||||
let nextIndex: number | null = null;
|
||||
if (e.key === "ArrowRight") {
|
||||
nextIndex = (index + 1) % tabs.length;
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
nextIndex = (index - 1 + tabs.length) % tabs.length;
|
||||
}
|
||||
if (nextIndex !== null) {
|
||||
e.preventDefault();
|
||||
setActiveTab(tabs[nextIndex].id);
|
||||
tabRefs.current[nextIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Split items: featured (big card) vs regular
|
||||
const featuredItem = pricing.items.find((item) => item.featured);
|
||||
const regularItems = pricing.items.filter((item) => !item.featured);
|
||||
@@ -34,11 +49,18 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
|
||||
{/* Tabs */}
|
||||
<Reveal>
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-2">
|
||||
{tabs.map((tab) => (
|
||||
<div role="tablist" aria-label="Разделы цен" className="mt-12 flex flex-wrap justify-center gap-2">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => { tabRefs.current[index] = el; }}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
id={`tab-${tab.id}`}
|
||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||
activeTab === tab.id
|
||||
? "bg-gold text-black shadow-lg shadow-gold/25"
|
||||
@@ -53,7 +75,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
</Reveal>
|
||||
|
||||
{/* Prices tab */}
|
||||
<div className={activeTab === "prices" ? "block" : "hidden"}>
|
||||
<div id="tabpanel-prices" role="tabpanel" aria-labelledby="tab-prices" className={activeTab === "prices" ? "block" : "hidden"}>
|
||||
<div className="mx-auto mt-10 max-w-4xl">
|
||||
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{pricing.subtitle}
|
||||
@@ -132,7 +154,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
</div>
|
||||
|
||||
{/* Rental tab */}
|
||||
<div className={activeTab === "rental" ? "block" : "hidden"}>
|
||||
<div id="tabpanel-rental" role="tabpanel" aria-labelledby="tab-rental" className={activeTab === "rental" ? "block" : "hidden"}>
|
||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||
{pricing.rentalItems.map((item, i) => (
|
||||
<div
|
||||
@@ -158,7 +180,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
</div>
|
||||
|
||||
{/* Rules tab */}
|
||||
<div className={activeTab === "rules" ? "block" : "hidden"}>
|
||||
<div id="tabpanel-rules" role="tabpanel" aria-labelledby="tab-rules" className={activeTab === "rules" ? "block" : "hidden"}>
|
||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||
{pricing.rules.map((rule, i) => (
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useReducer, useMemo, useCallback } from "react";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
||||
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { DayCard } from "./schedule/DayCard";
|
||||
@@ -336,7 +336,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
<span className="text-center">
|
||||
<span className="block leading-tight">{loc.name}</span>
|
||||
{loc.address && (
|
||||
<span className={`block text-[10px] font-normal leading-tight mt-0.5 ${
|
||||
<span className={`block text-xs font-normal leading-tight mt-0.5 ${
|
||||
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25"
|
||||
}`}>
|
||||
{shortAddress(loc.address)}
|
||||
@@ -348,6 +348,35 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Mobile filter button — visible only on small screens */}
|
||||
<Reveal>
|
||||
<div className="mt-4 flex sm:hidden justify-center">
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
types={types}
|
||||
availableStatuses={availableStatuses}
|
||||
levels={levels}
|
||||
filterTypes={filterTypes}
|
||||
toggleFilterType={toggleFilterType}
|
||||
filterTrainerSet={filterTrainerSet}
|
||||
toggleFilterTrainer={toggleFilterTrainer}
|
||||
filterStatusSet={filterStatusSet}
|
||||
toggleFilterStatus={toggleFilterStatus}
|
||||
filterLevel={filterLevel}
|
||||
setFilterLevel={setFilterLevel}
|
||||
filterTime={filterTime}
|
||||
setFilterTime={setFilterTime}
|
||||
availableDays={availableDays}
|
||||
filterDaySet={filterDaySet}
|
||||
toggleDay={toggleDay}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
trainerNames={trainerNames}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* View mode toggle + filter button */}
|
||||
<Reveal>
|
||||
<div className="mt-4 hidden sm:flex items-center justify-center">
|
||||
|
||||
@@ -52,6 +52,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
const wasDragRef = useRef(false);
|
||||
const pausedUntilRef = useRef(0);
|
||||
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [swipeHintVisible, setSwipeHintVisible] = useState(true);
|
||||
|
||||
// Pause auto-rotation when activeIndex changes externally (e.g. dot click)
|
||||
const prevIndexRef = useRef(activeIndex);
|
||||
@@ -72,6 +74,27 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
return () => clearInterval(id);
|
||||
}, [total, activeIndex, onActiveChange]);
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||
onActiveChange(wrapIndex(activeIndex - 1, total));
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
pausedUntilRef.current = Date.now() + PAUSE_MS;
|
||||
onActiveChange(wrapIndex(activeIndex + 1, total));
|
||||
}
|
||||
},
|
||||
[activeIndex, total, onActiveChange],
|
||||
);
|
||||
|
||||
// Hide swipe hint after first interaction
|
||||
const hideSwipeHint = useCallback(() => {
|
||||
if (swipeHintVisible) setSwipeHintVisible(false);
|
||||
}, [swipeHintVisible]);
|
||||
|
||||
// Pointer handlers
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
@@ -80,8 +103,9 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
wasDragRef.current = false;
|
||||
dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
|
||||
setDragOffset(0);
|
||||
hideSwipeHint();
|
||||
},
|
||||
[activeIndex]
|
||||
[activeIndex, hideSwipeHint]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
@@ -164,13 +188,19 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
|
||||
ref={containerRef}
|
||||
role="region"
|
||||
aria-label="Карусель команды"
|
||||
aria-roledescription="carousel"
|
||||
tabIndex={0}
|
||||
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y focus:outline-none focus-visible:ring-2 focus-visible:ring-gold/50 focus-visible:rounded-2xl"
|
||||
style={{ height: UI_CONFIG.team.stageHeight }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Spotlight cone */}
|
||||
<div
|
||||
@@ -184,6 +214,15 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
/>
|
||||
|
||||
{/* Cards */}
|
||||
{/* Mobile swipe hint */}
|
||||
<div
|
||||
className={`absolute bottom-2 left-1/2 -translate-x-1/2 z-20 text-xs text-neutral-400 tracking-wide transition-opacity duration-1000 md:hidden ${
|
||||
swipeHintVisible ? "opacity-60" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
← свайп →
|
||||
</div>
|
||||
|
||||
{members.map((m, i) => {
|
||||
const style = getCardStyle(i);
|
||||
if (!style) return null;
|
||||
@@ -191,6 +230,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
return (
|
||||
<div
|
||||
key={m.name}
|
||||
role="group"
|
||||
aria-label={m.name}
|
||||
onClick={() => {
|
||||
if (!style.isCenter && !wasDragRef.current) {
|
||||
onActiveChange(i);
|
||||
|
||||
@@ -6,6 +6,7 @@ interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
@@ -20,8 +21,9 @@ export function Button({
|
||||
children,
|
||||
className = "",
|
||||
onClick,
|
||||
disabled,
|
||||
}: ButtonProps) {
|
||||
const classes = `btn-primary ${sizes[size]} ${className}`;
|
||||
const classes = `btn-primary ${sizes[size]} disabled:opacity-50 disabled:cursor-not-allowed ${className}`;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
@@ -32,7 +34,7 @@ export function Button({
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={classes}>
|
||||
<button onClick={onClick} className={classes} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
||||
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ShowcaseLayoutProps<T> {
|
||||
renderDetail: (item: T, index: number) => React.ReactNode;
|
||||
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
|
||||
counter?: boolean;
|
||||
getItemLabel?: (item: T, index: number) => string;
|
||||
}
|
||||
|
||||
export function ShowcaseLayout<T>({
|
||||
@@ -21,6 +22,7 @@ export function ShowcaseLayout<T>({
|
||||
renderDetail,
|
||||
renderSelectorItem,
|
||||
counter = false,
|
||||
getItemLabel,
|
||||
}: ShowcaseLayoutProps<T>) {
|
||||
const selectorRef = useRef<HTMLDivElement>(null);
|
||||
const activeItemRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -121,6 +123,20 @@ export function ShowcaseLayout<T>({
|
||||
[activeIndex, items.length, onSelect],
|
||||
);
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
if (activeIndex > 0) onSelect(activeIndex - 1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
if (activeIndex < items.length - 1) onSelect(activeIndex + 1);
|
||||
}
|
||||
},
|
||||
[activeIndex, items.length, onSelect],
|
||||
);
|
||||
|
||||
function handleMouseEnter() {
|
||||
setIsUserInteracting(true);
|
||||
onHoverChange?.(true);
|
||||
@@ -136,12 +152,14 @@ export function ShowcaseLayout<T>({
|
||||
className="flex flex-col-reverse gap-6 lg:flex-row lg:gap-8"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Detail area */}
|
||||
<div className="lg:w-[60%]">
|
||||
<div
|
||||
ref={detailWrapRef}
|
||||
style={minHeight != null ? { minHeight } : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
ref={detailRef}
|
||||
@@ -182,6 +200,8 @@ export function ShowcaseLayout<T>({
|
||||
key={i}
|
||||
ref={i === activeIndex ? activeItemRef : null}
|
||||
onClick={() => onSelect(i)}
|
||||
aria-label={getItemLabel ? getItemLabel(item, i) : `Элемент ${i + 1}`}
|
||||
aria-pressed={i === activeIndex}
|
||||
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
|
||||
i === activeIndex
|
||||
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
|
||||
|
||||
@@ -153,7 +153,7 @@ export function SignupModal({
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -223,29 +223,40 @@ export function SignupModal({
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="signup-name" className="sr-only">Ваше имя</label>
|
||||
<input
|
||||
id="signup-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ваше имя"
|
||||
required
|
||||
aria-required="true"
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label htmlFor="signup-phone" className="sr-only">Телефон</label>
|
||||
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||
placeholder="+375 (__) ___-__-__"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby={error && error !== "network" ? "error-phone" : undefined}
|
||||
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="relative">
|
||||
<label htmlFor="signup-instagram" className="sr-only">Instagram (необязательно)</label>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
id="signup-instagram"
|
||||
type="text"
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||
@@ -254,8 +265,10 @@ export function SignupModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label htmlFor="signup-telegram" className="sr-only">Telegram (необязательно)</label>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||
<input
|
||||
id="signup-telegram"
|
||||
type="text"
|
||||
value={telegram}
|
||||
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
|
||||
@@ -266,7 +279,7 @@ export function SignupModal({
|
||||
</div>
|
||||
|
||||
{error && error !== "network" && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<p id="error-phone" className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user