import React, { useRef, useState } from 'react'; import { FaMagic, FaUpload } from 'react-icons/fa'; import type { SuggestionEntry } from '../../service/visibility/visibility.interface'; import { generateArticle, generateSuggestionsFromBrief, } from '../../service/visibility/visibility.service'; import { DOCUMENT_ACCEPT, extractDocumentText } from '../../utils/documentText'; import ArticleProgress from './ArticleProgress'; import GenerateLanguagesDialog from './GenerateLanguagesDialog'; import Modal from './Modal'; import { priorityToTone, toneToBadgeClass } from './helpers'; /** Hard cap on the brief sent to the backend (matches the API schema). */ const MAX_BRIEF_CHARS = 10_000; /** Props for {@link GenerateIdeasModal}. */ interface GenerateIdeasModalProps { open: boolean; onOpenChange: (next: boolean) => void; clientId: string; token: string; brandId: string; /** Brand language, used as the default for the generated ideas. */ language: string; /** * Fired with the freshly generated ideas so the parent can merge them into * the Recommended list without a refetch. */ onIdeasGenerated: (ideas: SuggestionEntry[]) => void; /** Fired after an article is dispatched from an idea so the parent can refresh. */ onArticleStarted?: () => void; } /** * "Turn your ideas into articles" modal. The merchant pastes a brief (or * uploads a txt/csv/docx that fills the textarea), gets 3 to 5 article ideas * back, and can dispatch a full article from each one inline. * * @param {GenerateIdeasModalProps} props - Modal props. * @returns {JSX.Element} The modal. */ const GenerateIdeasModal = ({ open, onOpenChange, clientId, token, brandId, language, onIdeasGenerated, onArticleStarted, }: GenerateIdeasModalProps): JSX.Element => { const fileInputRef = useRef(null); const [brief, setBrief] = useState(''); const [fileError, setFileError] = useState(null); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); const [ideas, setIdeas] = useState([]); // Idea awaiting the "which languages?" dialog before its article is // dispatched. Null when the dialog is closed. const [langDialogIdea, setLangDialogIdea] = useState( null ); // dispatch in flight (POST not yet acked) per idea. const [startingBySuggestion, setStartingBySuggestion] = useState< Record >({}); // articleId of the generation job in flight per idea (drives the inline // progress poller, same one the Recommended list uses). const [pendingArticleBySuggestion, setPendingArticleBySuggestion] = useState< Record >({}); // ideas whose article reached ``ready`` while the modal was open. const [readyBySuggestion, setReadyBySuggestion] = useState< Record >({}); const [articleErrorBySuggestion, setArticleErrorBySuggestion] = useState< Record >({}); const trimmedLength: number = brief.trim().length; const canGenerate: boolean = trimmedLength >= 10 && !generating; /** * Read an attached document into the brief textarea, truncated to the brief * budget so the merchant sees exactly what will be sent and can edit it. * Plain text and CSV are read directly; .docx is parsed in-browser with * mammoth. * * @param {File | null} file - The selected file, or null when cleared. * @returns {Promise} Resolves once the file text has been loaded. */ const handleFile = async (file: File | null): Promise => { setFileError(null); if (!file) return; const result = await extractDocumentText(file, MAX_BRIEF_CHARS); if (result.error) { setFileError(result.error); return; } setBrief(result.text ?? ''); }; /** * Generate 3 to 5 article ideas from the brief and surface them below. * * @returns {Promise} Resolves once the request settles. */ const handleGenerate = async (): Promise => { if (!canGenerate) return; setGenerating(true); setError(null); try { const response = await generateSuggestionsFromBrief( clientId, token, brandId, { brief: brief.trim().slice(0, MAX_BRIEF_CHARS), language } ); setIdeas(response.suggestions); onIdeasGenerated(response.suggestions); if (response.suggestions.length === 0) { setError( 'No ideas came back for that brief. Add more detail and try again.' ); } } catch (generateError) { setError( generateError instanceof Error ? generateError.message : 'Failed to generate ideas.' ); } finally { setGenerating(false); } }; /** * Dispatch a full article from one generated idea. The idea is already a * persisted suggestion, so generation links to it via ``suggestion_id`` and * the Recommended list picks the article up once it is ready. * * @param {SuggestionEntry} idea - The idea to turn into an article. * @param {string[]} languages - Languages to generate the idea in at once. * @returns {Promise} Resolves once the dispatch settles. */ const handleGenerateArticle = async ( idea: SuggestionEntry, languages: string[] ): Promise => { setLangDialogIdea(null); setStartingBySuggestion(previous => ({ ...previous, [idea.suggestion_id]: true, })); setArticleErrorBySuggestion(previous => { const next = { ...previous }; delete next[idea.suggestion_id]; return next; }); try { const dispatch = await generateArticle(clientId, token, { brand_id: brandId, topic: idea.topic || idea.title, target_prompts: idea.target_prompts, keywords: idea.keywords, language: idea.language, // Generate this idea in every chosen language at once; the backend // links the rows so the article view can switch between versions. languages: languages.length ? languages : undefined, suggestion_id: idea.suggestion_id, triggered_by: 'suggestion', }); setPendingArticleBySuggestion(previous => ({ ...previous, [idea.suggestion_id]: dispatch.article_id, })); onArticleStarted?.(); } catch (articleError) { setArticleErrorBySuggestion(previous => ({ ...previous, [idea.suggestion_id]: articleError instanceof Error ? articleError.message : 'Failed to start generation.', })); } finally { setStartingBySuggestion(previous => { const next = { ...previous }; delete next[idea.suggestion_id]; return next; }); } }; /** * Resolve a finished generation: drop the poller, flag the idea as ready * and let the parent refresh so the article lands under Recommended. * * @param {string} suggestionId - The idea whose article just finished. * @returns {void} */ const handleArticleReady = (suggestionId: string): void => { setPendingArticleBySuggestion(previous => { const next = { ...previous }; delete next[suggestionId]; return next; }); setReadyBySuggestion(previous => ({ ...previous, [suggestionId]: true })); onArticleStarted?.(); }; /** * Surface a failed generation on the owning idea and clear its poller. * * @param {string} suggestionId - The idea whose article failed. * @param {string} message - Failure message to show. * @returns {void} */ const handleArticleFailed = (suggestionId: string, message: string): void => { setPendingArticleBySuggestion(previous => { const next = { ...previous }; delete next[suggestionId]; return next; }); setArticleErrorBySuggestion(previous => ({ ...previous, [suggestionId]: message, })); }; return ( onOpenChange(false)} title="Turn your ideas into content" size="lg" footer={ } >

Turn your own notes into content ideas. Type a brief or upload a document, and we'll suggest 3 to 5 angles tailored to your brand voice and the prompts you already track.

  • 1. Describe it.{' '} Paste your idea, or upload a .txt, .csv or .docx (up to 5 MB) and we'll read it into the box - edit it however you like.
  • 2. Generate ideas. {' '} You get 3 to 5 content ideas (titles, angles and target keywords), each roughly 1,800-2,000 words when written.
  • 3. Pick what to write. {' '} Hit "Generate content" on any idea. It then appears under Recommended once it finishes, ready to review and publish.
{ void handleFile(event.target.files?.[0] ?? null); // Reset so re-selecting the same file fires onChange again. event.target.value = ''; }} />