import React, { useCallback, useEffect, useState } from 'react'; import { LuCalendarClock } from 'react-icons/lu'; import EmptyState from '../agent-analytics/EmptyState'; import { ARTICLE_STATUS } from '../../service/visibility/visibility.constants'; import type { ArticleDetailResponse, ArticleSummary, DisclaimerPosition, SuggestionEntry, } from '../../service/visibility/visibility.interface'; import { createPublishSchedule, deleteArticle, generateArticle, listPublishSchedules, rejectSuggestion, } from '../../service/visibility/visibility.service'; import { deleteArticleTexts } from '../../utils/confirmTexts'; import ConfirmDialog from '../ui/ConfirmDialog'; import { ArticleImpactBadge } from './ArticleImpactBadge'; import ArticleModal from './ArticleModal'; import ArticlePreview, { PreviewFrame } from './ArticlePreview'; import ArticleProgress from './ArticleProgress'; import ArticleSourcePrompts from './ArticleSourcePrompts'; import ArticlesTable from './ArticlesTable'; import AvailableLanguagesPill from './AvailableLanguagesPill'; import ContentCard, { CARD_ACTION, CARD_ACTION_QUIET, CardIconButton, CardOpenButton, } from './ContentCard'; import GenerateLanguagesDialog from './GenerateLanguagesDialog'; import { ACTIVE_SCHEDULE_STATUS, countLanguageSiblings, priorityToAccent, priorityToLabel, priorityToTone, } from './helpers'; import Pill from './Pill'; import SchedulePublishModal from './SchedulePublishModal'; import type { ContentView } from './ViewToggle'; interface RecommendedArticlesListProps { suggestions: SuggestionEntry[]; articles: ArticleSummary[]; clientId: string; token: string; brandId: string; onArticleReady: (article: ArticleDetailResponse) => void; onSuggestionRejected?: (suggestionId: string) => void; onArticleDeleted?: (articleId: string) => void; view?: ContentView; disclaimer?: string; disclaimerPosition?: DisclaimerPosition; onArticlePublishedChanged?: (article: ArticleDetailResponse) => void; } function RecommendedArticlesList({ suggestions, articles, clientId, token, brandId, onArticleReady, onSuggestionRejected, onArticleDeleted, view = 'grid', disclaimer, disclaimerPosition, onArticlePublishedChanged, }: RecommendedArticlesListProps): JSX.Element { const [openArticleId, setOpenArticleId] = useState(null); // 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 >({}); // Bulk publishing: the articles ticked for scheduling (selection order is // the publishing order the modal opens with), and the day each already // queued article is due, keyed by article id so a row can show its date. const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [scheduleOpen, setScheduleOpen] = useState(false); const [scheduling, setScheduling] = useState(false); const [scheduledByArticle, setScheduledByArticle] = useState< Record >({}); const loadSchedules = useCallback(async (): Promise => { try { const schedules = await listPublishSchedules(clientId, token); const byArticle: Record = {}; schedules .filter(schedule => schedule.status === ACTIVE_SCHEDULE_STATUS) .forEach(schedule => { (schedule.slots || []).forEach(slot => { if (!slot.published && slot.article_id && slot.publish_at) { byArticle[slot.article_id] = slot.publish_at; } }); }); setScheduledByArticle(byArticle); } catch { // A queue we cannot read is not worth blocking the article list for; // the rows simply show no scheduled date. setScheduledByArticle({}); } }, [clientId, token]); useEffect(() => { void loadSchedules(); }, [loadSchedules]); const selectableId = (article?: ArticleSummary): string | null => { if (!article || article.status !== ARTICLE_STATUS.READY) return null; if (scheduledByArticle[article.article_id]) return null; return article.article_id; }; const exitSelection = (): void => { setSelectionMode(false); setSelectedIds([]); }; const toggleSelected = (articleId: string): void => { setSelectedIds(current => current.includes(articleId) ? current.filter(one => one !== articleId) : [...current, articleId] ); }; const [scheduleError, setScheduleError] = useState(null); const handleSchedule = async ( articleIds: string[], publishDates: string[] ): Promise => { setScheduleError(null); setScheduling(true); try { await createPublishSchedule(clientId, token, { article_ids: articleIds, publish_dates: publishDates, }); setScheduleOpen(false); exitSelection(); await loadSchedules(); } catch (error) { setScheduleError( error instanceof Error ? error.message : 'Could not schedule those articles.' ); } finally { setScheduling(false); } }; // 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; }); } }; 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; }); } }; const handleCancelGeneration = async ( suggestion: SuggestionEntry, pendingArticleId: string ): Promise => { setDeletingByArticle(previous => ({ ...previous, [pendingArticleId]: true, })); setErrorBySuggestion(previousErrors => { const nextErrors = { ...previousErrors }; delete nextErrors[suggestion.suggestion_id]; return nextErrors; }); try { await deleteArticle(clientId, token, pendingArticleId); setPendingArticleBySuggestion(previous => { const nextPending = { ...previous }; delete nextPending[suggestion.suggestion_id]; return nextPending; }); onArticleDeleted?.(pendingArticleId); } catch (cancelError) { setErrorBySuggestion(previousErrors => ({ ...previousErrors, [suggestion.suggestion_id]: cancelError instanceof Error ? cancelError.message : 'Failed to cancel this generation.', })); } finally { setDeletingByArticle(previousDeleting => { const nextDeleting = { ...previousDeleting }; delete nextDeleting[pendingArticleId]; return nextDeleting; }); } }; /** * Nothing else is still writing, so the article that just landed can open * itself in front of the merchant. With several runs in flight the reader * would cover the rows still moving, so those keep offering "Open content". * * @param finishedSuggestionId {string} Suggestion whose article just finished. * @returns {boolean} Whether nothing else is still generating. */ const isOnlyGeneration = (finishedSuggestionId: string): boolean => { const otherLocal = Object.keys(pendingArticleBySuggestion).some( suggestionId => suggestionId !== finishedSuggestionId ); const otherBackend = articles.some( article => article.triggered_by_suggestion_id !== finishedSuggestionId && (article.status === 'pending' || article.status === 'generating') ); return !otherLocal && !otherBackend; }; /** * An article deleted from the reader also leaves the local generated cache, * so its suggestion goes back to offering "Generate content" instead of * pointing at a row the backend no longer has. * * @param deletedArticleId {string} The article the reader just deleted. * @returns {void} */ const handleModalDeleted = (deletedArticleId: string): void => { 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); }; const handleDeleteArticleOnly = (article: ArticleSummary): void => { setPendingDelete({ title: article.title || 'this content', run: () => runDeleteArticleOnly(article), }); }; 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; }); } }; const runPendingDelete = async (): Promise => { if (!pendingDelete) return; setPendingDeleteBusy(true); try { await pendingDelete.run(); } finally { setPendingDeleteBusy(false); setPendingDelete(null); } }; const handlePendingDeleteOpenChange = (next: boolean): void => { if (!next && !pendingDeleteBusy) setPendingDelete(null); }; const readyArticleFor = ( suggestionId: string, preferLanguage?: string ): ArticleSummary | undefined => { const readyForSuggestion = articles.filter( article => article.triggered_by_suggestion_id === suggestionId && article.status === 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 === ARTICLE_STATUS.READY) return cached; return undefined; }; const generatingArticleIdFor = (suggestionId: string): string | undefined => { const backendGenerating = articles.find( article => article.triggered_by_suggestion_id === suggestionId && (article.status === ARTICLE_STATUS.PENDING || article.status === ARTICLE_STATUS.GENERATING) ); return ( backendGenerating?.article_id ?? pendingArticleBySuggestion[suggestionId] ); }; 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, // Two or more languages => generate each at once, linked for the // in-article switcher. A single entry just forces that one 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 === 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)) // Surface brief-driven ideas (source="user") first; the sort is stable so // the backend's priority order is preserved within each group. .sort( (first, second) => Number(second.source === 'user') - Number(first.source === 'user') ); // 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. Filtered to ``ready`` / // ``pending`` / ``generating`` because ``published`` lives in the // Published tab and ``failed`` is filtered out by the agent endpoint. 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((first, second) => (second.created_at ?? '').localeCompare(first.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 ( <>
{selectionMode ? ( <>

{selectedIds.length === 0 ? 'Tap the articles you want to publish.' : `${selectedIds.length} article${selectedIds.length === 1 ? '' : 's'} selected`}

) : ( <>

Schedule several articles at once instead of publishing them one by one (they always go live, never as a draft).

)}
{/* Table view lists the generated contents; the idea cards below stay cards in both views, because an idea is not a content yet - it has no word count, no scores and nothing to open. */} {view === 'table' && orphanArticles.length > 0 && ( )}
{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); const readyLanguageCount = readyArticle ? countLanguageSiblings(articles, readyArticle.translation_group_id) : 0; // 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 === ARTICLE_STATUS.READY || article.status === ARTICLE_STATUS.PUBLISHED ); const groupGenerating = isMultiLanguage && !groupAllReady && siblingArticles.some( article => article.status === ARTICLE_STATUS.PENDING || article.status === 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; // A multi-language set is only schedulable once every language is // ready, or the queue would publish one language and strand the rest. const suggestionPickId = showReady ? selectableId(readyArticle ?? undefined) : null; const suggestionPicked = suggestionPickId !== null && selectedIds.includes(suggestionPickId); const generatedFor = showReady ? readyArticle : null; const busyGenerating = showGenerating || busy[suggestion.suggestion_id]; return ( toggleSelected(suggestionPickId) : undefined } preview={ generatedFor ? ( ) : ( ) } lead={ <> {suggestion.source === 'user' && ( From your brief )} ); })} {/* Standalone articles. The score is the article's own (seo + aeo) average, since orphans carry no ``priority_score``. */} {view === 'grid' && orphanArticles.map(article => { const isReady = article.status === ARTICLE_STATUS.READY; const orphanScore = Math.round( ((article.seo_score || 0) + (article.aeo_score || 0)) / 2 ); const accent = priorityToAccent(orphanScore); const orphanPickId = selectableId(article); const scheduledFor = scheduledByArticle[article.article_id]; return ( toggleSelected(orphanPickId) : undefined } preview={ } lead={ <> ); })}
setOpenArticleId(null)} clientId={clientId} token={token} disclaimer={disclaimer} disclaimerPosition={disclaimerPosition} onDeleted={handleModalDeleted} onPublishedChanged={onArticlePublishedChanged} onScheduled={() => { void loadSchedules(); }} /> { setScheduleOpen(next); // A failure belongs to the attempt that produced it; carrying it // into the next opening would only accuse a queue nobody built yet. if (!next) setScheduleError(null); }} busy={scheduling} error={scheduleError} articles={selectedIds .map( articleId => articles.find(one => one.article_id === articleId) ?? // An article generated in this session is not in the parent's // list yet; without this it would be ticked and then silently // dropped from the queue. Object.values(generatedSummaryBySuggestion).find( one => one.article_id === articleId ) ) .filter((one): one is ArticleSummary => Boolean(one))} onConfirm={(articleIds, publishDates) => { void handleSchedule(articleIds, publishDates); }} /> { 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;