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.

); } const entries = proofEntriesForWeek(proof.entries, weekIso); if (entries.length === 0) { return (

No recorded responses for this prompt yet.

); } return (
{entries.map(entry => ( ))}
); }; /** 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 (
  • {/* 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.

    {visibleWins.length === 0 ? (

    No wins recorded for this week.

    ) : (
      {visibleWins.map(win => { const detail = detailForText(topWinsDetail, win); const key = `win:${win}`; return ( toggleRow(key, detail)} proof={ detail?.prompt_id ? proof.proofFor(detail.prompt_id) : null } brandDomain={brandDomain} weekIso={weekIso} /> ); })}
    )}

    Where you missed out

    Questions your customers ask where AI skipped you - and who it recommended instead. Generate content to win the prompt back.

    {visibleLosses.length === 0 ? (

    No missed opportunities flagged this week.

    ) : (
      {visibleLosses.map(loss => { const detail = detailForText(topLossesDetail, loss); const key = `loss:${loss}`; const run = generation.runFor(loss); return ( toggleRow(key, detail)} proof={ detail?.prompt_id ? proof.proofFor(detail.prompt_id) : null } brandDomain={brandDomain} weekIso={weekIso} actions={ } > {run?.phase === 'failed' && (

      {run.message}

      )} {run?.phase === 'generating' && (
      generation.markReady(loss, article.summary) } onFailed={message => generation.markFailed(loss, message) } />
      )}
      ); })}
    )}
    { if (!next) generation.closeLanguageDialog(); }} primaryLanguage={language || 'en'} busy={generation.dialogBusy} onConfirm={languages => { void generation.confirmLanguages(languages); }} /> ); }; export default TopWinsLosses;