Files
blackheart-website/src/components/sections/team/TeamProfile.tsx
diana.dolgolyova e4cb38c409 refactor: simplify team bio — replace complex achievements with simple list, remove experience
- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image)
- Remove VictoryItemListField, DateRangeField, CityField and related helpers
- Remove experience field from admin form and user profile (can be in bio text)
- Simplify TeamProfile: remove victory tabs, show achievements as RichCards
- Fix auto-save: snapshot comparison prevents false saves on focus/blur
- Add save on tab leave (visibilitychange) and page close (sendBeacon)
- Add save after image uploads (main photo, achievements, education)
- Auto-migrate old VictoryItem data to RichListItem format in DB parser
2026-03-25 22:53:30 +03:00

397 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin } from "lucide-react";
import type { TeamMember, RichListItem, 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 hasVictories = member.victories && member.victories.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 || 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">
{/* Victories */}
{hasVictories && (
<div>
<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={14} />
Достижения
</span>
<ScrollRow>
{member.victories!.map((item, i) => (
<RichCard key={i} item={item} onImageClick={setLightbox} />
))}
</ScrollRow>
</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>
)}
{/* 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 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>
);
}