import React, { ReactNode, useState } from 'react';
import { LuChevronDown } from 'react-icons/lu';
import type { WinLossDetail } from '../../service/visibility/visibility.interface';
import ArticleModal from './ArticleModal';
import ArticleProgress from './ArticleProgress';
import GenerateLanguagesDialog from './GenerateLanguagesDialog';
import { EngineOutcomeChips, WonByChips } from './OutcomeChips';
import { PromptResponseEntryCard } from './PromptResponseModal';
import { useLossArticleGeneration } from './useLossArticleGeneration';
import {
PromptProof,
proofEntriesForWeek,
usePromptProof,
} from './usePromptProof';
/** Props for {@link TopWinsLosses}. */
interface TopWinsLossesProps {
topWins: string[];
topLosses: string[];
/**
* Structured mirrors from the weekly report; empty on legacy reports, in
* which case rows render as plain, non-interactive text.
*/
topWinsDetail: WinLossDetail[];
topLossesDetail: WinLossDetail[];
brandId: string;
clientId: string;
token: string;
language?: string | null;
/** Own domain, highlighted among cited sources in the proof layer. */
brandDomain?: string | null;
/** The report's ISO week, so the proof layer matches the chips. */
weekIso?: string | null;
/**
* Bubbled up when a loss row's generated article finishes. Lets the parent
* dashboard refetch the Articles list / tab counts so the new article
* appears in the Recommended section without a manual page refresh.
*/
onArticleGenerated?: () => void;
}
/** Upper bound on rows rendered per card. */
const MAX_ROWS = 5;
/**
* The week's structured detail for one win/loss text, when the report carries
* it. Matched case-insensitively: the plain list and the detail list are two
* renderings of the same localized string.
*
* @param {WinLossDetail[]} details - The report's detail list.
* @param {string} text - The row's prompt text.
* @returns {WinLossDetail | null} The matching detail, or ``null``.
*/
const detailForText = (
details: WinLossDetail[],
text: string
): WinLossDetail | null => {
const key = text.trim().toLowerCase();
return (
details.find(detail => detail.text.trim().toLowerCase() === key) ?? null
);
};
/** Props for {@link ProofBlock}. */
interface ProofBlockProps {
/** Fetch state for this row's prompt. */
proof: PromptProof | null;
/** The merchant's own domain, for source highlighting. */
brandDomain: string | null;
/** The report's week, so the proof matches the row's verdict chips. */
weekIso: string | null;
}
/**
* The expanded proof layer under a row: what each engine actually said,
* lazily fetched the first time the row opens.
*
* @param {ProofBlockProps} props - Fetch state, own domain and report week.
* @returns {JSX.Element} Proof cards, a skeleton, or the failure line.
*/
const ProofBlock = ({
proof,
brandDomain,
weekIso,
}: ProofBlockProps): JSX.Element => {
if (!proof || proof.status === 'loading') {
return (
);
}
if (proof.status === 'failed') {
return (
Could not load the AI responses for this prompt. Close and reopen the
row to retry.
);
};
/** Props for {@link WinLossRow}. */
interface WinLossRowProps {
text: string;
kind: 'win' | 'loss';
/** This week's structured detail, or ``null`` on legacy reports. */
detail: WinLossDetail | null;
expanded: boolean;
onToggle: () => void;
proof: PromptProof | null;
brandDomain: string | null;
/** The report's week, forwarded to the proof layer. */
weekIso: string | null;
/** Right-edge controls (the loss rows' Generate button). */
actions?: ReactNode;
/** Extra blocks inside the row (generation error / progress). */
children?: ReactNode;
}
/**
* One row of either card: status marker, the prompt text (a disclosure button
* when the proof layer is reachable), the week's verdict chips, and the
* expanded proof. Module-level on purpose: defined inside the parent it would
* remount - and drop focus plus expansion state - on every parent render.
*
* @param {WinLossRowProps} props - Row content and interaction state.
* @returns {JSX.Element} The row, as exactly one list item.
*/
const WinLossRow = ({
text,
kind,
detail,
expanded,
onToggle,
proof,
brandDomain,
weekIso,
actions,
children,
}: WinLossRowProps): JSX.Element => {
const expandable = Boolean(detail?.prompt_id);
return (
{kind === 'win' ? '✓' : '!'}
{/* The disclosure button holds the text and the chevron ONLY;
the chips sit outside it so they are never swallowed by the
row's click target. */}
{expandable ? (
) : (
{text}
)}
{detail && detail.engines.length > 0 && (
)}
{kind === 'loss' && detail && detail.won_by.length > 0 && (
entry.mentioned)
? 'Also in the answer'
: 'Won by'
}
/>
)}
{actions}
{expanded && expandable && (
)}
{children}
);
};
/**
* Two side-by-side cards: "Where you appeared this week" (wins) and "Where you
* missed out" (losses). The weekly report's structured detail drives
* per-engine verdict chips and, on losses, the rivals who won the prompt;
* expanding a row lazily fetches the engine's actual answer and cited sources.
* Loss rows keep the Generate-content dispatch.
*
* @param {TopWinsLossesProps} props - The week's rows, detail and auth.
* @returns {JSX.Element | null} The pair of cards, or ``null`` when empty.
*/
const TopWinsLosses = ({
topWins,
topLosses,
topWinsDetail,
topLossesDetail,
brandId,
clientId,
token,
language,
brandDomain = null,
weekIso = null,
onArticleGenerated,
}: TopWinsLossesProps): JSX.Element | null => {
const generation = useLossArticleGeneration({
clientId,
token,
brandId,
language,
onArticleGenerated,
});
const proof = usePromptProof(clientId, token, brandId);
// Keyed by kind + text (not index), so a week switch can never leave the
// expansion pointing at a different prompt's proof.
const [expandedKey, setExpandedKey] = useState(null);
if (topWins.length === 0 && topLosses.length === 0) return null;
const visibleWins = topWins.slice(0, MAX_ROWS);
const visibleLosses = topLosses
.slice(0, MAX_ROWS)
.filter(loss => generation.runFor(loss)?.phase !== 'covered');
const toggleRow = (key: string, detail: WinLossDetail | null): void => {
const next = expandedKey === key ? null : key;
setExpandedKey(next);
if (next && detail?.prompt_id) proof.loadProof(detail.prompt_id);
};
return (
<>
{/* Default grid stretch on purpose: the two cards stay equal height,
per Daniel - mismatched bottoms looked worse than the empty space. */}
Where you appeared this week
Customer questions where AI assistants surfaced your brand, with
each engine's verdict. Open a row to read exactly what the
AI said and which sources it cited.