- Header: throttle scroll handler via requestAnimationFrame (was firing 60+/sec)
- Auth: use crypto.timingSafeEqual for password and token signature comparison
- A11y: add role="dialog", aria-modal, aria-label to all modals (SignupModal, NewsModal, TeamProfile lightbox)
- A11y: add aria-label to close buttons, menu toggle (with aria-expanded), floating CTA
- A11y: add aria-label to MC Instagram buttons
- Error logging: add console.error with route names to all API catch blocks (admin + public)
- Fix open-day-register error leak (was returning raw DB error to client)
- Fix MasterClasses key={index} → key={item.title}
- Delete 3 unused modal components (BookingModal, MasterClassSignupModal, OpenDaySignupModal) — replaced by unified SignupModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
479 lines
20 KiB
TypeScript
479 lines
20 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
||
import Image from "next/image";
|
||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||
import { SignupModal } from "@/components/ui/SignupModal";
|
||
|
||
interface TeamProfileProps {
|
||
member: TeamMember;
|
||
onBack: () => void;
|
||
schedule?: ScheduleLocation[];
|
||
}
|
||
|
||
export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||
const [lightbox, setLightbox] = useState<string | null>(null);
|
||
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
function handleKeyDown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") {
|
||
if (lightbox) setLightbox(null);
|
||
else onBack();
|
||
}
|
||
}
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [lightbox, onBack]);
|
||
const places = member.victories?.filter(v => !v.type || v.type === 'place') ?? [];
|
||
const nominations = member.victories?.filter(v => v.type === 'nomination') ?? [];
|
||
const judging = member.victories?.filter(v => v.type === 'judge') ?? [];
|
||
const victoryTabs = [
|
||
...(places.length > 0 ? [{ key: 'place' as const, label: 'Достижения', icon: Trophy, items: places }] : []),
|
||
...(nominations.length > 0 ? [{ key: 'nomination' as const, label: 'Номинации', icon: Award, items: nominations }] : []),
|
||
...(judging.length > 0 ? [{ key: 'judge' as const, label: 'Судейство', icon: Scale, items: judging }] : []),
|
||
];
|
||
const hasVictories = victoryTabs.length > 0;
|
||
const [activeTab, setActiveTab] = useState(victoryTabs[0]?.key ?? 'place');
|
||
const hasExperience = member.experience && member.experience.length > 0;
|
||
const hasEducation = member.education && member.education.length > 0;
|
||
|
||
// Extract trainer's groups from schedule using groupId
|
||
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
|
||
schedule?.forEach(location => {
|
||
location.days.forEach(day => {
|
||
day.classes
|
||
.filter(c => c.trainer === member.name)
|
||
.forEach(c => {
|
||
const key = c.groupId
|
||
? `${c.groupId}||${location.name}`
|
||
: `${c.trainer}||${c.type}||${location.name}`;
|
||
const existing = groupMap.get(key);
|
||
if (existing) {
|
||
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
|
||
if (c.level && !existing.level) existing.level = c.level;
|
||
if (c.recruiting) existing.recruiting = true;
|
||
} else {
|
||
groupMap.set(key, {
|
||
type: c.type,
|
||
location: location.name,
|
||
address: location.address,
|
||
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
||
level: c.level,
|
||
recruiting: c.recruiting,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
const uniqueGroups = Array.from(groupMap.values()).map(g => {
|
||
// Merge slots by day, then merge days with identical time sets
|
||
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
|
||
const dayOrder: string[] = [];
|
||
for (const s of g.slots) {
|
||
const existing = dayMap.get(s.day);
|
||
if (existing) {
|
||
if (!existing.times.includes(s.time)) existing.times.push(s.time);
|
||
} else {
|
||
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
|
||
dayOrder.push(s.day);
|
||
}
|
||
}
|
||
for (const entry of dayMap.values()) entry.times.sort();
|
||
const merged: { days: string[]; times: string[] }[] = [];
|
||
const used = new Set<string>();
|
||
for (const day of dayOrder) {
|
||
if (used.has(day)) continue;
|
||
const entry = dayMap.get(day)!;
|
||
const timeKey = entry.times.join("|");
|
||
const days = [entry.dayShort];
|
||
used.add(day);
|
||
for (const other of dayOrder) {
|
||
if (used.has(other)) continue;
|
||
const o = dayMap.get(other)!;
|
||
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
|
||
}
|
||
merged.push({ days, times: entry.times });
|
||
}
|
||
return { ...g, merged };
|
||
});
|
||
const hasGroups = uniqueGroups.length > 0;
|
||
|
||
const hasBio = hasVictories || hasExperience || hasEducation || hasGroups;
|
||
|
||
return (
|
||
<div
|
||
className="w-full"
|
||
style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }}
|
||
>
|
||
{/* Magazine editorial layout */}
|
||
<div className="relative mx-auto max-w-5xl flex flex-col sm:flex-row sm:items-start">
|
||
{/* Photo — left column, sticky */}
|
||
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
|
||
<button
|
||
onClick={onBack}
|
||
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer"
|
||
>
|
||
<ArrowLeft size={14} />
|
||
Назад
|
||
</button>
|
||
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
|
||
<Image
|
||
src={member.image}
|
||
alt={member.name}
|
||
fill
|
||
sizes="(min-width: 1024px) 380px, (min-width: 640px) 340px, 100vw"
|
||
className="object-cover"
|
||
/>
|
||
{/* Top gradient for name */}
|
||
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-transparent" />
|
||
{/* Bottom gradient for mobile bio peek */}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent sm:hidden" />
|
||
|
||
{/* Name + role overlay at top */}
|
||
<div className="absolute top-0 left-0 right-0 p-6 sm:p-8">
|
||
<h3
|
||
className="text-3xl sm:text-4xl font-bold text-white leading-tight"
|
||
style={{ textShadow: "0 2px 24px rgba(0,0,0,0.6)" }}
|
||
>
|
||
{member.name}
|
||
</h3>
|
||
<p
|
||
className="mt-1.5 text-sm sm:text-base font-medium text-gold-light"
|
||
style={{ textShadow: "0 1px 12px rgba(0,0,0,0.5)" }}
|
||
>
|
||
{member.role}
|
||
</p>
|
||
{member.instagram && (
|
||
<a
|
||
href={member.instagram}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-white/70 transition-colors hover:text-gold-light"
|
||
style={{ textShadow: "0 1px 8px rgba(0,0,0,0.5)" }}
|
||
>
|
||
<Instagram size={14} />
|
||
{member.instagram.split("/").filter(Boolean).pop()}
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bio panel — overlaps photo edge on desktop */}
|
||
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
|
||
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40">
|
||
{/* Ambient photo background */}
|
||
<div className="absolute inset-0">
|
||
<Image
|
||
src={member.image}
|
||
alt=""
|
||
fill
|
||
sizes="600px"
|
||
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
|
||
/>
|
||
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
||
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||
</div>
|
||
<div className="relative p-5 sm:p-6">
|
||
{/* Victory tabs */}
|
||
{hasVictories && (
|
||
<div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{victoryTabs.map(tab => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={`inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||
activeTab === tab.key
|
||
? "border-gold/30 bg-gold/10 text-gold"
|
||
: "border-white/[0.08] bg-white/[0.03] text-white/40 hover:text-white/60"
|
||
}`}
|
||
>
|
||
<tab.icon size={14} />
|
||
{tab.label}
|
||
<span className={`ml-0.5 text-xs ${activeTab === tab.key ? "text-gold/60" : "text-white/20"}`}>
|
||
{tab.items.length}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="grid mt-4" style={{ gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }}>
|
||
{victoryTabs.map(tab => (
|
||
<div key={tab.key} className={`col-start-1 row-start-1 ${activeTab === tab.key ? "" : "invisible"}`}>
|
||
<ScrollRow>
|
||
{tab.items.map((item, i) => (
|
||
<VictoryCard key={i} victory={item} />
|
||
))}
|
||
</ScrollRow>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Groups */}
|
||
{hasGroups && (
|
||
<div className={hasVictories ? "mt-8" : ""}>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||
<Clock size={14} />
|
||
Группы
|
||
</span>
|
||
<ScrollRow>
|
||
{uniqueGroups.map((g, i) => (
|
||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
||
<div className="space-y-0.5">
|
||
{g.merged.map((m, mi) => (
|
||
<div key={mi} className="flex items-center gap-1.5 text-xs text-white/50">
|
||
<Clock size={11} className="shrink-0" />
|
||
<span className="font-medium text-white/70">{m.days.join(", ")}</span>
|
||
<span>{m.times.join(", ")}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex items-start gap-1.5 text-xs text-white/40">
|
||
<MapPin size={11} className="mt-0.5 shrink-0" />
|
||
<span>{g.location} · {g.address.replace(/^г\.\s*\S+,\s*/, "")}</span>
|
||
</div>
|
||
{g.level && (
|
||
<p className="text-[10px] text-gold/60">{g.level}</p>
|
||
)}
|
||
{g.recruiting && (
|
||
<span className="inline-block rounded-full bg-green-500/15 border border-green-500/30 px-2 py-0.5 text-[10px] text-green-400">
|
||
Набор открыт
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
|
||
className="w-full mt-1 rounded-lg bg-gold/15 border border-gold/25 py-1.5 text-[11px] font-semibold text-gold hover:bg-gold/25 transition-colors cursor-pointer"
|
||
>
|
||
Записаться
|
||
</button>
|
||
</div>
|
||
))}
|
||
</ScrollRow>
|
||
</div>
|
||
)}
|
||
|
||
{/* Education */}
|
||
{hasEducation && (
|
||
<div className={hasVictories || hasGroups ? "mt-8" : ""}>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||
<GraduationCap size={14} />
|
||
Образование
|
||
</span>
|
||
<ScrollRow>
|
||
{member.education!.map((item, i) => (
|
||
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||
))}
|
||
</ScrollRow>
|
||
</div>
|
||
)}
|
||
|
||
{/* Experience */}
|
||
{hasExperience && (
|
||
<div className={hasVictories || hasGroups || hasEducation ? "mt-8" : ""}>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||
<Trophy size={15} />
|
||
Опыт
|
||
</span>
|
||
<ScrollRow>
|
||
{member.experience!.map((item, i) => (
|
||
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3">
|
||
<p className="text-sm text-white/60">{item}</p>
|
||
</div>
|
||
))}
|
||
</ScrollRow>
|
||
</div>
|
||
)}
|
||
|
||
{/* Description */}
|
||
{member.description && (
|
||
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
|
||
{member.description}
|
||
</p>
|
||
)}
|
||
|
||
{/* Empty state */}
|
||
{!hasBio && !member.description && (
|
||
<p className="text-sm text-white/30 italic">
|
||
Информация скоро появится
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Image lightbox */}
|
||
{lightbox && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Просмотр изображения"
|
||
onClick={() => setLightbox(null)}
|
||
>
|
||
<button
|
||
onClick={() => setLightbox(null)}
|
||
aria-label="Закрыть"
|
||
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
<div className="relative max-h-[85vh] max-w-[90vw]">
|
||
<Image
|
||
src={lightbox}
|
||
alt="Достижение"
|
||
width={900}
|
||
height={900}
|
||
className="rounded-lg object-contain max-h-[85vh]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<SignupModal
|
||
open={bookingGroup !== null}
|
||
onClose={() => setBookingGroup(null)}
|
||
subtitle={bookingGroup ?? undefined}
|
||
endpoint="/api/group-booking"
|
||
extraBody={{ groupInfo: bookingGroup }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ScrollRow({ children }: { children: React.ReactNode }) {
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
||
const wasDragged = useRef(false);
|
||
|
||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||
dragState.current = { startX: e.clientX, scrollLeft: el.scrollLeft };
|
||
wasDragged.current = false;
|
||
}, []);
|
||
|
||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||
if (!dragState.current || !scrollRef.current) return;
|
||
const dx = e.clientX - dragState.current.startX;
|
||
if (Math.abs(dx) > 4) wasDragged.current = true;
|
||
scrollRef.current.scrollLeft = dragState.current.scrollLeft - dx;
|
||
}, []);
|
||
|
||
const onPointerUp = useCallback(() => {
|
||
dragState.current = null;
|
||
}, []);
|
||
|
||
return (
|
||
<div className="relative mt-4">
|
||
<div
|
||
ref={scrollRef}
|
||
className="flex items-stretch gap-3 overflow-x-auto pb-2 pt-4 cursor-grab active:cursor-grabbing select-none"
|
||
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
onLostPointerCapture={onPointerUp}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VictoryCard({ victory }: { victory: VictoryItem }) {
|
||
const hasLink = !!victory.link;
|
||
|
||
return (
|
||
<div className="group w-44 shrink-0 rounded-xl border border-white/[0.08] overflow-visible bg-white/[0.03] relative">
|
||
<div className="absolute top-0 left-0 w-1 h-full bg-gold/40 rounded-l-xl" />
|
||
{victory.place && (
|
||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||
<span className="inline-block rounded-full border border-gold/40 bg-gold/20 px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-gold whitespace-nowrap backdrop-blur-sm">
|
||
{victory.place}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className={`pl-4 pr-3 pb-3 space-y-1 ${victory.place ? "pt-6" : "py-3"}`}>
|
||
{victory.category && (
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
|
||
)}
|
||
<p className="text-sm text-white/50">{victory.competition}</p>
|
||
{hasLink && (
|
||
<a
|
||
href={victory.link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||
>
|
||
<ExternalLink size={10} />
|
||
Подробнее
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (src: string) => void }) {
|
||
const hasImage = !!item.image;
|
||
const hasLink = !!item.link;
|
||
|
||
if (hasImage) {
|
||
return (
|
||
<div className="group w-48 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
||
<button
|
||
onClick={() => onImageClick(item.image!)}
|
||
className="relative w-14 shrink-0 overflow-hidden cursor-pointer"
|
||
>
|
||
<Image
|
||
src={item.image!}
|
||
alt={item.text}
|
||
fill
|
||
sizes="56px"
|
||
className="object-cover transition-transform group-hover:scale-105"
|
||
/>
|
||
</button>
|
||
<div className="flex-1 min-w-0 p-2.5">
|
||
<p className="text-xs text-white/70">{item.text}</p>
|
||
{hasLink && (
|
||
<a
|
||
href={item.link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||
>
|
||
<ExternalLink size={11} />
|
||
Подробнее
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="group w-48 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
||
<div className="p-3">
|
||
<p className="text-sm text-white/60">{item.text}</p>
|
||
{hasLink && (
|
||
<a
|
||
href={item.link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="mt-1.5 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||
>
|
||
<ExternalLink size={11} />
|
||
Подробнее
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|