import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import type { ArticleDetailResponse, ArticleVersionDetailResponse, ArticleVersionEntry, } from '../../service/visibility/visibility.interface'; import { deleteArticle, getArticle, getArticleVersion, getArticleVersions, patchArticle, publishArticleToBlog, regenerateArticle, unpublishArticle, } from '../../service/visibility/visibility.service'; import { publishCopilotArticleToBlog } from '../../service/copilot/copilot.service'; import { getArticlePublishing, PostTypeOption, } from '../../service/articlePublishing/articlePublishing.service'; import { RESOURCE_TYPE_ARTICLE_SUGGESTION } from '../../service/media/media.interface'; import type { MediaResponse } from '../../service/media/media.interface'; import { listMediaForResource } from '../../service/media/media.service'; import { deleteArticleTexts } from '../../utils/confirmTexts'; import ConfirmDialog from '../ui/ConfirmDialog'; import { editedByToTone, languageLabel, normaliseFaq, toneToBadgeClass, } from './helpers'; import ImageSuggestionCard from './ImageSuggestionCard'; import { PromptImpactPanel } from './PromptImpactPanel'; import MarkdownView, { extractHeadings, markdownToHtml, type TocEntry, } from './MarkdownView'; import Modal from './Modal'; /** * Sentinel value for the per-article post-type picker meaning "create a new * type/path for this article" rather than selecting an existing one. */ const CREATE_TYPE_OPTION = '__create__'; interface ArticleModalProps { open: boolean; articleId: string | null; onClose: () => void; clientId: string; token: string; /** * Invoked after the merchant hard-deletes this article. Parent should drop * it from its local article list; the modal also calls ``onClose`` itself. */ onDeleted?: (articleId: string) => void; /** * Invoked after the merchant flips this article between ``ready`` and * ``published``. Parent should patch its local article list with the * updated summary so the Recommended/Published tabs re-balance. */ onPublishedChanged?: (article: ArticleDetailResponse) => void; } /** Slug used for the meta description pseudo-section at the top. */ const SLUG_META = 'article-overview'; /** Hard cap mirrors the backend ``ArticleRegenerateRequest`` schema. */ const REGEN_INSTRUCTIONS_MAX = 2000; /** Auto-poll cadence (ms) while an article is in pending/generating state. */ const POLL_INTERVAL_MS = 2000; /** Time-to-live for inline copy/save feedback messages (ms). */ const FEEDBACK_TIMEOUT_MS = 2400; const REGEN_PLACEHOLDER = 'Optional - tell the model what to change. Examples:\n' + '• Rewrite as a listicle with 7 numbered picks.\n' + '• Keep the content as-is, but never use the word "premium".\n' + '• Shift to a casual, second-person tone.\n' + '• Add a comparison section between brand A and brand B.'; /** * Full article viewer with two tabs: * - **Current**: TOC sidebar + markdown body + FAQ + target prompts * - **Versions**: timeline of edits + selected version preview */ const ArticleModal = ({ open, articleId, onClose, clientId, token, onDeleted, onPublishedChanged, }: ArticleModalProps): JSX.Element | null => { const [article, setArticle] = useState(null); // The article actually shown. Defaults to the ``articleId`` the parent // opened the modal with, but the language-version switcher swaps it for a // sibling translation without touching the parent's prop. const [activeArticleId, setActiveArticleId] = useState( articleId ); const [mediaBySuggestion, setMediaBySuggestion] = useState< Record >({}); const [mediaError, setMediaError] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [activeSlug, setActiveSlug] = useState(null); const [copyFeedback, setCopyFeedback] = useState(null); const [regenerateBusy, setRegenerateBusy] = useState(false); const [regenerateOpen, setRegenerateOpen] = useState(false); const [regenerateInstructions, setRegenerateInstructions] = useState(''); const [selectedTab, setSelectedTab] = useState(0); const [deleteBusy, setDeleteBusy] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [publishBusy, setPublishBusy] = useState(false); const [publishToBlogOpen, setPublishToBlogOpen] = useState(false); const [publishToBlogBusy, setPublishToBlogBusy] = useState(false); // A "publish to blog" job runs in the background (especially with image // generation): show a live "working" indicator and poll until it goes live, // so the merchant sees progress instead of an idle article. '' means none. const [bgPublishKind, setBgPublishKind] = useState<'' | 'images' | 'text'>( '' ); // Per-article publishing target: the merchant can send this one article to a // post type other than their global default. Empty string means "use default". const [publishTargets, setPublishTargets] = useState([]); const [overridePostType, setOverridePostType] = useState(''); // When the merchant picks "create new", they type a path/slug here and it is // registered (or reused) right before publishing this one article. const [newTypePath, setNewTypePath] = useState(''); const [editing, setEditing] = useState(false); const [editDraft, setEditDraft] = useState(''); const [saveBusy, setSaveBusy] = useState(false); const [versionsLoading, setVersionsLoading] = useState(false); const [versionsError, setVersionsError] = useState(null); const [versions, setVersions] = useState([]); const [selectedVersionId, setSelectedVersionId] = useState( null ); const [versionDetail, setVersionDetail] = useState(null); const [versionDetailLoading, setVersionDetailLoading] = useState(false); const contentPaneRef = useRef(null); const showFeedback = useCallback((message: string) => { setCopyFeedback(message); window.setTimeout(() => setCopyFeedback(null), FEEDBACK_TIMEOUT_MS); }, []); const loadArticle = useCallback(async () => { if (!activeArticleId) return; setLoading(true); setError(null); try { const data = await getArticle(clientId, token, activeArticleId); setArticle(data); } catch (loadError) { setError( loadError instanceof Error ? loadError.message : 'Failed to load content.' ); } finally { setLoading(false); } }, [activeArticleId, clientId, token]); // When the parent opens the modal (or swaps which article it points at), // reset the active article to the parent's choice so a previous in-modal // language switch doesn't leak across opens. useEffect(() => { setActiveArticleId(articleId); }, [articleId]); useEffect(() => { if (open && activeArticleId) { loadArticle(); } if (!open) { setArticle(null); setError(null); setActiveSlug(null); setCopyFeedback(null); setSelectedTab(0); setVersions([]); setSelectedVersionId(null); setVersionDetail(null); setVersionsError(null); setEditing(false); setEditDraft(''); setRegenerateOpen(false); setRegenerateInstructions(''); setMediaBySuggestion({}); setMediaError(null); setBgPublishKind(''); } }, [open, activeArticleId, loadArticle]); // Switching to a sibling language version reuses the open modal, so clear // the per-article transient panels (versions, edit draft, media) that would // otherwise carry over from the article we just left. useEffect(() => { if (!open) return; setActiveSlug(null); setSelectedTab(0); setVersions([]); setSelectedVersionId(null); setVersionDetail(null); setVersionsError(null); setEditing(false); setEditDraft(''); setRegenerateOpen(false); setRegenerateInstructions(''); setMediaBySuggestion({}); setMediaError(null); }, [activeArticleId]); useEffect(() => { if (!open || !activeArticleId || !clientId || !token) return; let cancelled: boolean = false; listMediaForResource( clientId, token, RESOURCE_TYPE_ARTICLE_SUGGESTION, activeArticleId ) .then((rows: MediaResponse[]) => { if (cancelled) return; const map: Record = {}; for (const row of rows) map[row.suggestion_id] = row; setMediaBySuggestion(map); }) .catch(listError => { if (!cancelled) console.warn('[ArticleModal] listMediaForResource failed', listError); }); return () => { cancelled = true; }; }, [open, activeArticleId, clientId, token]); const handleMediaChanged = useCallback((fresh: MediaResponse): void => { setMediaBySuggestion(previous => ({ ...previous, [fresh.suggestion_id]: fresh, })); }, []); const handleMediaDeleted = useCallback((suggestionId: string): void => { setMediaBySuggestion(previous => { const next: Record = { ...previous }; delete next[suggestionId]; return next; }); }, []); /** * Auto-poll while the article is in a generating state - typical regen * lifecycle is ``pending`` → ``generating`` → ``ready``. Polling stops * automatically once status leaves the in-flight window. */ const articleStatus = article?.summary.status; useEffect(() => { if (!open || !activeArticleId) return; if (articleStatus !== 'pending' && articleStatus !== 'generating') return; let cancelled = false; const pollOnce = async () => { if (cancelled) return; try { const fresh = await getArticle(clientId, token, activeArticleId); if (cancelled) return; setArticle(fresh); if (fresh.summary.status === 'ready') { showFeedback('Content ready.'); } else if (fresh.summary.status === 'failed') { showFeedback('Regeneration failed - try again.'); } } catch { /* transient errors shouldn't kill polling */ } }; const intervalHandle = window.setInterval(pollOnce, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(intervalHandle); }; }, [open, articleStatus, activeArticleId, clientId, token, showFeedback]); // While a background publish-to-blog job runs, poll until the article goes // live so the indicator reflects real progress and clears on completion. useEffect(() => { if (!open || !activeArticleId || !bgPublishKind) return undefined; let cancelled = false; const pollOnce = async () => { if (cancelled) return; try { const fresh = await getArticle(clientId, token, activeArticleId); if (cancelled) return; setArticle(fresh); if (fresh.summary.status === 'published') { setBgPublishKind(''); showFeedback('Published live to your blog.'); } } catch { /* transient errors shouldn't kill polling */ } }; const intervalHandle = window.setInterval(pollOnce, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(intervalHandle); }; }, [open, activeArticleId, bgPublishKind, clientId, token, showFeedback]); const isGenerating = articleStatus === 'pending' || articleStatus === 'generating'; const isPublished = articleStatus === 'published'; // Publish is reversible (ready → published → ready), but only those two // states can transition: pending/generating/failed have nothing to flip. const canTogglePublish = articleStatus === 'ready' || articleStatus === 'published'; const publishLabel = isPublished ? 'Mark as not published' : 'Mark as published'; // Load the selectable post types once the modal opens for a publishable, // not-yet-published article, defaulting the picker to the global target. useEffect(() => { if (!open || isPublished || !canTogglePublish) return; if (publishTargets.length > 0) return; getArticlePublishing() .then(config => { setPublishTargets(config.post_types); setOverridePostType(config.post_type || ''); }) .catch(() => { // Non-fatal: without the list we just publish to the global default. }); }, [open, isPublished, canTogglePublish, publishTargets.length]); useEffect(() => { if (!open || !activeArticleId || selectedTab !== 1) return; if (versions.length > 0 || versionsLoading) return; setVersionsLoading(true); setVersionsError(null); getArticleVersions(clientId, token, activeArticleId) .then(response => { setVersions(response.versions || []); const firstVersion = response.versions?.[0]; if (firstVersion) setSelectedVersionId(firstVersion.version_id); }) .catch(versionsLoadError => { setVersionsError( versionsLoadError instanceof Error ? versionsLoadError.message : 'Failed to load versions.' ); }) .finally(() => setVersionsLoading(false)); }, [ open, activeArticleId, selectedTab, clientId, token, versions.length, versionsLoading, ]); useEffect(() => { if (!activeArticleId || !selectedVersionId) { setVersionDetail(null); return; } let cancelled = false; setVersionDetailLoading(true); getArticleVersion(clientId, token, activeArticleId, selectedVersionId) .then(detail => { if (cancelled) return; setVersionDetail(detail); }) .catch(versionLoadError => { if (cancelled) return; setVersionsError( versionLoadError instanceof Error ? versionLoadError.message : 'Failed to load the selected version.' ); }) .finally(() => { if (!cancelled) setVersionDetailLoading(false); }); return () => { cancelled = true; }; }, [activeArticleId, selectedVersionId, clientId, token]); const faq = useMemo(() => normaliseFaq(article?.faq), [article]); const tableOfContents: TocEntry[] = useMemo(() => { if (!article) return []; const headings = extractHeadings(article.markdown ?? ''); const title = article.h1 || article.summary.title; const base: TocEntry[] = title ? [{ level: 0, text: title, slug: SLUG_META }, ...headings] : headings; const extras: TocEntry[] = []; if (faq.length > 0) extras.push({ level: 0, text: 'FAQ', slug: 'faq-section' }); if ( Array.isArray( (article.outline as { sections?: unknown } | null)?.sections ) && ((article.outline as { sections: unknown[] }).sections.length ?? 0) > 0 ) extras.push({ level: 0, text: 'Outline', slug: 'outline' }); if ( Array.isArray(article.image_suggestions) && article.image_suggestions.length > 0 ) extras.push({ level: 0, text: 'Image suggestions', slug: 'image-suggestions', }); if ( Array.isArray(article.internal_links) && article.internal_links.length > 0 ) extras.push({ level: 0, text: 'Internal links', slug: 'internal-links' }); if ( Array.isArray(article.external_refs) && article.external_refs.length > 0 ) extras.push({ level: 0, text: 'External references', slug: 'external-refs', }); if (article.schema_article_jsonld || article.schema_faq_jsonld) extras.push({ level: 0, text: 'Schema (JSON-LD)', slug: 'schema-jsonld', }); return [...base, ...extras]; }, [article, faq]); const scrollToSlug = (slug: string) => { const contentPane = contentPaneRef.current; if (!contentPane) return; const target = contentPane.querySelector( `#${CSS.escape(slug)}` ); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); setActiveSlug(slug); } }; useEffect(() => { if (!open || !article) return; const contentPane = contentPaneRef.current; if (!contentPane) return; const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.isIntersecting && entry.target.id) { setActiveSlug(entry.target.id); break; } } }, { root: contentPane, rootMargin: '0px 0px -70% 0px', threshold: 0 } ); contentPane.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(headingEl => { if (headingEl.id) observer.observe(headingEl); }); const overviewEl = contentPane.querySelector(`#${SLUG_META}`); if (overviewEl) observer.observe(overviewEl); [ 'faq-section', 'outline', 'image-suggestions', 'internal-links', 'external-refs', 'schema-jsonld', ].forEach(id => { const sectionEl = contentPane.querySelector(`#${id}`); if (sectionEl) observer.observe(sectionEl); }); return () => observer.disconnect(); }, [open, article, tableOfContents]); /** * Best-effort clipboard write with a hidden-textarea fallback for * environments where the async API is unavailable (older webviews, * embedded iframes with restricted permissions). */ const writeToClipboard = async (value: string): Promise => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return true; } } catch { /* fall through to the textarea fallback */ } try { const fallbackTextarea = document.createElement('textarea'); fallbackTextarea.value = value; fallbackTextarea.setAttribute('readonly', ''); fallbackTextarea.style.position = 'absolute'; fallbackTextarea.style.left = '-9999px'; document.body.appendChild(fallbackTextarea); fallbackTextarea.select(); document.execCommand('copy'); document.body.removeChild(fallbackTextarea); return true; } catch { return false; } }; const handleCopyHtml = async () => { if (!article?.markdown) return; const html = markdownToHtml(article.markdown); const success = await writeToClipboard(html); showFeedback( success ? 'HTML copied - paste into the WordPress post body.' : "Couldn't copy - select manually." ); }; const handleCopyText = async () => { if (!article?.markdown) return; const html = markdownToHtml(article.markdown); const tmp = document.createElement('div'); tmp.innerHTML = html; const plain = (tmp as HTMLElement).innerText ?? tmp.textContent ?? ''; const ok = await writeToClipboard(plain); showFeedback( ok ? 'Plain text copied.' : "Couldn't copy — select manually." ); }; const handleCopyJsonLd = async (value: string, label: string) => { const ok = await writeToClipboard(value); showFeedback(ok ? `${label} copied.` : "Couldn't copy - select manually."); }; const handleStartEdit = () => { if (!article?.markdown) return; setEditDraft(article.markdown); setEditing(true); }; const handleCancelEdit = () => { setEditing(false); setEditDraft(''); }; const handleSaveEdit = async () => { if (!article) return; if (editDraft === article.markdown) { setEditing(false); return; } setSaveBusy(true); try { const updated = await patchArticle( clientId, token, article.summary.article_id, { markdown: editDraft } ); setArticle(updated); setEditing(false); setEditDraft(''); // Force the versions tab to refetch so the new version row shows up. setVersions([]); showFeedback('Edits saved - a new version was recorded.'); } catch (saveError) { showFeedback( saveError instanceof Error ? saveError.message : 'Failed to save edits.' ); } finally { setSaveBusy(false); } }; const handleRegenerate = async () => { if (!article) return; setRegenerateBusy(true); try { const instructions = regenerateInstructions.trim(); await regenerateArticle( clientId, token, article.summary.article_id, instructions ? { instructions } : undefined ); // Optimistically flip status so the polling effect picks up // immediately, even before the backend has flipped to "generating". setArticle(previous => previous ? { ...previous, summary: { ...previous.summary, status: 'generating' }, } : previous ); showFeedback( instructions ? 'Regeneration started with your directive.' : 'Regeneration started.' ); setRegenerateOpen(false); setRegenerateInstructions(''); } catch (regenerateError) { showFeedback( regenerateError instanceof Error ? regenerateError.message : 'Regenerate failed.' ); } finally { setRegenerateBusy(false); } }; /** * Toggle the article between ``ready`` and ``published`` on the backend * and patch the modal's local copy with the response. Forwards the * updated detail to the parent so the Recommended/Published tabs * re-balance without a full refetch. */ const handlePublishToggle = async () => { if (!article) return; const articleId: string = article.summary.article_id; const wasPublished: boolean = article.summary.status === 'published'; setPublishBusy(true); try { if (wasPublished) { const updated = await unpublishArticle(clientId, token, articleId); setArticle(updated); onPublishedChanged?.(updated); showFeedback('Marked as not published.'); return; } // Publish live to the store blog through the agent (it uploads the post // and marks the article published), then reload to reflect the new state. // Resolve the target. "New path" publishes this article as a child Page // under the typed parent path; otherwise it is the chosen post type. let override: { post_type?: string; parent_path?: string } | undefined; if (overridePostType === CREATE_TYPE_OPTION) { const path: string = newTypePath.trim(); if (!path) { showFeedback('Type a path for the pages first.'); return; } override = { parent_path: path }; } else if (overridePostType) { override = { post_type: overridePostType }; } const result = await publishCopilotArticleToBlog( articleId, token, clientId, override ); if (!result.ok) { showFeedback(result.error ?? 'Failed to publish to the blog.'); return; } const updated = await getArticle(clientId, token, articleId); setArticle(updated); onPublishedChanged?.(updated); showFeedback('Published live to your blog.'); } catch (err) { showFeedback( err instanceof Error ? err.message : 'Failed to update status.' ); } finally { setPublishBusy(false); } }; const handlePublishToBlog = async (generateImages: boolean) => { if (!article) return; setPublishToBlogBusy(true); try { await publishArticleToBlog( clientId, token, article.summary.article_id, generateImages ); setPublishToBlogOpen(false); // Kick off the live "working" indicator; the poll effect clears it once // the article flips to published. setBgPublishKind(generateImages ? 'images' : 'text'); } catch (err) { showFeedback( err instanceof Error ? err.message : 'Failed to publish to the blog.' ); } finally { setPublishToBlogBusy(false); } }; /** * Confirm and hard-delete the currently-loaded article. On success * notifies the parent via ``onDeleted`` and closes the modal; on * failure surfaces the error in the inline copy/feedback strip. * * @returns {Promise} Resolves once the delete request and modal teardown have finished. */ const handleDelete = async () => { if (!article) return; setDeleteConfirmOpen(false); setDeleteBusy(true); try { await deleteArticle(clientId, token, article.summary.article_id); onDeleted?.(article.summary.article_id); onClose(); } catch (deleteError) { showFeedback( deleteError instanceof Error ? deleteError.message : 'Failed to delete content.' ); } finally { setDeleteBusy(false); } }; const deleteTitle = article ? article.summary.title || article.h1 || 'this content' : ''; const handleCopyVersionHtml = async () => { if (!versionDetail?.markdown) return; const html = markdownToHtml(versionDetail.markdown); const success = await writeToClipboard(html); showFeedback( success ? 'HTML copied - paste into the WordPress post body.' : "Couldn't copy - select manually." ); }; const handleCopyVersionText = async () => { if (!versionDetail?.markdown) return; const html = markdownToHtml(versionDetail.markdown); const tmp = document.createElement('div'); tmp.innerHTML = html; const plain = (tmp as HTMLElement).innerText ?? tmp.textContent ?? ''; const ok = await writeToClipboard(plain); showFeedback( ok ? 'Plain text copied.' : "Couldn't copy - select manually." ); }; const statsLine = useMemo(() => { if (!article) return ''; const parts: string[] = []; if (article.summary.word_count) { parts.push(`${article.summary.word_count} words`); } if (article.summary.seo_score) { parts.push(`SEO ${article.summary.seo_score}/100`); } if (article.summary.aeo_score) { parts.push(`AEO ${article.summary.aeo_score}/100`); } return parts.join(' · '); }, [article]); if (!open) return null; const renderCurrentTab = (): JSX.Element => (
{loading && !article ? (
) : article ? (
{article.summary.status} {article.summary.language.toUpperCase()} {statsLine && ( {statsLine} )}

{article.h1 || article.summary.title}

{article.meta_description && (

{article.meta_description}

)} {Array.isArray(article.language_versions) && article.language_versions.length > 1 && (
Language version: {article.language_versions.map(version => { const isCurrent = version.article_id === article.summary.article_id; return isCurrent ? ( {languageLabel(version.language)} ) : ( ); })}
)}
{!isGenerating && ( )} {editing ? (