← back to Open Seo

src/client/features/ai-search/components/BrandLookupShareOfVoice.tsx

111 lines

import {
  formatCount,
  formatPlatformLabel,
} from "@/client/features/ai-search/platformLabels";
import type { BrandLookupResult } from "@/types/schemas/ai-search";

type ShareOfVoice = NonNullable<BrandLookupResult["shareOfVoice"]>;
type ShareEntry = ShareOfVoice["entries"][number];

/**
 * Competitor Share of Voice leaderboard. The server sorts entries descending by
 * mentions and flags `isTarget`; this component only renders. Bars are scaled to
 * the leader (the exact % is shown on every row, so nothing is hidden) so a
 * dominant competitor reads as a full bar and small shares stay visible.
 */
export function BrandLookupShareOfVoice({
  shareOfVoice,
}: {
  shareOfVoice: ShareOfVoice;
}) {
  const target = shareOfVoice.entries.find((entry) => entry.isTarget) ?? null;
  const maxPct = Math.max(
    0,
    ...shareOfVoice.entries.map((entry) => entry.sharePct ?? 0),
  );

  return (
    <section className="flex h-full flex-col overflow-hidden rounded-xl border border-base-300 bg-base-100">
      <div className="flex items-baseline justify-between gap-2 border-b border-base-300 px-4 py-3">
        <h3 className="text-sm font-semibold">Share of Voice</h3>
        {target ? (
          <span className="text-xs text-base-content/50">
            <span className="font-medium text-base-content/80">
              {target.label}
            </span>{" "}
            {target.sharePct == null
              ? "· no comparable data"
              : `· ${Math.round(target.sharePct)}%`}
          </span>
        ) : null}
      </div>

      <ul className="flex-1 divide-y divide-base-200">
        {shareOfVoice.entries.map((entry, index) => (
          <LeaderboardRow
            key={entry.label}
            entry={entry}
            rank={index + 1}
            maxPct={maxPct}
          />
        ))}
      </ul>

      {/* Captions only the platforms actually summed — when one platform's
          cross_aggregated call failed, the leaderboard must not claim both. */}
      <p className="border-t border-base-200 px-4 py-2 text-[11px] text-base-content/50">
        Mentions share across{" "}
        {shareOfVoice.platforms.map(formatPlatformLabel).join(" and ")} · bars
        relative to the leader.
      </p>
    </section>
  );
}

function LeaderboardRow({
  entry,
  rank,
  maxPct,
}: {
  entry: ShareEntry;
  rank: number;
  maxPct: number;
}) {
  const hasData = entry.mentions != null && entry.sharePct != null;
  const barWidth =
    hasData && maxPct > 0 ? ((entry.sharePct ?? 0) / maxPct) * 100 : 0;

  return (
    <li
      className={`grid grid-cols-[1.25rem_minmax(0,1fr)_2.75rem] items-center gap-3 px-4 py-2.5 ${
        entry.isTarget ? "bg-primary/5" : ""
      }`}
    >
      <span className="text-xs tabular-nums text-base-content/40">{rank}</span>
      <div className="min-w-0">
        <div className="flex items-center gap-2">
          <span className="truncate text-sm">{entry.label}</span>
          {entry.isTarget ? (
            <span className="badge badge-primary badge-xs border-0">You</span>
          ) : null}
          <span className="ml-auto shrink-0 text-xs tabular-nums text-base-content/50">
            {/* Null mentions = "no data"; render a dash, not zero. */}
            {entry.mentions == null ? "—" : formatCount(entry.mentions)}
          </span>
        </div>
        <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-base-200">
          <div
            className={`h-full rounded-full ${
              entry.isTarget ? "bg-primary" : "bg-base-content/25"
            }`}
            style={{ width: `${barWidth}%` }}
          />
        </div>
      </div>
      <span className="text-right text-sm font-medium tabular-nums">
        {hasData ? `${Math.round(entry.sharePct ?? 0)}%` : "—"}
      </span>
    </li>
  );
}