{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "match-highlight",
  "title": "Match Highlight",
  "description": "Detailed match header for a match page: teams, score, live clock, cards, and corners.",
  "registryDependencies": [
    "popover",
    "match-card-compact"
  ],
  "files": [
    {
      "path": "registry/mrdoge-ui/match-highlight/match-highlight.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useState } from \"react\"\nimport { ChevronDown, Flag, Square } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { EntityImage } from \"@/registry/mrdoge-ui/entity-image/entity-image\"\nimport { MatchCardCompact, type MatchCardCompactTeam } from \"@/registry/mrdoge-ui/match-card-compact/match-card-compact\"\nimport { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from \"@/components/ui/popover\"\n\n/** Mirrors match.status directly, no \"scheduled\"/\"finished\" relabeling. */\nexport type MatchHighlightStatus = \"upcoming\" | \"live\" | \"completed\"\n\nexport interface MatchHighlightTeam {\n  name: string\n  logoUrl?: string\n  yellowCards?: number\n  redCards?: number\n  corners?: number\n}\n\nexport interface MatchHighlightClock {\n  /** Finer-grained than the outer `status`, which only has \"live\", not paused/intermission/interrupted. */\n  state: \"scheduled\" | \"live\" | \"paused\" | \"intermission\" | \"interrupted\" | \"finished\"\n  /** Short label, e.g. \"45+2'\", \"HT\", \"FT\". Used if displayLong is absent. */\n  display?: string | null\n  /** Verbose label meant for a detail header, e.g. \"Half-time\", \"Full Time\", \"2nd Half\". Preferred over `display`. */\n  displayLong?: string | null\n  /** Seconds elapsed as of `referenceTime`, paired together to tick a live timer client-side. Without both, falls back to `displayLong`/`display` as a static label. */\n  elapsedSeconds?: number | null\n  referenceTime?: string | null\n  /** Running match minute (soccer), already capped at the phase max. `null` for sports without one. */\n  minute?: number | null\n  /** Stoppage/injury-time overflow in minutes, e.g. `3` for \"45+3'\". Soccer only. */\n  stoppage?: number | null\n}\n\nexport interface MatchHighlightRegion {\n  name: string\n  logoUrl?: string\n}\n\nexport interface MatchHighlightCompetitionMatch {\n  id: string\n  home: MatchCardCompactTeam\n  away: MatchCardCompactTeam\n  /** Pre-formatted, e.g. \"2-1\" or \"18:00\": whatever's meaningful for that match's own status. */\n  info?: string\n}\n\nexport interface MatchHighlightDataProps {\n  loading?: false\n  status: MatchHighlightStatus\n  competition?: string\n  /** Shown to the left of `competition`. Omit to render the name with no flag. */\n  region?: MatchHighlightRegion\n  /**\n   * Other matches today in the same competition. Pass together with\n   * `onOpenCompetitionMatches` to turn the competition name into a\n   * dropdown; omit either and it renders as plain, non-interactive text.\n   * `undefined` while the dropdown is open and still loading, `null` if\n   * the fetch failed or there simply aren't any.\n   */\n  competitionMatches?: MatchHighlightCompetitionMatch[] | null\n  /** Called every time the dropdown opens; fetch `competitionMatches` lazily here rather than eagerly on every render. */\n  onOpenCompetitionMatches?: () => void\n  onSelectCompetitionMatch?: (matchId: string) => void\n  kickoff?: Date | string\n  home: MatchHighlightTeam\n  away: MatchHighlightTeam\n  homeScore?: number\n  awayScore?: number\n  clock?: MatchHighlightClock\n  /** @defaultValue \"24h\" */\n  timeFormat?: \"12h\" | \"24h\"\n  className?: string\n}\n\ninterface MatchHighlightLoadingProps {\n  /** Renders a skeleton with the same dimensions instead; no other prop is needed. */\n  loading: true\n  className?: string\n}\n\nexport type MatchHighlightProps = MatchHighlightDataProps | MatchHighlightLoadingProps\n\nfunction formatKickoff(kickoff: Date | string, timeFormat: \"12h\" | \"24h\") {\n  const parsed = typeof kickoff === \"string\" ? new Date(kickoff) : kickoff\n  return {\n    date: parsed.toLocaleDateString(undefined, { day: \"2-digit\", month: \"2-digit\" }),\n    time: parsed.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\", hour12: timeFormat === \"12h\" }),\n  }\n}\n\n// Today's kickoff time is more useful than the date; any other day, the\n// date is what tells the two matches apart.\nfunction formatFinishedDate(kickoff: Date | string, timeFormat: \"12h\" | \"24h\") {\n  const parsed = typeof kickoff === \"string\" ? new Date(kickoff) : kickoff\n  const isToday = parsed.toDateString() === new Date().toDateString()\n  const { date, time } = formatKickoff(parsed, timeFormat)\n  return isToday ? time : date\n}\n\n// Ticks every second from elapsedSeconds + drift since referenceTime,\n// uncapped past 45/90. stoppage is a static \"+N\" suffix, not counted up;\n// it's the ref's fixed allotment, not a live position within it.\n// Falls back to a static label when not ticking (not live, or a sport\n// with no continuous minute).\nfunction useClockLabel(status: MatchHighlightStatus, clock: MatchHighlightClock | undefined): string | null {\n  const canTick = status === \"live\" && clock?.state === \"live\" && clock.elapsedSeconds != null && Boolean(clock.referenceTime)\n  const [now, setNow] = useState(() => Date.now())\n\n  useEffect(() => {\n    if (!canTick) return\n    const id = setInterval(() => setNow(Date.now()), 1000)\n    return () => clearInterval(id)\n  }, [canTick])\n\n  // Once the match is really over, ignore the clock entirely: it can be\n  // frozen on a stale in-progress reading (e.g. \"125'\" from extra time)\n  // if the last push arrived right before the match ended. `status` is\n  // authoritative here; the caller falls back to a plain \"FT\" instead.\n  // Same reasoning as Match Card's own toMatchCardStatus.\n  if (!clock || status === \"completed\") return null\n  if (canTick) {\n    const driftSeconds = (now - new Date(clock.referenceTime!).getTime()) / 1000\n    const liveSeconds = Math.max(0, clock.elapsedSeconds! + driftSeconds)\n    const seconds = Math.floor(liveSeconds % 60)\n    const minutes = Math.floor(liveSeconds / 60)\n    const base = `${minutes}:${String(seconds).padStart(2, \"0\")}`\n    return clock.stoppage ? `${base} +${clock.stoppage}` : base\n  }\n  return clock.displayLong ?? clock.display ?? null\n}\n\n// The stats row's height is always reserved, whether or not this\n// particular team actually has cards/corners to show. Otherwise two\n// live matches (one with stats posted, one without) render at different\n// heights, and so does the same match before/after its first stats push.\nfunction TeamColumn({ team, showStats }: { team: MatchHighlightTeam; showStats: boolean }) {\n  const hasStats = showStats && (team.yellowCards != null || team.redCards != null || team.corners != null)\n\n  return (\n    <div className=\"flex min-w-0 flex-1 flex-col items-center gap-2 text-center\">\n      <EntityImage src={team.logoUrl} name={team.name} className=\"size-12 @sm:size-14\" />\n      <span className=\"line-clamp-2 text-sm font-medium\">{team.name}</span>\n      <div className=\"flex h-4 items-center gap-2.5 text-xs text-muted-foreground tabular-nums\">\n        {hasStats ? (\n          <>\n            {team.yellowCards != null ? (\n              <span className=\"flex items-center gap-1\">\n                <Square className=\"size-3 fill-yellow-500 text-yellow-500\" />\n                {team.yellowCards}\n              </span>\n            ) : null}\n            {team.redCards != null ? (\n              <span className=\"flex items-center gap-1\">\n                <Square className=\"size-3 fill-destructive text-destructive\" />\n                {team.redCards}\n              </span>\n            ) : null}\n            {team.corners != null ? (\n              <span className=\"flex items-center gap-1\">\n                <Flag className=\"size-3.5\" />\n                {team.corners}\n              </span>\n            ) : null}\n          </>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nfunction CompetitionMatchesSkeleton() {\n  return (\n    <ul className=\"flex flex-col gap-0.5\">\n      {[0, 1, 2].map((i) => (\n        <li key={i} className=\"flex items-center gap-2 px-1.5 py-1\">\n          <div className=\"flex shrink-0 -space-x-1.5\">\n            <span className=\"size-5 animate-pulse rounded-full bg-muted ring-2 ring-popover\" />\n            <span className=\"size-5 animate-pulse rounded-full bg-muted ring-2 ring-popover\" />\n          </div>\n          <span className=\"h-4 w-28 animate-pulse rounded bg-muted\" />\n        </li>\n      ))}\n    </ul>\n  )\n}\n\nfunction CompetitionRow({\n  competition,\n  region,\n  competitionMatches,\n  onOpenCompetitionMatches,\n  onSelectCompetitionMatch,\n}: {\n  competition: string\n  region?: MatchHighlightRegion\n  competitionMatches?: MatchHighlightCompetitionMatch[] | null\n  onOpenCompetitionMatches?: () => void\n  onSelectCompetitionMatch?: (matchId: string) => void\n}) {\n  const label = (\n    <>\n      {region ? <EntityImage src={region.logoUrl} name={region.name} className=\"size-3.5\" /> : null}\n      <span className=\"truncate\">{competition}</span>\n    </>\n  )\n\n  const [open, setOpen] = useState(false)\n\n  if (!onOpenCompetitionMatches) {\n    return <div className=\"flex items-center justify-center gap-1.5 text-center text-muted-foreground\">{label}</div>\n  }\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next)\n        if (next) onOpenCompetitionMatches()\n      }}\n    >\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"flex w-full cursor-pointer items-center justify-center gap-1.5 text-muted-foreground hover:text-foreground\"\n        >\n          {label}\n          <ChevronDown className={cn(\"size-3.5 shrink-0 transition-transform\", open && \"rotate-180\")} />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent align=\"center\" className=\"max-h-80 overflow-y-auto\">\n        <PopoverTitle>{competition}</PopoverTitle>\n        {competitionMatches === undefined ? (\n          <CompetitionMatchesSkeleton />\n        ) : competitionMatches === null || competitionMatches.length === 0 ? (\n          <p className=\"text-xs text-muted-foreground\">No other matches.</p>\n        ) : (\n          <ul className=\"flex flex-col gap-0.5\">\n            {competitionMatches.map((match) => (\n              <li key={match.id}>\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    onSelectCompetitionMatch?.(match.id)\n                    setOpen(false)\n                  }}\n                  className=\"w-full cursor-pointer rounded-md px-1.5 py-1 text-left hover:bg-muted\"\n                >\n                  <MatchCardCompact home={match.home} away={match.away} info={match.info} />\n                </button>\n              </li>\n            ))}\n          </ul>\n        )}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\n// Mirrors the real markup row-for-row (same icon sizes, same reserved\n// stats-row height) rather than approximating with generic bars, so\n// there's no layout shift once real data replaces it, regardless of\n// which status the match turns out to have.\nexport function MatchHighlightSkeleton({ className }: { className?: string }) {\n  return (\n    <div className={cn(\"@container w-full rounded-xl border bg-card p-3\", className)}>\n      <div className=\"flex items-center justify-center gap-1.5\">\n        <span className=\"size-3.5 shrink-0 animate-pulse rounded-full bg-muted\" />\n        {/* h-4, not h-3: matches text-xs' real 1rem line-height, not just its font-size */}\n        <span className=\"h-4 w-20 animate-pulse rounded bg-muted\" />\n      </div>\n      <div className=\"mt-3 flex items-center justify-center gap-3 @sm:gap-6\">\n        <div className=\"flex flex-1 flex-col items-center gap-2\">\n          <span className=\"size-12 animate-pulse rounded-full bg-muted @sm:size-14\" />\n          {/* h-5, not h-3: matches text-sm's real 1.25rem line-height, not just its font-size */}\n          <span className=\"h-5 w-16 animate-pulse rounded bg-muted\" />\n          <span className=\"h-4 w-10 animate-pulse rounded bg-muted\" />\n        </div>\n        <div className=\"flex shrink-0 flex-col items-center gap-1\">\n          <span className=\"h-8 w-14 animate-pulse rounded bg-muted @sm:h-9\" />\n          <span className=\"h-4 w-10 animate-pulse rounded bg-muted\" />\n        </div>\n        <div className=\"flex flex-1 flex-col items-center gap-2\">\n          <span className=\"size-12 animate-pulse rounded-full bg-muted @sm:size-14\" />\n          <span className=\"h-5 w-16 animate-pulse rounded bg-muted\" />\n          <span className=\"h-4 w-10 animate-pulse rounded bg-muted\" />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport function MatchHighlight(props: MatchHighlightProps) {\n  // Called unconditionally (rules-of-hooks) even in the loading branch,\n  // which has no clock to tick yet.\n  const clockLabel = useClockLabel(props.loading ? \"upcoming\" : props.status, props.loading ? undefined : props.clock)\n\n  if (props.loading) {\n    return <MatchHighlightSkeleton className={props.className} />\n  }\n\n  const {\n    status,\n    competition,\n    region,\n    competitionMatches,\n    onOpenCompetitionMatches,\n    onSelectCompetitionMatch,\n    kickoff,\n    home,\n    away,\n    homeScore,\n    awayScore,\n    clock,\n    timeFormat = \"24h\",\n    className,\n  } = props\n\n  const showScore = status !== \"upcoming\"\n  const isLive = status === \"live\"\n  const isTicking = isLive && clock?.state === \"live\"\n  const kickoffParts = status === \"upcoming\" && kickoff ? formatKickoff(kickoff, timeFormat) : null\n\n  return (\n    <div className={cn(\"@container w-full rounded-xl border bg-card p-3 text-card-foreground\", className)}>\n      {competition ? (\n        <div className=\"text-xs font-medium\">\n          <CompetitionRow\n            competition={competition}\n            region={region}\n            competitionMatches={competitionMatches}\n            onOpenCompetitionMatches={onOpenCompetitionMatches}\n            onSelectCompetitionMatch={onSelectCompetitionMatch}\n          />\n        </div>\n      ) : null}\n      <div className=\"mt-3 flex items-center justify-center gap-3 @sm:gap-6\">\n        <TeamColumn team={home} showStats={showScore} />\n        <div className=\"flex shrink-0 flex-col items-center gap-1\">\n          {showScore ? (\n            <span className={cn(\"text-2xl font-bold tabular-nums @sm:text-3xl\", isLive && \"text-destructive\")}>\n              {homeScore ?? 0} – {awayScore ?? 0}\n            </span>\n          ) : null}\n          {kickoffParts ? (\n            <div className=\"flex flex-col items-center text-xs font-medium tabular-nums text-muted-foreground\">\n              <span>{kickoffParts.date}</span>\n              <span>{kickoffParts.time}</span>\n            </div>\n          ) : status === \"completed\" ? (\n            <div className=\"flex flex-col items-center text-xs font-medium tabular-nums text-muted-foreground\">\n              <span>FT</span>\n              {kickoff ? <span>{formatFinishedDate(kickoff, timeFormat)}</span> : null}\n            </div>\n          ) : (\n            <span className={cn(\"text-xs font-medium tabular-nums text-muted-foreground\", isTicking && \"text-destructive\")}>\n              {status === \"upcoming\" ? \"Upcoming\" : clockLabel}\n            </span>\n          )}\n        </div>\n        <TeamColumn team={away} showStats={showScore} />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/mrdoge-ui/entity-image/entity-image.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface EntityImageProps {\n  src?: string | null\n  /** Used for the alt text and the initials fallback. */\n  name: string\n  size?: \"sm\" | \"default\" | \"lg\"\n  className?: string\n}\n\nconst sizeClass: Record<NonNullable<EntityImageProps[\"size\"]>, string> = {\n  sm: \"size-5\",\n  default: \"size-8\",\n  lg: \"size-10\",\n}\n\n/**\n * Just the image: no background, border, or corner radius, so team crests\n * and region flags render at their native shape. Pass a `className` (e.g.\n * `size-6`) to override the size; it takes precedence over `size`.\n */\nexport function EntityImage({ src, name, size = \"default\", className }: EntityImageProps) {\n  const [errored, setErrored] = useState(false)\n\n  if (!src || errored) {\n    return (\n      <span\n        className={cn(\n          \"flex shrink-0 items-center justify-center rounded-full bg-muted text-[0.6rem] font-medium text-muted-foreground\",\n          sizeClass[size],\n          className\n        )}\n      >\n        {name.slice(0, 2).toUpperCase()}\n      </span>\n    )\n  }\n\n  return (\n    // eslint-disable-next-line @next/next/no-img-element -- framework-agnostic registry component, no next/image dependency\n    <img\n      src={src}\n      alt={name}\n      className={cn(\"shrink-0 object-contain\", sizeClass[size], className)}\n      onError={() => setErrored(true)}\n    />\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}