/** * BoostMedia AI Content Generator Admin - Content Generator Page * * @package BoostMedia_AI * @license GPL-2.0-or-later */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { AlertCircle, CopyPlus, LogOut, RotateCcw, Save, FileText, Newspaper } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import { Button, Loader } from '../components/common' import { useStructures } from '../hooks' import type { ContentPlan, GeneratedPost, PostStructure, PostTypeRulesResponse, Reporter, SavedContentPlan, SavedTechnicalRules, SprintArticle, SprintResult, } from '../types' import { Header } from '../components/layout/Header' import { t } from '../lib/i18n' import { endpoints, getErrorMessage } from '../api/client' import { StepsProgress, SettingsStep, type GeneratorStep, type GenerationConfig, } from '../components/generator' import { TechnicalStep } from './content-generator/TechnicalStep' import { ContentStep } from './content-generator/ContentStep' import { IntentStep } from './content-generator/IntentStep' import { buildTechnicalRulesPayload, createEmptyContentPlan, createEmptyTechnicalState, extractTechnicalStateFromSavedRules, normalizeTechnicalFieldMapping, } from './content-generator/conversation-utils' const initialConfig: GenerationConfig = { postType: '', taxonomy: '', term: '', planName: '', reporterId: null, count: 10, countPerCategory: false, topic: '', keywords: [], length: 'auto', scheduling: 'none', generationType: 'one_time', repeatFrequency: 'weekly', repeatEvery: 1, repeatUnit: 'week', repeatWeekdays: [], repeatHour: 9, stopAfterMonths: null, maxTotalRuns: null, maxTotalPosts: null, remainingRuns: null, remainingPosts: null, isActive: true, } interface ContentGeneratorLocationState { planId?: number intent?: 'new' | 'run' | 'review' } interface PersistedPlanEditorDraft { version: 1 planId: number intent: 'run' | 'review' step: GeneratorStep config: GenerationConfig technicalState: Record contentState: Record technicalSessionId: string | null contentSessionId: string | null structureHash: string intentState?: Record intentSessionId?: string | null sprintResult?: SprintResult | null sprintArticles?: SprintArticle[] } const PLAN_EDITOR_DRAFT_STORAGE_PREFIX = 'bc:content-plan-editor' const NEW_FLOW_DRAFT_STORAGE_KEY = 'bc:content-new-flow-draft' const NEW_FLOW_MAX_AGE_MS = 2 * 60 * 60 * 1000 const VALID_GENERATOR_STEPS: GeneratorStep[] = [ 'settings', 'select', 'reporter', 'configure', 'technical', 'content', 'intent', 'sprint', 'article-review', 'generate', 'review', ] function normalizeGeneratorStep(value: string | null | undefined): GeneratorStep { if (!value || !VALID_GENERATOR_STEPS.includes(value as GeneratorStep)) return 'settings' if (value === 'select' || value === 'reporter' || value === 'configure') return 'settings' if (value === 'sprint' || value === 'article-review' || value === 'generate' || value === 'review') return 'settings' return value as GeneratorStep } function getPlanEditorDraftStorageKey(planId: number): string { return `${PLAN_EDITOR_DRAFT_STORAGE_PREFIX}:${planId}` } function readPlanEditorDraft(planId: number): PersistedPlanEditorDraft | null { if (typeof window === 'undefined' || planId <= 0) { return null } try { const raw = window.sessionStorage.getItem(getPlanEditorDraftStorageKey(planId)) if (!raw) { return null } const parsed = JSON.parse(raw) as Partial if (parsed.version !== 1 || parsed.planId !== planId) { return null } return { version: 1, planId, intent: parsed.intent === 'run' ? 'run' : 'review', step: normalizeGeneratorStep(parsed.step), config: parsed.config as GenerationConfig, technicalState: (parsed.technicalState as Record) || {}, contentState: (parsed.contentState as Record) || {}, technicalSessionId: typeof parsed.technicalSessionId === 'string' ? parsed.technicalSessionId : null, contentSessionId: typeof parsed.contentSessionId === 'string' ? parsed.contentSessionId : null, structureHash: typeof parsed.structureHash === 'string' ? parsed.structureHash : '', intentState: (parsed.intentState as Record) || {}, intentSessionId: typeof parsed.intentSessionId === 'string' ? parsed.intentSessionId : null, sprintResult: (parsed.sprintResult as SprintResult) || null, sprintArticles: Array.isArray(parsed.sprintArticles) ? (parsed.sprintArticles as SprintArticle[]) : [], } } catch { return null } } function writePlanEditorDraft(planId: number, draft: PersistedPlanEditorDraft): void { if (typeof window === 'undefined' || planId <= 0) { return } window.sessionStorage.setItem(getPlanEditorDraftStorageKey(planId), JSON.stringify(draft)) } function clearPlanEditorDraft(planId: number | null): void { if (typeof window === 'undefined' || !planId) { return } window.sessionStorage.removeItem(getPlanEditorDraftStorageKey(planId)) } interface PersistedNewFlowDraft { version: 1 timestamp: number step: GeneratorStep config: GenerationConfig technicalState: Record contentState: Record technicalSessionId: string | null contentSessionId: string | null structureHash: string intentState?: Record intentSessionId?: string | null sprintResult?: SprintResult | null sprintArticles?: SprintArticle[] } function readNewFlowDraft(): PersistedNewFlowDraft | null { if (typeof window === 'undefined') { return null } try { const raw = window.sessionStorage.getItem(NEW_FLOW_DRAFT_STORAGE_KEY) if (!raw) { return null } const parsed = JSON.parse(raw) as Partial if (parsed.version !== 1) { return null } if (typeof parsed.timestamp === 'number' && Date.now() - parsed.timestamp > NEW_FLOW_MAX_AGE_MS) { window.sessionStorage.removeItem(NEW_FLOW_DRAFT_STORAGE_KEY) return null } return { version: 1, timestamp: parsed.timestamp || Date.now(), step: normalizeGeneratorStep(parsed.step), config: parsed.config as GenerationConfig, technicalState: (parsed.technicalState as Record) || {}, contentState: (parsed.contentState as Record) || {}, technicalSessionId: typeof parsed.technicalSessionId === 'string' ? parsed.technicalSessionId : null, contentSessionId: typeof parsed.contentSessionId === 'string' ? parsed.contentSessionId : null, structureHash: typeof parsed.structureHash === 'string' ? parsed.structureHash : '', intentState: (parsed.intentState as Record) || undefined, intentSessionId: typeof parsed.intentSessionId === 'string' ? parsed.intentSessionId : null, sprintResult: (parsed.sprintResult as SprintResult) || null, sprintArticles: Array.isArray(parsed.sprintArticles) ? (parsed.sprintArticles as SprintArticle[]) : undefined, } } catch { return null } } function writeNewFlowDraft(draft: PersistedNewFlowDraft): void { if (typeof window === 'undefined') { return } window.sessionStorage.setItem(NEW_FLOW_DRAFT_STORAGE_KEY, JSON.stringify(draft)) } function clearNewFlowDraft(): void { if (typeof window === 'undefined') { return } window.sessionStorage.removeItem(NEW_FLOW_DRAFT_STORAGE_KEY) } function buildGeneratorSearch(params: { planId?: number | null intent?: 'new' | 'run' | 'review' | null step?: GeneratorStep | null }): string { const search = new URLSearchParams() if (params.planId && params.planId > 0) { search.set('planId', String(params.planId)) } if (params.intent) { search.set('intent', params.intent) } if (params.step) { search.set('step', params.step) } const query = search.toString() return query ? `?${query}` : '' } function normalizeScopeValue(value: string | null | undefined): string { return value && value !== '-' ? value : '' } function splitScopeList(value: string | null | undefined): string[] { return (value || '') .split(',') .map((item) => item.trim()) .filter(Boolean) } function normalizePositiveNumber(value: unknown): number | null { const nextValue = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN if (!Number.isFinite(nextValue) || nextValue <= 0) { return null } return Math.round(nextValue) } function normalizeScheduleConfig( value: unknown, fallbackFrequency: 'daily' | 'weekly' | 'monthly', ): Pick< GenerationConfig, | 'repeatEvery' | 'repeatUnit' | 'repeatWeekdays' | 'repeatHour' | 'stopAfterMonths' | 'maxTotalRuns' | 'maxTotalPosts' > { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} const unit = raw.unit === 'day' || raw.unit === 'week' || raw.unit === 'month' ? raw.unit : fallbackFrequency === 'daily' ? 'day' : fallbackFrequency === 'monthly' ? 'month' : 'week' const rawHour = typeof raw.hour === 'number' ? raw.hour : typeof raw.hour === 'string' ? Number(raw.hour) : 9 return { repeatEvery: Math.max(1, normalizePositiveNumber(raw.interval) || 1), repeatUnit: unit, repeatWeekdays: Array.isArray(raw.weekdays) ? raw.weekdays .map((item) => Number(item)) .filter((item) => Number.isInteger(item) && item >= 0 && item <= 6) : [], repeatHour: Math.min(23, Math.max(0, Number.isFinite(rawHour) ? Math.round(rawHour) : 9)), stopAfterMonths: normalizePositiveNumber(raw.stop_after_months), maxTotalRuns: normalizePositiveNumber(raw.max_total_runs), maxTotalPosts: normalizePositiveNumber(raw.max_total_posts), } } function normalizeScheduleCounters(value: unknown): Pick { const raw = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} return { remainingRuns: normalizePositiveNumber(raw.remaining_runs), remainingPosts: normalizePositiveNumber(raw.remaining_posts), } } function normalizeContentPlanState(state: Record) { return { phase: typeof state.phase === 'string' ? state.phase : 'content', ready: Boolean(state.ready), captured_answers: Array.isArray(state.captured_answers) ? state.captured_answers as Record[] : [], open_questions: Array.isArray(state.open_questions) ? state.open_questions : [], question_cards: Array.isArray(state.question_cards) ? state.question_cards : [], question_responses: ( state.question_responses && typeof state.question_responses === 'object' && !Array.isArray(state.question_responses) ) ? state.question_responses as Record : {}, content_plan: ((state.content_plan as ContentPlan | undefined) || createEmptyContentPlan()), } } function buildPlanSnapshot(params: { config: GenerationConfig technicalState: Record contentState: Record technicalSessionId: string | null contentSessionId: string | null activePlanId: number | null }) { return JSON.stringify({ activePlanId: params.activePlanId, config: params.config, technicalState: { ...createEmptyTechnicalState(), ...params.technicalState, captured_answers: Array.isArray(params.technicalState.captured_answers) ? params.technicalState.captured_answers : [], open_questions: Array.isArray(params.technicalState.open_questions) ? params.technicalState.open_questions : [], technical_summary: String(params.technicalState.technical_summary || ''), field_mapping: normalizeTechnicalFieldMapping(params.technicalState.field_mapping), }, contentState: normalizeContentPlanState(params.contentState), technicalSessionId: params.technicalSessionId, contentSessionId: params.contentSessionId, }) } function hasMeaningfulContentPlan(plan: ContentPlan | null | undefined) { if (!plan) { return false } const emptyPlan = createEmptyContentPlan() return JSON.stringify(plan) !== JSON.stringify(emptyPlan) } function resolveDraftSessionId( draftValue: string | null | undefined, ...fallbacks: Array ): string | null { if (typeof draftValue === 'string' && draftValue.trim()) { return draftValue } for (const fallback of fallbacks) { if (typeof fallback === 'string' && fallback.trim()) { return fallback } } return null } function PrerequisiteGuard({ children }: { children: React.ReactNode }) { const navigate = useNavigate() const { structures, loading: structuresLoading } = useStructures() const [reporters, setReporters] = useState(null) const [reportersLoading, setReportersLoading] = useState(true) useEffect(() => { let cancelled = false endpoints.getReporters().then((res) => { if (!cancelled) { setReporters((res.data as Reporter[]) || []) setReportersLoading(false) } }).catch(() => { if (!cancelled) { setReporters([]) setReportersLoading(false) } }) return () => { cancelled = true } }, []) if (structuresLoading || reportersLoading) { return (
) } if (!structures || structures.length === 0) { return (

{t('Content types not scanned yet')}

{t('Before creating content, the plugin needs to scan your site\'s content types. This is a one-time setup step.')}

) } if (!reporters || reporters.length === 0) { return (

{t('No reporter available')}

{t('A reporter is needed to create content. Go to the Reporters page to set one up.')}

) } return <>{children} } export default function ContentGenerator() { const location = useLocation() const navigate = useNavigate() const [step, setStep] = useState('settings') const [config, setConfig] = useState(initialConfig) const [, setGeneratedPosts] = useState([]) const [selectedReporter, setSelectedReporter] = useState(null) const [savedTechnicalRules, setSavedTechnicalRules] = useState(null) const [savedContentPlan, setSavedContentPlan] = useState(null) const [structureHash, setStructureHash] = useState('') const [activePlanId, setActivePlanId] = useState(null) const [planIntent, setPlanIntent] = useState<'new' | 'run' | 'review' | null>(null) const [loadingStep, setLoadingStep] = useState(false) const [stepError, setStepError] = useState(null) const [forceTechnicalReview, setForceTechnicalReview] = useState(false) const [forceContentReview, setForceContentReview] = useState(false) const [technicalSessionId, setTechnicalSessionId] = useState(null) const [contentSessionId, setContentSessionId] = useState(null) const [savingPlanAction, setSavingPlanAction] = useState<'save' | 'saveAs' | null>(null) const [lastSavedSnapshot, setLastSavedSnapshot] = useState('') const [showExitPrompt, setShowExitPrompt] = useState(false) const [, setRestoredDraft] = useState(false) const loadedLocationKeyRef = useRef('') const saveGenerationRef = useRef(0) const lastLoadedSaveGenRef = useRef(0) const configRef = useRef(initialConfig) const [technicalState, setTechnicalState] = useState>({ ...createEmptyTechnicalState() }) const [contentState, setContentState] = useState>({ phase: 'content', ready: false, captured_answers: [], open_questions: [], question_cards: [], question_responses: {}, content_plan: createEmptyContentPlan(), }) // Sprint 2.0 state const [intentState, setIntentState] = useState>({}) const [intentSessionId, setIntentSessionId] = useState(null) const [sprintResult, setSprintResult] = useState(null) const [sprintArticles, setSprintArticles] = useState([]) const [, setSprintError] = useState(null) const [, setActiveSprintJobId] = useState(null) const [submittingIntent, setSubmittingIntent] = useState(false) useEffect(() => { configRef.current = config }, [config]) const useSprintMode = useMemo(() => { if (activePlanId && savedContentPlan && !forceContentReview) { const plan = savedContentPlan.content_plan if (plan && hasMeaningfulContentPlan(plan)) { return false } } return true }, [activePlanId, savedContentPlan, forceContentReview]) const resetTechnicalState = useCallback(() => { setTechnicalSessionId(null) setTechnicalState({ ...createEmptyTechnicalState() }) setForceTechnicalReview(false) }, []) const resetContentState = useCallback(() => { setContentSessionId(null) setContentState({ phase: 'content', ready: false, captured_answers: [], open_questions: [], question_cards: [], question_responses: {}, content_plan: createEmptyContentPlan(), }) setForceContentReview(false) }, []) const resetSprintState = useCallback(() => { setIntentSessionId(null) setIntentState({}) setSprintResult(null) setSprintArticles([]) setSprintError(null) setActiveSprintJobId(null) if (typeof window !== 'undefined') { sessionStorage.removeItem('bc:active-sprint-job') } }, []) const clearAdaptiveState = useCallback(() => { setSelectedReporter(null) setSavedTechnicalRules(null) setSavedContentPlan(null) setStructureHash('') resetTechnicalState() resetContentState() resetSprintState() }, [resetContentState, resetSprintState, resetTechnicalState]) const clearPlanSelection = useCallback(() => { clearPlanEditorDraft(activePlanId) loadedLocationKeyRef.current = '' setActivePlanId(null) setPlanIntent(null) setLastSavedSnapshot('') navigate('/generate?intent=new&step=settings', { replace: true }) }, [activePlanId, navigate]) const applySavedTechnicalRules = useCallback((rules: SavedTechnicalRules | null) => { if (!rules) { resetTechnicalState() return } setTechnicalSessionId(rules.session_id) setTechnicalState({ ...extractTechnicalStateFromSavedRules(rules) }) }, [resetTechnicalState]) const applySavedContentPlan = useCallback((plan: SavedContentPlan | null) => { if (!plan) { resetContentState() return } setContentSessionId(plan.content_session_id) setContentState({ phase: 'content', ready: true, captured_answers: plan.content_answers, open_questions: [], question_cards: [], question_responses: {}, content_plan: plan.content_plan, }) }, [resetContentState]) const handleConfigUpdate = useCallback((newConfig: GenerationConfig) => { configRef.current = newConfig flushSync(() => { setConfig((current) => { const postTypeChanged = current.postType !== newConfig.postType const taxonomyChanged = current.taxonomy !== newConfig.taxonomy || current.term !== newConfig.term const reporterChanged = current.reporterId !== newConfig.reporterId if (postTypeChanged) { clearAdaptiveState() } else if (taxonomyChanged) { setSavedTechnicalRules(null) setSavedContentPlan(null) resetTechnicalState() resetContentState() } else if (reporterChanged) { setSavedContentPlan(null) resetContentState() } return newConfig }) }) }, [clearAdaptiveState, resetContentState, resetTechnicalState]) const handleStartOver = useCallback(() => { clearNewFlowDraft() if (typeof window !== 'undefined') { const keysToRemove: string[] = [] for (let i = 0; i < window.sessionStorage.length; i++) { const key = window.sessionStorage.key(i) if (key && key.startsWith('bc:')) { keysToRemove.push(key) } } keysToRemove.forEach((key) => window.sessionStorage.removeItem(key)) } restoredNewFlowRef.current = false setRestoredDraft(false) setStep('settings') setConfig(initialConfig) setGeneratedPosts([]) setStepError(null) setShowExitPrompt(false) clearAdaptiveState() clearPlanSelection() }, [clearAdaptiveState, clearPlanSelection]) const conversationPayload = useMemo(() => { const technicalSummary = String(technicalState.technical_summary || '') const technicalAnswers = buildTechnicalRulesPayload({ captured_answers: Array.isArray(technicalState.captured_answers) ? (technicalState.captured_answers as Record[]) : [], field_mapping: normalizeTechnicalFieldMapping(technicalState.field_mapping), }) if (useSprintMode && sprintResult) { return { technicalSummary, technicalAnswers, contentPlan: { ...((intentState.content_plan as ContentPlan | undefined) || createEmptyContentPlan()), research_enabled: false, } as ContentPlan, contentAnswers: Array.isArray(intentState.captured_answers) ? (intentState.captured_answers as Record[]) : [], sprintMode: true, sprintResult, sprintArticles, } } return { technicalSummary, technicalAnswers, contentPlan: (contentState.content_plan as ContentPlan | undefined) || createEmptyContentPlan(), contentAnswers: Array.isArray(contentState.captured_answers) ? (contentState.captured_answers as Record[]) : [], } }, [contentState, intentState, sprintArticles, sprintResult, technicalState, useSprintMode]) const currentSnapshot = useMemo(() => buildPlanSnapshot({ config, technicalState, contentState, technicalSessionId, contentSessionId, activePlanId, }), [activePlanId, config, contentSessionId, contentState, technicalSessionId, technicalState]) const routeState = (location.state || {}) as ContentGeneratorLocationState const routePlanId = Number(new URLSearchParams(location.search).get('planId') || routeState.planId || 0) const routeIntent = (() => { const searchIntent = new URLSearchParams(location.search).get('intent') if (searchIntent === 'new' || searchIntent === 'run' || searchIntent === 'review') { return searchIntent } if (routeState.intent === 'new' || routeState.intent === 'run' || routeState.intent === 'review') { return routeState.intent } return routePlanId > 0 ? 'review' : 'new' })() const routeStep = normalizeGeneratorStep(new URLSearchParams(location.search).get('step')) const routePostType = new URLSearchParams(location.search).get('postType') const isPlanContext = Boolean(activePlanId || savedContentPlan || routePlanId) const showPlanBar = step !== 'settings' || isPlanContext const hasUnsavedPlanChanges = isPlanContext && lastSavedSnapshot !== '' && currentSnapshot !== lastSavedSnapshot const exitPlanEditor = useCallback(() => { setShowExitPrompt(false) clearPlanSelection() navigate('/plans') }, [clearPlanSelection, navigate]) const hydratePlanFromSessions = useCallback(async (plan: SavedContentPlan) => { let nextPlan = plan let nextRules = plan.technical_rules if (plan.technical_session_id) { try { const technicalSessionRes = await endpoints.getChatSession(plan.technical_session_id) const technicalStructuredState = ((technicalSessionRes.data as any)?.structured_state || {}) as Record const technicalRulesPayload = buildTechnicalRulesPayload({ captured_answers: Array.isArray(technicalStructuredState.captured_answers) ? technicalStructuredState.captured_answers as Record[] : [], field_mapping: normalizeTechnicalFieldMapping(technicalStructuredState.field_mapping), }) if (!nextRules || technicalRulesPayload.length > 0 || String(technicalStructuredState.technical_summary || '').trim()) { nextRules = { id: nextRules?.id || 0, structure_hash: nextRules?.structure_hash || plan.structure_hash, still_valid: nextRules?.still_valid ?? plan.still_valid, summary: String(technicalStructuredState.technical_summary || nextRules?.summary || ''), rules: technicalRulesPayload.length > 0 ? technicalRulesPayload : (nextRules?.rules || []), updated_at: nextRules?.updated_at || null, source_session_id: nextRules?.source_session_id || null, session_id: plan.technical_session_id, } } } catch { // Keep the stored technical payload when session recovery fails. } } if (plan.content_session_id) { try { const contentSessionRes = await endpoints.getChatSession(plan.content_session_id) const contentStructuredState = ((contentSessionRes.data as any)?.structured_state || {}) as Record const recoveredPlan = (contentStructuredState.content_plan as ContentPlan | undefined) || createEmptyContentPlan() const recoveredAnswers = Array.isArray(contentStructuredState.captured_answers) ? contentStructuredState.captured_answers as Record[] : [] const shouldRecoverPlan = !hasMeaningfulContentPlan(plan.content_plan) && hasMeaningfulContentPlan(recoveredPlan) const shouldRecoverAnswers = (!Array.isArray(plan.content_answers) || plan.content_answers.length === 0) && recoveredAnswers.length > 0 const recoveredSummary = String(recoveredPlan.summary || plan.summary || '') if (shouldRecoverPlan || shouldRecoverAnswers || (!plan.summary && recoveredSummary)) { nextPlan = { ...nextPlan, summary: recoveredSummary, content_plan: shouldRecoverPlan ? recoveredPlan : nextPlan.content_plan, content_answers: shouldRecoverAnswers ? recoveredAnswers : nextPlan.content_answers, } } } catch { // Keep the stored content-plan payload when session recovery fails. } } return { plan: { ...nextPlan, technical_session_id: nextPlan.technical_session_id || nextRules?.session_id || null, technical_rules: nextRules, }, technicalRules: nextRules, } }, []) const saveTechnicalRulesState = useCallback(async (stateOverride?: Record) => { const currentConfig = configRef.current const sourceState = { ...createEmptyTechnicalState(), ...(stateOverride || technicalState), } if (!currentConfig.postType || !structureHash) { throw new Error(t('Structure data missing. Go back to Settings and continue from there.')) } const answers = buildTechnicalRulesPayload({ captured_answers: Array.isArray(sourceState.captured_answers) ? sourceState.captured_answers as Record[] : [], field_mapping: normalizeTechnicalFieldMapping(sourceState.field_mapping), }) const res = await endpoints.savePostTypeRules({ post_type: currentConfig.postType, taxonomy_scope: normalizeScopeValue(currentConfig.taxonomy) || undefined, term_scope: normalizeScopeValue(currentConfig.term) || undefined, selected_term_slugs: splitScopeList(currentConfig.term), structure_hash: structureHash, rules_json: answers, summary: String(sourceState.technical_summary || ''), technical_session_id: technicalSessionId || undefined, }) const data = res.data as any const nextRules = (data.technical_rules || null) as SavedTechnicalRules | null if (nextRules) { setSavedTechnicalRules(nextRules) setTechnicalSessionId(nextRules.session_id) } return nextRules }, [structureHash, technicalSessionId, technicalState]) const saveContentPlanState = useCallback(async (options?: { saveAsNew?: boolean contentStateOverride?: Record technicalStateOverride?: Record }) => { const currentConfig = configRef.current if (!currentConfig.postType || !currentConfig.reporterId) { throw new Error(t('Select a reporter before saving this plan.')) } const saveAsNew = Boolean(options?.saveAsNew) const nextContentState = normalizeContentPlanState(options?.contentStateOverride || contentState) const nextTechnicalState = options?.technicalStateOverride || technicalState const nextTechnicalRules = await saveTechnicalRulesState(nextTechnicalState) const resolvedTechnicalSessionId = nextTechnicalRules?.session_id || technicalSessionId || savedContentPlan?.technical_session_id || null const res = await endpoints.savePostTypeRules({ id: saveAsNew ? undefined : activePlanId || undefined, post_type: currentConfig.postType, taxonomy_scope: normalizeScopeValue(currentConfig.taxonomy) || undefined, term_scope: normalizeScopeValue(currentConfig.term) || undefined, selected_term_slugs: splitScopeList(currentConfig.term), name: currentConfig.planName || undefined, reporter_id: currentConfig.reporterId, structure_hash: structureHash, content_plan: nextContentState.content_plan, content_answers: nextContentState.captured_answers, content_plan_summary: nextContentState.content_plan.summary || '', topic: currentConfig.topic, keywords: currentConfig.keywords, count: currentConfig.count, length: currentConfig.length, scheduling: currentConfig.scheduling, count_per_category: currentConfig.countPerCategory, generation_type: currentConfig.generationType, repeat_frequency: currentConfig.generationType === 'repeating' ? currentConfig.repeatFrequency : undefined, schedule_config: currentConfig.generationType === 'repeating' ? { interval: currentConfig.repeatEvery, unit: currentConfig.repeatUnit, weekdays: currentConfig.repeatWeekdays, hour: currentConfig.repeatHour, stop_after_months: currentConfig.stopAfterMonths, max_total_runs: currentConfig.maxTotalRuns, max_total_posts: currentConfig.maxTotalPosts, } : undefined, is_active: currentConfig.isActive, technical_session_id: resolvedTechnicalSessionId || undefined, content_session_id: contentSessionId || savedContentPlan?.content_session_id || undefined, }) const data = res.data as any const nextPlan = (data.plan || null) as SavedContentPlan | null if (nextPlan) { if (saveAsNew && activePlanId && activePlanId !== nextPlan.id) { clearPlanEditorDraft(activePlanId) } setSavedContentPlan(nextPlan) setActivePlanId(nextPlan.id) setPlanIntent((current) => current === 'run' ? 'run' : 'review') setContentSessionId(nextPlan.content_session_id) setConfig((current) => ({ ...current, planName: nextPlan.name || current.planName, scheduling: (nextPlan.publishing_schedule || current.scheduling) as 'none' | 'immediate' | 'daily' | 'weekly', ...normalizeScheduleConfig(nextPlan.schedule_config, nextPlan.repeat_frequency || 'weekly'), ...normalizeScheduleCounters(nextPlan.schedule_counters), })) setLastSavedSnapshot(buildPlanSnapshot({ config: { ...currentConfig, planName: nextPlan.name || currentConfig.planName, scheduling: (nextPlan.publishing_schedule || currentConfig.scheduling) as 'none' | 'immediate' | 'daily' | 'weekly', ...normalizeScheduleConfig(nextPlan.schedule_config, nextPlan.repeat_frequency || 'weekly'), ...normalizeScheduleCounters(nextPlan.schedule_counters), }, technicalState: nextTechnicalState, contentState: nextContentState, technicalSessionId: resolvedTechnicalSessionId, contentSessionId: nextPlan.content_session_id, activePlanId: nextPlan.id, })) } return nextPlan }, [activePlanId, contentSessionId, contentState, saveTechnicalRulesState, savedContentPlan?.content_session_id, savedContentPlan?.technical_session_id, structureHash, technicalSessionId, technicalState]) const resolveReporter = useCallback(async (preferredReporterId?: number | null) => { const reportersRes = await endpoints.getReporters() const reporters = (reportersRes.data as Reporter[]) || [] const selected = reporters.find((item) => item.id === (preferredReporterId || 0)) || [...reporters] .sort((a, b) => new Date(b.last_used_at || 0).getTime() - new Date(a.last_used_at || 0).getTime())[0] || reporters.find((item) => Boolean(item.is_default)) || reporters[0] || null return { reporters, selected } }, []) const loadFreshContext = useCallback(async (postType: string, reporterId?: number | null) => { const structureRes = await endpoints.getStructure(postType) const structure = structureRes.data as PostStructure const nextHash = structure.structure_hash || '' const { selected } = await resolveReporter(reporterId) if (selected && reporterId !== selected.id) { setConfig((current) => ({ ...current, reporterId: selected.id })) } setStructureHash(nextHash) setSelectedReporter(selected) setSavedTechnicalRules(null) setSavedContentPlan(null) setTechnicalSessionId(null) setContentSessionId(null) return { structureHash: nextHash, reporter: selected, } }, [resolveReporter]) const loadAdaptiveContext = useCallback(async (postType: string, reporterId?: number | null) => { const structureRes = await endpoints.getStructure(postType) const structure = structureRes.data as PostStructure const nextHash = structure.structure_hash || '' const { selected } = await resolveReporter(reporterId) if (selected && reporterId !== selected.id) { setConfig((current) => ({ ...current, reporterId: selected.id })) } const rulesRes = await endpoints.getPostTypeRules(postType, { reporter_id: selected?.id, structure_hash: nextHash || undefined, taxonomy_scope: normalizeScopeValue(config.taxonomy) || undefined, term_scope: normalizeScopeValue(config.term) || undefined, }) const rules = rulesRes.data as PostTypeRulesResponse setStructureHash(nextHash) setSelectedReporter(selected) setSavedTechnicalRules(rules.technical_rules) setSavedContentPlan(rules.content_plan) setTechnicalSessionId(rules.technical_rules?.session_id ?? null) setContentSessionId(rules.content_plan?.content_session_id ?? null) if (rules.content_plan) { setConfig((current) => ({ ...current, planName: rules.content_plan?.name || current.planName, count: rules.content_plan?.post_count || current.count, countPerCategory: rules.content_plan?.count_per_category ?? current.countPerCategory, topic: rules.content_plan?.topic || current.topic, keywords: Array.isArray(rules.content_plan?.keywords) && rules.content_plan.keywords.length > 0 ? rules.content_plan.keywords : current.keywords, length: rules.content_plan?.post_length || current.length, scheduling: (rules.content_plan?.publishing_schedule || current.scheduling) as 'none' | 'immediate' | 'daily' | 'weekly', generationType: rules.content_plan?.generation_type || current.generationType, repeatFrequency: rules.content_plan?.repeat_frequency || current.repeatFrequency, ...normalizeScheduleConfig(rules.content_plan?.schedule_config, rules.content_plan?.repeat_frequency || 'weekly'), ...normalizeScheduleCounters(rules.content_plan?.schedule_counters), isActive: rules.content_plan?.is_active ?? current.isActive, })) } return { structureHash: nextHash, reporter: selected, technicalRules: rules.technical_rules, contentPlan: rules.content_plan, } }, [config.taxonomy, config.term, resolveReporter]) const loadPlanFromRoute = useCallback(async (planId: number, intent: 'run' | 'review', requestedStep: GeneratorStep) => { setLoadingStep(true) setStepError(null) try { const res = await endpoints.getContentPlanById(planId) const hydrated = await hydratePlanFromSessions(res.data as SavedContentPlan) const plan = hydrated.plan const storedDraft = readPlanEditorDraft(plan.id) let reporter: Reporter | null = null const reporterId = storedDraft?.config?.reporterId ?? plan.reporter_id if (reporterId) { try { const reporterRes = await endpoints.getReporter(reporterId) reporter = reporterRes.data as Reporter } catch { reporter = null } } setActivePlanId(plan.id) setPlanIntent(intent) setSavedContentPlan(plan) setSavedTechnicalRules(hydrated.technicalRules) setStructureHash(storedDraft?.structureHash || plan.structure_hash) setSelectedReporter(reporter) setGeneratedPosts([]) const baseConfig: GenerationConfig = { postType: plan.post_type, taxonomy: normalizeScopeValue(plan.taxonomy_scope), term: normalizeScopeValue(plan.term_scope), planName: plan.name || '', reporterId: plan.reporter_id || null, count: plan.post_count || 10, countPerCategory: plan.count_per_category, topic: plan.topic || '', keywords: Array.isArray(plan.keywords) ? plan.keywords : [], length: plan.post_length || 'auto', scheduling: (plan.publishing_schedule || 'none') as 'none' | 'immediate' | 'daily' | 'weekly', generationType: plan.generation_type || 'one_time', repeatFrequency: plan.repeat_frequency || 'weekly', ...normalizeScheduleConfig(plan.schedule_config, plan.repeat_frequency || 'weekly'), ...normalizeScheduleCounters(plan.schedule_counters), isActive: plan.is_active, } const nextConfig: GenerationConfig = storedDraft?.config ? { ...baseConfig, ...storedDraft.config, } : baseConfig setConfig(nextConfig) const nextTechnicalState = storedDraft?.technicalState ? { ...createEmptyTechnicalState(), ...storedDraft.technicalState, captured_answers: Array.isArray(storedDraft.technicalState.captured_answers) ? storedDraft.technicalState.captured_answers : [], open_questions: Array.isArray(storedDraft.technicalState.open_questions) ? storedDraft.technicalState.open_questions : [], } : (hydrated.technicalRules ? { ...extractTechnicalStateFromSavedRules(hydrated.technicalRules) } : { ...createEmptyTechnicalState() }) const nextContentState = storedDraft?.contentState ? normalizeContentPlanState(storedDraft.contentState) : { phase: 'content', ready: true, captured_answers: plan.content_answers, open_questions: [], content_plan: plan.content_plan, } const resolvedTechnicalSessionId = resolveDraftSessionId( storedDraft?.technicalSessionId, plan.technical_session_id, hydrated.technicalRules?.session_id, ) const resolvedContentSessionId = resolveDraftSessionId( storedDraft?.contentSessionId, plan.content_session_id, ) setTechnicalSessionId(resolvedTechnicalSessionId) setContentSessionId(resolvedContentSessionId) setTechnicalState(nextTechnicalState) setContentState(nextContentState) if (storedDraft?.intentState && Object.keys(storedDraft.intentState).length > 0) { setIntentState(storedDraft.intentState) } if (storedDraft?.intentSessionId) { setIntentSessionId(storedDraft.intentSessionId) } if (storedDraft?.sprintResult) { setSprintResult(storedDraft.sprintResult) } if (storedDraft?.sprintArticles && storedDraft.sprintArticles.length > 0) { setSprintArticles(storedDraft.sprintArticles) } setLastSavedSnapshot(buildPlanSnapshot({ config: nextConfig, technicalState: nextTechnicalState, contentState: nextContentState, technicalSessionId: resolvedTechnicalSessionId, contentSessionId: resolvedContentSessionId, activePlanId: plan.id, })) if (intent === 'review') { const validReviewSteps: GeneratorStep[] = ['settings', 'technical', 'intent', 'content'] const targetStep = validReviewSteps.includes(requestedStep) ? requestedStep : 'settings' setStep(targetStep) } else if (intent === 'run') { if (!storedDraft) { try { const updated = await endpoints.markContentPlanRun(plan.id) const refreshedPlan = updated.data as SavedContentPlan setSavedContentPlan(refreshedPlan) } catch { // Non-fatal. The plan can still run. } } const validRunSteps: GeneratorStep[] = ['settings', 'technical', 'intent', 'content'] const targetRunStep = requestedStep && validRunSteps.includes(requestedStep as GeneratorStep) ? (requestedStep as GeneratorStep) : 'settings' setStep(targetRunStep) } else { setStep(storedDraft?.step || requestedStep) } } catch (error) { setStepError(getErrorMessage(error, t('Unknown error'))) setStep('settings') } finally { setLoadingStep(false) } }, [hydratePlanFromSessions]) useEffect(() => { const planId = routePlanId const intent = routeIntent const requestedStep = routeStep const loadKey = `${planId}:${intent || 'none'}` if (!planId || !intent || intent === 'new' || loadedLocationKeyRef.current === loadKey) { return } if (saveGenerationRef.current > lastLoadedSaveGenRef.current) { loadedLocationKeyRef.current = loadKey lastLoadedSaveGenRef.current = saveGenerationRef.current return } loadedLocationKeyRef.current = loadKey void loadPlanFromRoute(planId, intent, requestedStep) }, [loadPlanFromRoute, routeIntent, routePlanId, routeStep]) useEffect(() => { if (activePlanId || routePlanId) { return } if (planIntent !== 'new') { setPlanIntent('new') } }, [activePlanId, planIntent, routePlanId]) const appliedRoutePostTypeRef = useRef(false) useEffect(() => { if (appliedRoutePostTypeRef.current || !routePostType || routePlanId || activePlanId) { return } appliedRoutePostTypeRef.current = true setConfig((prev) => ({ ...prev, postType: routePostType })) }, [activePlanId, routePlanId, routePostType]) const restoredNewFlowRef = useRef(false) useEffect(() => { if (restoredNewFlowRef.current || activePlanId || routePlanId) { return } if (planIntent !== 'new' || (step !== 'settings' && step !== 'select')) { return } const saved = readNewFlowDraft() if (!saved || !saved.config?.postType) { return } restoredNewFlowRef.current = true setRestoredDraft(true) setConfig(saved.config) setStep(saved.step) setTechnicalState(saved.technicalState || { ...createEmptyTechnicalState() }) setContentState(saved.contentState || { phase: 'content', ready: false, captured_answers: [], open_questions: [], question_cards: [], question_responses: {}, content_plan: createEmptyContentPlan(), }) setTechnicalSessionId(saved.technicalSessionId) setContentSessionId(saved.contentSessionId) setStructureHash(saved.structureHash || '') if (saved.intentState) setIntentState(saved.intentState) if (saved.intentSessionId) setIntentSessionId(saved.intentSessionId) if (saved.sprintResult) setSprintResult(saved.sprintResult) if (saved.sprintArticles) setSprintArticles(saved.sprintArticles) // Restore active sprint job if we're on the sprint step if (saved.step === 'sprint') { const savedJobId = sessionStorage.getItem('bc:active-sprint-job') if (savedJobId) { setActiveSprintJobId(savedJobId) } } }, [activePlanId, planIntent, routePlanId, step]) useEffect(() => { if (!activePlanId || !planIntent) { return } const nextSearch = buildGeneratorSearch({ planId: activePlanId, intent: planIntent, step }) if (location.pathname === '/generate' && location.search === nextSearch) { return } navigate(`/generate${nextSearch}`, { replace: true }) }, [activePlanId, location.pathname, location.search, navigate, planIntent, step]) useEffect(() => { if (activePlanId || planIntent !== 'new') { return } const nextSearch = buildGeneratorSearch({ intent: 'new', step }) if (location.pathname === '/generate' && location.search === nextSearch) { return } navigate(`/generate${nextSearch}`, { replace: true }) }, [activePlanId, location.pathname, location.search, navigate, planIntent, step]) useEffect(() => { if (!activePlanId || !planIntent) { return } writePlanEditorDraft(activePlanId, { version: 1, planId: activePlanId, intent: planIntent === 'run' ? 'run' : 'review', step, config, technicalState, contentState, technicalSessionId, contentSessionId, structureHash, intentState, intentSessionId, sprintResult, sprintArticles, }) }, [activePlanId, config, contentSessionId, contentState, intentSessionId, intentState, planIntent, sprintArticles, sprintResult, step, structureHash, technicalSessionId, technicalState]) useEffect(() => { if (activePlanId || planIntent !== 'new') { return } const hasProgress = (step !== 'settings' && step !== 'select') || config.postType !== '' if (!hasProgress) { return } writeNewFlowDraft({ version: 1, timestamp: Date.now(), step, config, technicalState, contentState, technicalSessionId, contentSessionId, structureHash, intentState, intentSessionId, sprintResult, sprintArticles, }) }, [activePlanId, config, contentSessionId, contentState, intentSessionId, intentState, planIntent, sprintArticles, sprintResult, step, structureHash, technicalSessionId, technicalState]) const settingsCompletingRef = useRef(false) const previousConfigRef = useRef({ postType: config.postType, taxonomy: config.taxonomy, term: config.term, reporterId: config.reporterId }) const handleSettingsComplete = useCallback(async () => { if (settingsCompletingRef.current) return settingsCompletingRef.current = true setLoadingStep(true) setStepError(null) try { const prev = previousConfigRef.current const postTypeChanged = config.postType !== prev.postType const taxonomyChanged = config.taxonomy !== prev.taxonomy || config.term !== prev.term const reporterChanged = config.reporterId !== prev.reporterId const structuralChange = postTypeChanged || taxonomyChanged previousConfigRef.current = { postType: config.postType, taxonomy: config.taxonomy, term: config.term, reporterId: config.reporterId } const shouldStayFresh = planIntent === 'new' && !activePlanId const isLoadedPlanSelection = Boolean( activePlanId && savedContentPlan && savedContentPlan.post_type === config.postType && normalizeScopeValue(savedContentPlan.taxonomy_scope) === normalizeScopeValue(config.taxonomy) && normalizeScopeValue(savedContentPlan.term_scope) === normalizeScopeValue(config.term) ) if (structuralChange || shouldStayFresh) { if (shouldStayFresh) { await loadFreshContext(config.postType, config.reporterId) } else if (isLoadedPlanSelection) { const structureRes = await endpoints.getStructure(config.postType) const structure = structureRes.data as PostStructure const { selected } = await resolveReporter(config.reporterId) setStructureHash(structure.structure_hash || '') setSelectedReporter(selected) } else { await loadAdaptiveContext(config.postType, config.reporterId) } } else if (reporterChanged) { const { selected } = await resolveReporter(config.reporterId) setSelectedReporter(selected) } setStep('technical') } catch (error) { setStepError(getErrorMessage(error, t('Failed to load technical context'))) } finally { setLoadingStep(false) settingsCompletingRef.current = false } }, [activePlanId, config.postType, config.reporterId, config.taxonomy, config.term, loadAdaptiveContext, loadFreshContext, planIntent, resolveReporter, savedContentPlan]) const handleUseSavedTechnicalRules = useCallback(() => { const nextStep = useSprintMode ? 'intent' : 'content' if (!savedTechnicalRules) { setStep(nextStep) return } applySavedTechnicalRules(savedTechnicalRules) setStep(nextStep) }, [applySavedTechnicalRules, savedTechnicalRules, useSprintMode]) const handleTechnicalContinue = useCallback(async (state: Record) => { setTechnicalState(state) if (!structureHash) { setStepError(t('Structure data missing. Go back to Settings and continue from there.')) return } try { await saveTechnicalRulesState(state) setStep(useSprintMode ? 'intent' : 'content') } catch (error) { setStepError(getErrorMessage(error, t('Failed to save technical rules'))) } }, [saveTechnicalRulesState, structureHash, useSprintMode]) const handleReuseSavedContentPlan = useCallback(async () => { if (savedTechnicalRules) { applySavedTechnicalRules(savedTechnicalRules) } if (!savedContentPlan) { setStep('intent') return } applySavedContentPlan(savedContentPlan) if (activePlanId) { try { const updated = await endpoints.markContentPlanRun(activePlanId) setSavedContentPlan(updated.data as SavedContentPlan) } catch { // Non-fatal. Generation can continue. } } setStep('intent') }, [activePlanId, applySavedContentPlan, applySavedTechnicalRules, savedContentPlan, savedTechnicalRules]) const primeContentStateFromSavedPlan = useCallback(() => { if (!savedContentPlan) { return } applySavedContentPlan(savedContentPlan) }, [applySavedContentPlan, savedContentPlan]) const handleContentContinue = useCallback(async (state: Record) => { setContentState(state) try { await saveContentPlanState({ contentStateOverride: state }) setStep('intent') } catch (error) { setStepError(getErrorMessage(error, t('Failed to save content plan'))) } }, [saveContentPlanState]) const [saveToast, setSaveToast] = useState(null) const toastTimerRef = useRef(null) useEffect(() => { return () => { if (toastTimerRef.current) clearTimeout(toastTimerRef.current) } }, []) const handleSaveCurrentPlan = useCallback(async (saveAsNew = false) => { saveGenerationRef.current += 1 setSavingPlanAction(saveAsNew ? 'saveAs' : 'save') setStepError(null) try { const savedPlan = await saveContentPlanState({ saveAsNew }) if (saveAsNew) { setShowExitPrompt(false) } clearNewFlowDraft() if (savedPlan && planIntent === 'new') { setPlanIntent('review') } if (savedPlan?.id) { const intent = planIntent === 'run' ? 'run' : 'review' loadedLocationKeyRef.current = `${savedPlan.id}:${intent}` lastLoadedSaveGenRef.current = saveGenerationRef.current } setSaveToast(t('All current changes saved.')) if (toastTimerRef.current) clearTimeout(toastTimerRef.current) toastTimerRef.current = window.setTimeout(() => setSaveToast(null), 3000) return savedPlan } catch (error) { setStepError(getErrorMessage(error, t('Unknown error'))) return null } finally { setSavingPlanAction(null) } }, [planIntent, saveContentPlanState]) return (
{showPlanBar && (
{isPlanContext ? (
{t('Editing plan')}
{config.planName || savedContentPlan?.name || t('Untitled Plan')}
{hasUnsavedPlanChanges ? t('You have unsaved changes in this plan.') : t('All current plan changes are saved.')}
) : (
{t('New plan (unsaved)')}
)}
)} {saveToast ? (
{saveToast}
) : null} {stepError ? (
{stepError}
) : null}
{loadingStep ? (
) : null} {!loadingStep && step === 'settings' && ( void handleSettingsComplete()} /> )} {!loadingStep && step === 'technical' && ( setStep('settings')} onUseSavedRules={handleUseSavedTechnicalRules} onEditSavedRules={() => { setTechnicalSessionId(savedTechnicalRules?.session_id ?? null) setForceTechnicalReview(true) }} onContinue={(state) => void handleTechnicalContinue(state)} onSkip={() => setStep(useSprintMode ? 'intent' : 'content')} /> )} {!loadingStep && step === 'content' && ( setStep('technical')} onReuseSavedPlan={handleReuseSavedContentPlan} onEditSavedPlan={() => { primeContentStateFromSavedPlan() setContentSessionId(savedContentPlan?.content_session_id ?? null) setForceContentReview(true) }} onStartFresh={() => { setSavedContentPlan(null) resetContentState() setForceContentReview(true) }} onContinue={(state) => void handleContentContinue(state)} onSkip={() => setStep('intent')} /> )} {!loadingStep && step === 'intent' && ( { if (submittingIntent) return void (async () => { setIntentState(state) setSubmittingIntent(true) setStepError(null) try { const savedPlan = await handleSaveCurrentPlan(false) const planId = savedPlan?.id || activePlanId if (!planId) { setStepError(t('Failed to save plan. Please try again.')) setSubmittingIntent(false) return } if (config.generationType === 'repeating') { navigate('/jobs') } else { await endpoints.sprintGenerate({ post_type: config.postType, taxonomy: config.taxonomy || undefined, term: config.term || undefined, reporter_id: config.reporterId, topic: config.topic, keywords: config.keywords, count: config.count, length: config.length, intent_state: state, technical_state: technicalState, intent_session_id: intentSessionId, plan_id: planId, }) navigate('/jobs') } } catch (err) { setSubmittingIntent(false) setStepError(getErrorMessage(err, t('Unknown error'))) } })() }} onBack={() => setStep('technical')} onSkip={() => { if (submittingIntent) return void (async () => { setSubmittingIntent(true) setStepError(null) try { const savedPlan = await handleSaveCurrentPlan(false) const planId = savedPlan?.id || activePlanId if (!planId) { setStepError(t('Failed to save plan. Please try again.')) setSubmittingIntent(false) return } if (config.generationType === 'repeating') { navigate('/jobs') } else { await endpoints.sprintGenerate({ post_type: config.postType, taxonomy: config.taxonomy || undefined, term: config.term || undefined, reporter_id: config.reporterId, topic: config.topic, keywords: config.keywords, count: config.count, length: config.length, intent_state: intentState, technical_state: technicalState, intent_session_id: intentSessionId, plan_id: planId, }) navigate('/jobs') } } catch (err) { setSubmittingIntent(false) setStepError(getErrorMessage(err, t('Unknown error'))) } })() }} submitting={submittingIntent} /> )}
{showExitPrompt ? (

{t('Unsaved changes')}

{t('This plan has unsaved changes. Choose how you want to leave this editor.')}

) : null}
) }