/** * State machine behind the "Generate content" flow on the loss rows of the * wins/losses cards. The component used to juggle four parallel state maps * (pending / error / busy / dismissed, all index-keyed) with clone-and-set * logic inline in the JSX; here each loss topic carries ONE run state and * the component calls named transitions. */ import { useState } from 'react'; import type { ArticleSummary } from '../../service/visibility/visibility.interface'; import { generateArticle } from '../../service/visibility/visibility.service'; /** One loss topic's generation run, keyed by the topic text. */ export type LossRun = | { phase: 'generating'; articleId: string } | { phase: 'failed'; message: string } /** Article landed ready: the row is covered and leaves the list. */ | { phase: 'covered' }; /** Options for {@link useLossArticleGeneration}. */ interface UseLossArticleGenerationOptions { clientId: string; token: string; brandId: string; /** The brand's primary language, always generated. */ language?: string | null; /** Bubbled up when a generated article lands ready. */ onArticleGenerated?: () => void; } /** Named state and transitions handed back to the cards. */ interface LossArticleGeneration { /** Topic awaiting the "which languages?" dialog, or ``null`` when closed. */ dialogTopic: string | null; /** The dispatch for the dialog's topic is in flight. */ dialogBusy: boolean; /** Open the language dialog for a loss topic. */ openLanguageDialog: (topic: string) => void; closeLanguageDialog: () => void; /** Dispatch generation for the dialog's topic in the chosen languages. */ confirmLanguages: (languages: string[]) => Promise; /** The topic's current run, or ``null`` when it never started. */ runFor: (topic: string) => LossRun | null; /** Transition a generating topic to covered and open its article. */ markReady: (topic: string, article: ArticleSummary) => void; /** Transition a generating topic to failed with a message. */ markFailed: (topic: string, message: string) => void; /** Article opened after a run completed, for the review modal. */ openArticleId: string | null; closeArticleModal: () => void; } /** * Own the whole generate-an-article-from-a-loss flow: the language dialog, * the per-topic run state and the ready-article modal. * * @param {UseLossArticleGenerationOptions} options - Auth, brand and callbacks. * @returns {LossArticleGeneration} Named state and transitions. */ export const useLossArticleGeneration = ({ clientId, token, brandId, language, onArticleGenerated, }: UseLossArticleGenerationOptions): LossArticleGeneration => { const [dialogTopic, setDialogTopic] = useState(null); const [dialogBusy, setDialogBusy] = useState(false); const [runByTopic, setRunByTopic] = useState>({}); const [openArticleId, setOpenArticleId] = useState(null); const setRun = (topic: string, run: LossRun): void => { setRunByTopic(previous => ({ ...previous, [topic]: run })); }; const confirmLanguages = async (languages: string[]): Promise => { const topic = dialogTopic; if (!topic) return; setDialogBusy(true); try { const dispatch = await generateArticle(clientId, token, { brand_id: brandId, topic, // Anchor the article to the originating loss so the backend can // drop this row from "Where you missed out" on subsequent // weekly-report fetches. target_prompts: [topic], language: language ?? undefined, // Two or more languages => generate each at once, linked for the // in-article switcher. languages: languages.length ? languages : undefined, triggered_by: 'manual', }); setRun(topic, { phase: 'generating', articleId: dispatch.article_id }); } catch (err) { setRun(topic, { phase: 'failed', message: err instanceof Error ? err.message : 'Failed to start generation.', }); } finally { setDialogBusy(false); // Only close what is still OUR dialog: Escape can dismiss it mid-flight // and the merchant may already have it open for a different topic. setDialogTopic(current => (current === topic ? null : current)); } }; return { dialogTopic, dialogBusy, openLanguageDialog: (topic: string) => setDialogTopic(topic), closeLanguageDialog: () => setDialogTopic(null), confirmLanguages, runFor: (topic: string) => runByTopic[topic] ?? null, markReady: (topic: string, article: ArticleSummary) => { setRun(topic, { phase: 'covered' }); setOpenArticleId(article.article_id); onArticleGenerated?.(); }, markFailed: (topic: string, message: string) => setRun(topic, { phase: 'failed', message }), openArticleId, closeArticleModal: () => setOpenArticleId(null), }; };