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:
2026-03-29 20:42:14 +03:00
parent 024424c578
commit 77ad2a6b68
30 changed files with 538 additions and 418 deletions
+115 -54
View File
@@ -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={() => {
+48 -7
View File
@@ -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>
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
>
+7 -5
View File
@@ -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={() => ""}
+2 -1
View File
@@ -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}
+5 -2
View File
@@ -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} />
+24 -10
View File
@@ -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
+33 -7
View File
@@ -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>
+8 -1
View File
@@ -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
View File
@@ -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>
);
}
-11
View File
@@ -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
View File
@@ -32,7 +32,7 @@ export default function HomePage() {
<>
<ClientShell>
<Header />
<main>
<main id="main-content">
<Hero data={content.hero} />
<About
data={content.about}
+1
View File
@@ -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 ===== */
+49 -6
View File
@@ -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"
+5 -4
View File
@@ -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">
+1
View File
@@ -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 */}
+2 -2
View File
@@ -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]">
+5
View File
@@ -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"
}`}
+9 -2
View File
@@ -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 && (
<>
+4 -3
View File
@@ -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"
+28 -4
View File
@@ -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");
+11 -11
View File
@@ -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"
+28 -6
View File
@@ -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
+31 -2
View File
@@ -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">
+43 -2
View File
@@ -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);
+4 -2
View File
@@ -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>
);
+1 -1
View File
@@ -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>
+20
View File
@@ -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"
+23 -10
View File
@@ -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