import React, { useState } from 'react'; import { FaTrash } from 'react-icons/fa'; import EmptyState from '../agent-analytics/EmptyState'; import type { ArticleDetailResponse, ArticleSummary, SuggestionEntry, } from '../../service/visibility/visibility.interface'; import { deleteArticle, generateArticle, rejectSuggestion, } from '../../service/visibility/visibility.service'; import ConfirmDialog from '../ui/ConfirmDialog'; import { deleteArticleTexts } from '../../utils/confirmTexts'; import ArticleModal from './ArticleModal'; import ArticleProgress from './ArticleProgress'; import { ArticleImpactBadge } from './ArticleImpactBadge'; import GenerateLanguagesDialog from './GenerateLanguagesDialog'; import { countLanguageSiblings, priorityToAccent, priorityToLabel, priorityToTone, toneToBadgeClass, } from './helpers'; interface RecommendedArticlesListProps { suggestions: SuggestionEntry[]; articles: ArticleSummary[]; clientId: string; token: string; brandId: string; onArticleReady: (article: ArticleDetailResponse) => void; /** * Invoked after a suggestion has been rejected on the backend so the * parent can drop it from the visible list without a full re-fetch. */ onSuggestionRejected?: (suggestionId: string) => void; /** * Invoked after a generated article has been hard-deleted so the parent * can drop it from its local article list. */ onArticleDeleted?: (articleId: string) => void; /** * Invoked after the merchant flips an article between ``ready`` and * ``published``. The parent should update its local article list so the * Recommended/Published tabs re-balance without a refetch. */ onArticlePublishedChanged?: (article: ArticleDetailResponse) => void; } /** * "Recommended articles" section. Each row surfaces a suggestion + a * "Generate" button; clicking dispatches and inlines progress until * ``ready``, then flips to "Open article". */ const RecommendedArticlesList = ({ suggestions, articles, clientId, token, brandId, onArticleReady, onSuggestionRejected, onArticleDeleted, onArticlePublishedChanged, }: RecommendedArticlesListProps): JSX.Element => { // Suggestion awaiting the "which languages?" dialog before its article is // dispatched. Null when the dialog is closed. const [langDialogSuggestion, setLangDialogSuggestion] = useState(null); const [pendingArticleBySuggestion, setPendingArticleBySuggestion] = useState< Record >({}); const [generatedSummaryBySuggestion, setGeneratedSummaryBySuggestion] = useState>({}); const [busy, setBusy] = useState>({}); const [rejectingBySuggestion, setRejectingBySuggestion] = useState< Record >({}); const [deletingByArticle, setDeletingByArticle] = useState< Record >({}); const [errorBySuggestion, setErrorBySuggestion] = useState< Record >({}); const [openArticleId, setOpenArticleId] = useState(null); // Single confirm-modal state shared by both delete entry points - each // click closure captures its own ``run`` so the modal stays generic and // the side effects remain context-aware. const [pendingDelete, setPendingDelete] = useState<{ title: string; run: () => Promise; } | null>(null); const [pendingDeleteBusy, setPendingDeleteBusy] = useState(false); const handleReject = async (suggestion: SuggestionEntry) => { setRejectingBySuggestion(previousRejecting => ({ ...previousRejecting, [suggestion.suggestion_id]: true, })); setErrorBySuggestion(previousErrors => { const nextErrors = { ...previousErrors }; delete nextErrors[suggestion.suggestion_id]; return nextErrors; }); try { await rejectSuggestion( clientId, token, brandId, suggestion.suggestion_id ); onSuggestionRejected?.(suggestion.suggestion_id); } catch (rejectionError) { setErrorBySuggestion(previousErrors => ({ ...previousErrors, [suggestion.suggestion_id]: rejectionError instanceof Error ? rejectionError.message : 'Failed to remove this idea.', })); } finally { setRejectingBySuggestion(previousRejecting => { const nextRejecting = { ...previousRejecting }; delete nextRejecting[suggestion.suggestion_id]; return nextRejecting; }); } }; /** * When the modal hard-deletes the article it's showing, look up which * suggestion (if any) cached that article id locally and clear the cache * before forwarding the event to the parent. * * @param deletedArticleId {string} Identifier of the article just hard-deleted on the backend. * @returns {void} */ const handleModalDeleted = (deletedArticleId: string) => { const owningSuggestionId = Object.keys(generatedSummaryBySuggestion).find( suggestionId => generatedSummaryBySuggestion[suggestionId].article_id === deletedArticleId ); if (owningSuggestionId) { setGeneratedSummaryBySuggestion(previousCache => { const nextCache = { ...previousCache }; delete nextCache[owningSuggestionId]; return nextCache; }); } onArticleDeleted?.(deletedArticleId); }; /** * Hard-delete a generated article and flip the row back to "Generate * article". Confirms with the merchant first since the operation also * cascades the article's full version history. * * @param suggestion {SuggestionEntry} The suggestion row whose article is being removed. * @param article {ArticleSummary} The ``ready`` article tied to the suggestion. * @returns {Promise} Resolves once the delete request and local-state cleanup have finished. */ const handleDelete = ( suggestion: SuggestionEntry, article: ArticleSummary ) => { const title = article.title || suggestion.title || 'this content'; setPendingDelete({ title, run: () => runDeleteSuggestionArticle(suggestion, article), }); }; const runDeleteSuggestionArticle = async ( suggestion: SuggestionEntry, article: ArticleSummary ) => { setDeletingByArticle(previousDeleting => ({ ...previousDeleting, [article.article_id]: true, })); setErrorBySuggestion(previousErrors => { const nextErrors = { ...previousErrors }; delete nextErrors[suggestion.suggestion_id]; return nextErrors; }); try { await deleteArticle(clientId, token, article.article_id); setGeneratedSummaryBySuggestion(previousCache => { const nextCache = { ...previousCache }; delete nextCache[suggestion.suggestion_id]; return nextCache; }); onArticleDeleted?.(article.article_id); } catch (deleteError) { setErrorBySuggestion(previousErrors => ({ ...previousErrors, [suggestion.suggestion_id]: deleteError instanceof Error ? deleteError.message : 'Failed to delete this content.', })); } finally { setDeletingByArticle(previousDeleting => { const nextDeleting = { ...previousDeleting }; delete nextDeleting[article.article_id]; return nextDeleting; }); } }; /** * Fire the staged delete after the user confirms in the modal. * * @returns {Promise} Resolves once the wrapped runner finishes and * the modal state has been reset. */ const runPendingDelete = async (): Promise => { if (!pendingDelete) return; setPendingDeleteBusy(true); try { await pendingDelete.run(); } finally { setPendingDeleteBusy(false); setPendingDelete(null); } }; /** * Backdrop/Esc/cancel handler for the delete-confirm modal. Ignores close * requests while the runner is in flight to avoid orphaning the spinner. * * @param next {boolean} Requested open state from the modal. * @returns {void} */ const handlePendingDeleteOpenChange = (next: boolean): void => { if (!next && !pendingDeleteBusy) setPendingDelete(null); }; /** * Stage a delete-only confirm for an already-generated article (no parent * suggestion cleanup). Selected from the trash icon on the article card. * * @param article {ArticleSummary} Article to schedule for deletion. * @returns {void} */ const handleDeleteArticleOnly = (article: ArticleSummary): void => { setPendingDelete({ title: article.title || 'this content', run: () => runDeleteArticleOnly(article), }); }; /** * Hard-delete a standalone article without touching suggestion-side caches. * * @param article {ArticleSummary} Article to remove. * @returns {Promise} Resolves once the delete request finishes. */ const runDeleteArticleOnly = async ( article: ArticleSummary ): Promise => { setDeletingByArticle(prev => ({ ...prev, [article.article_id]: true, })); try { await deleteArticle(clientId, token, article.article_id); onArticleDeleted?.(article.article_id); } finally { setDeletingByArticle(prev => { const next = { ...prev }; delete next[article.article_id]; return next; }); } }; /** * Resolve the in-flight article id for a suggestion. Reads the backend * status first (an article linked to this suggestion that is still * ``pending`` / ``generating``) so the row keeps showing "Generating…" * across reloads, tab switches and generations kicked off elsewhere - e.g. * by the Reco copilot - not just within this component's local session. * Falls back to the just-dispatched local id before the article list has * refetched. * * @param suggestionId {string} Suggestion to look up. * @returns {string | undefined} The generating article id, if any. */ const generatingArticleIdFor = (suggestionId: string): string | undefined => { const backendGenerating = articles.find( article => article.triggered_by_suggestion_id === suggestionId && (article.status === 'pending' || article.status === 'generating') ); return ( backendGenerating?.article_id ?? pendingArticleBySuggestion[suggestionId] ); }; const readyArticleFor = ( suggestionId: string, preferLanguage?: string ): ArticleSummary | undefined => { const readyForSuggestion = articles.filter( article => article.triggered_by_suggestion_id === suggestionId && article.status === 'ready' ); if (readyForSuggestion.length) { // For a multi-language set, open the primary (the language the merchant // picked) rather than whichever translation finished most recently; // the switcher inside the modal still reaches the other versions. const primary = preferLanguage ? readyForSuggestion.find( article => (article.language || '').toLowerCase() === preferLanguage.toLowerCase() ) : undefined; return primary ?? readyForSuggestion[0]; } const cached = generatedSummaryBySuggestion[suggestionId]; if (cached && cached.status === 'ready') return cached; return undefined; }; const handleGenerate = async ( suggestion: SuggestionEntry, languages: string[] ) => { setLangDialogSuggestion(null); setBusy(prev => ({ ...prev, [suggestion.suggestion_id]: true })); setErrorBySuggestion(prev => { const next = { ...prev }; delete next[suggestion.suggestion_id]; return next; }); try { const dispatch = await generateArticle(clientId, token, { brand_id: brandId, topic: suggestion.topic || suggestion.title, target_prompts: suggestion.target_prompts, keywords: suggestion.keywords, language: suggestion.language, languages: languages.length ? languages : undefined, suggestion_id: suggestion.suggestion_id, triggered_by: 'suggestion', }); setPendingArticleBySuggestion(prev => ({ ...prev, [suggestion.suggestion_id]: dispatch.article_id, })); } catch (err) { setErrorBySuggestion(prev => ({ ...prev, [suggestion.suggestion_id]: err instanceof Error ? err.message : 'Failed to start generation.', })); } finally { setBusy(prev => ({ ...prev, [suggestion.suggestion_id]: false })); } }; // Suggestions whose generated article the merchant already published live // in the Published tab - drop them from the Recommended list so the // section only surfaces ideas that still need action. const publishedSuggestionIds = new Set( articles .filter( article => article.status === 'published' && article.triggered_by_suggestion_id ) .map(article => article.triggered_by_suggestion_id as string) ); const visibleSuggestions = suggestions.filter( suggestion => !publishedSuggestionIds.has(suggestion.suggestion_id) ); // Orphan articles - generated outside the suggestion flow (e.g. from a // "Where you missed out" loss row, or via the manual one-off // generator). They have no parent suggestion to render under, so we // surface them as standalone rows. const ORPHAN_VISIBLE_STATUSES: ReadonlySet = new Set(['pending', 'generating', 'ready']); const orphanArticlesRaw = articles .filter( article => !article.triggered_by_suggestion_id && ORPHAN_VISIBLE_STATUSES.has(article.status) ) .sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')); // Collapse multi-language siblings into a single card per translation group // (the card opens the article, which carries the language-version switcher). // Articles without a group are kept as-is. const seenOrphanGroups = new Set(); const orphanArticles = orphanArticlesRaw.filter(article => { const groupId = article.translation_group_id; if (!groupId) return true; if (seenOrphanGroups.has(groupId)) return false; seenOrphanGroups.add(groupId); return true; }); if (visibleSuggestions.length === 0 && orphanArticles.length === 0) { return ( ); } return ( <>
{visibleSuggestions.map(suggestion => { const readyArticle = readyArticleFor( suggestion.suggestion_id, suggestion.language ); const pendingId = generatingArticleIdFor(suggestion.suggestion_id); const isGenerating = Boolean(pendingId) && !readyArticle; const accent = priorityToAccent(suggestion.priority_score); // Language-version set state. A multi-language set must stay in the // "generating" state until EVERY language has finished, so the // merchant doesn't open a half-translated set; until then each // language shows its own little status chip. const siblingArticles = articles.filter( article => article.triggered_by_suggestion_id === suggestion.suggestion_id ); const isMultiLanguage = siblingArticles.some(article => article.translation_group_id) && siblingArticles.length > 1; const groupAllReady = isMultiLanguage && siblingArticles.every( article => article.status === 'ready' || article.status === 'published' ); const groupGenerating = isMultiLanguage && !groupAllReady && siblingArticles.some( article => article.status === 'pending' || article.status === 'generating' ); // For a multi-language set the whole group gates ready/generating; // single-language keeps the original primary-article logic. const showReady = isMultiLanguage ? groupAllReady : Boolean(readyArticle); const showGenerating = isMultiLanguage ? groupGenerating : isGenerating; return (

{suggestion.title}

{suggestion.rationale && (

{suggestion.rationale}

)}
{showReady && readyArticle ? ( <> Ready {countLanguageSiblings( articles, readyArticle.translation_group_id ) > 1 && ( {`Available in ${countLanguageSiblings( articles, readyArticle.translation_group_id )} languages`} )} ) : ( )} {!showReady && ( )}
{errorBySuggestion[suggestion.suggestion_id] && (

{errorBySuggestion[suggestion.suggestion_id]}

)} {isMultiLanguage && showGenerating && (
{`Generating in ${siblingArticles.length} languages:`} {siblingArticles.map(sibling => { const done = sibling.status === 'ready' || sibling.status === 'published'; return ( {`${(sibling.language || '?').toUpperCase()} ${ done ? '✓' : '…' }`} ); })}
)} {!isMultiLanguage && showGenerating && pendingId && ( { setPendingArticleBySuggestion(prev => { const next = { ...prev }; delete next[suggestion.suggestion_id]; return next; }); setGeneratedSummaryBySuggestion(prev => ({ ...prev, [suggestion.suggestion_id]: article.summary, })); onArticleReady(article); }} onFailed={message => setErrorBySuggestion(prev => ({ ...prev, [suggestion.suggestion_id]: message, })) } /> )}
); })} {/* Standalone articles - generated outside the suggestion flow (e.g. from a "Where you missed out" loss row, or via the manual one-off generator). */} {orphanArticles.map(article => { const isReady = article.status === 'ready'; return (

{article.title || 'Generated content'}

{isReady ? 'Generated from a missed prompt - open to review or publish.' : 'Generation in progress…'}

{isReady && article.article_id && ( )}
{isReady ? ( <> Ready {countLanguageSiblings( articles, article.translation_group_id ) > 1 && ( {`Available in ${countLanguageSiblings( articles, article.translation_group_id )} languages`} )} ) : ( Generating… )}
); })}
setOpenArticleId(null)} clientId={clientId} token={token} onDeleted={handleModalDeleted} onPublishedChanged={onArticlePublishedChanged} /> { if (!next) setLangDialogSuggestion(null); }} primaryLanguage={langDialogSuggestion?.language || 'en'} busy={ langDialogSuggestion ? Boolean(busy[langDialogSuggestion.suggestion_id]) : false } onConfirm={languages => { if (langDialogSuggestion) { void handleGenerate(langDialogSuggestion, languages); } }} /> ); }; export default RecommendedArticlesList;