"use client"; import { ArrowUpRight, MapPin } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion, type Variants, } from "motion/react"; import { type ReactNode, useId, useMemo, useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/motion/dialog"; import { EASE_OUT, EASE_OUT_CSS, SPRING_LAYOUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export interface TeamSocialLink { /** Accessible name, e.g. "GitHub". */ label: string; href: string; icon: ReactNode; } export interface TeamMember { id: string; name: string; role: string; /** Filter group, e.g. "Engineering". */ department?: string; bio?: ReactNode; location?: string; /** Portrait: an image URL or any node (illustration, gradient). Falls back to initials. */ photo?: string | ReactNode; /** Background for the initials fallback (any CSS color). */ color?: string; socials?: TeamSocialLink[]; } export interface TeamSectionProps { members: TeamMember[]; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; /** Show department chips. Default true when members span 2+ departments. */ filterable?: boolean; /** Department order. Defaults to first-seen order in `members`. */ departments?: string[]; allLabel?: string; /** Controlled active department (`allLabel` or a department). */ department?: string; defaultDepartment?: string; onDepartmentChange?: (department: string) => void; /** Most columns at wide container sizes. Default 4. */ columns?: 2 | 3 | 4; /** Called when a member's detail panel opens. */ onMemberOpen?: (member: TeamMember) => void; className?: string; } // Static class maps so Tailwind sees every class. const GRID_COLS: Record<2 | 3 | 4, string> = { 2: "@lg:grid-cols-2", 3: "@lg:grid-cols-3", 4: "@lg:grid-cols-3 @3xl:grid-cols-4", }; const STAGGER = 0.06; const CHART_VARS = [1, 2, 3, 4, 5].map((n) => `var(--chart-${n})`); /** Deterministic fallback color from the name, so SSR and client agree. */ function colorFor(member: TeamMember) { if (member.color) return member.color; let hash = 0; for (const ch of member.name) hash = (hash * 31 + ch.charCodeAt(0)) | 0; return CHART_VARS[Math.abs(hash) % CHART_VARS.length]; } function initials(name: string) { const parts = name.trim().split(/\s+/); const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : ""; return ((parts[0]?.[0] ?? "") + last).toUpperCase(); } function Portrait({ member, className }: { member: TeamMember; className?: string }) { if (typeof member.photo === "string") { return ( // biome-ignore lint/performance/noImgElement: library component, framework-agnostic. ); } if (member.photo) { return
{member.photo}
; } const color = colorFor(member); return (
{initials(member.name)}
); } function SocialLinks({ socials, size = "sm", className, }: { socials: TeamSocialLink[]; size?: "sm" | "md"; className?: string; }) { return ( ); } /** * Team page section: a header (eyebrow, heading, description), department * filter chips with a gliding pill, and a grid of member cards that stagger in * on view. Hovering or focusing a card slides up its bio and social links; * clicking a member opens a detail dialog with the full bio and links. The * grid reflows with spring layout when the filter changes and picks its * column count from its own container width. */ export function TeamSection({ members, eyebrow = "Team", heading = "The people behind the work", description, filterable, departments: departmentsProp, allLabel = "Everyone", department: departmentProp, defaultDepartment, onDepartmentChange, columns = 4, onMemberOpen, className, }: TeamSectionProps) { const reduce = useReducedMotion() ?? false; const canHover = useHoverCapable(); const uid = useId(); const departments = useMemo(() => { if (departmentsProp) return departmentsProp; const seen: string[] = []; for (const member of members) { if (member.department && !seen.includes(member.department)) seen.push(member.department); } return seen; }, [members, departmentsProp]); const showFilters = filterable ?? departments.length > 1; const [internal, setInternal] = useState(defaultDepartment ?? allLabel); const active = departmentProp ?? internal; const setActive = (next: string) => { if (departmentProp === undefined) setInternal(next); onDepartmentChange?.(next); }; const visible = useMemo( () => (active === allLabel ? members : members.filter((m) => m.department === active)), [members, active, allLabel], ); // Keep the last member while the dialog plays its exit. const [selected, setSelected] = useState(null); const [open, setOpen] = useState(false); const openMember = (member: TeamMember) => { setSelected(member); setOpen(true); onMemberOpen?.(member); }; const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT; const item: Variants = { hidden: { opacity: 0, y: reduce ? 0 : 16 }, show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE_OUT } }, exit: { opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.15, ease: EASE_OUT }, }, }; return (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
{showFilters ? (
Filter by department {[allLabel, ...departments].map((name) => { const isActive = name === active; return ( ); })}
) : null} {visible.map((member) => ( // Layout animation is the point: cards glide into the gaps // left by members filtered out. openMember(member)} /> ))}
{selected ? : null}
); } function MemberCard({ member, canHover, reduce, onOpen, }: { member: TeamMember; canHover: boolean; reduce: boolean; onOpen: () => void; }) { const socials = member.socials ?? []; const reveal = canHover && (member.bio || socials.length > 0); return (
{reveal ? ( // Hover/focus reveal. Sits above the stretched button (z-20) so the // social links stay clickable; the panel itself lets clicks through.
{member.bio ? (

{member.bio}

) : null} {socials.length > 0 ? ( ) : null}
) : null}

{/* Stretched button: its ::after covers the card, so the whole card opens the detail panel while staying one tab stop. */}

{member.role}

{!canHover && socials.length > 0 ? ( ) : null}
); } function MemberDetail({ member }: { member: TeamMember }) { const socials = member.socials ?? []; return ( <>
{member.name} {member.role}
{member.department ? ( {member.department} ) : null} {member.location ? ( {member.location} ) : null}
{member.bio ? (

{member.bio}

) : null} {socials.length > 0 ? ( ) : null} ); }