const { useState, useMemo, useEffect, useRef } = React; const LIVE_SOURCES = ['foreplay', 'adspy', 'brandsearch']; const MANUAL_SEARCH_ALL_COMPETITORS_ID = '__all_competitors__'; const AR_TARGET_MIN = 1; const AR_TARGET_MAX = window.RESILIA_LIMITS?.autoresearchPerDirectionMax || 1000; const SEARCH_ADS_PER_DIRECTION_MAX = window.RESILIA_LIMITS?.searchAdsPerDirectionMax || 10000; const SEARCH_ADS_PER_PLATFORM_MAX = window.RESILIA_LIMITS?.searchAdsPerPlatformMax || 100000; const AUTORESEARCH_PAGE_SIZE = 50; const AUTO_REMODEL_COUNT_MAX = window.RESILIA_LIMITS?.autoRemodelCountMax || 10000; const AUTO_REMODEL_PAGE_SIZE = 50; const AUTO_REMODEL_PREVIOUS_PAGE_SIZE = 10; const PIPELINE_PAGE_SIZE = 50; const TAB_STORAGE_KEY = 'resilia.dashboard.activeTab'; const SEARCH_PASS_THRESHOLD = 7.5; const DASHBOARD_TABS = new Set(['autoRemodel', 'research', 'filter', 'remodel', 'video', 'accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminCosts', 'adminWorkspaces', 'adminUsers']); const savedDashboardTab = () => { try { const saved = window.localStorage.getItem(TAB_STORAGE_KEY); return DASHBOARD_TABS.has(saved) ? saved : null; } catch { return null; } }; const boundedPositiveIntOrNull = (value, max = AR_TARGET_MAX) => { if (value === null || value === undefined || value === '') return null; const n = Number(value); if (!Number.isFinite(n)) return null; return Math.max(AR_TARGET_MIN, Math.min(max, Math.floor(n))); }; const COST_FLOW_ORDER = ['search', 'autoresearch', 'auto_remodel']; const COST_FLOW_LABELS = { search: 'Search', autoresearch: 'Auto Research', auto_remodel: 'Auto-Remodel', }; const costMoney = (value) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4, }).format(Number(value || 0)); const compactNumber = (value) => new Intl.NumberFormat('en-US').format(Number(value || 0)); const sanitizeArTargetInput = (value, max = AR_TARGET_MAX) => { if (value === '') return ''; const n = boundedPositiveIntOrNull(value, max); return n === null ? '' : String(n); }; const sourceState = (selected = ['foreplay']) => { const active = new Set(Array.isArray(selected) ? selected : [selected]); return { foreplay: active.has('foreplay'), adspy: active.has('adspy'), brandsearch: active.has('brandsearch'), metaads: false, }; }; const allSourceState = () => sourceState(LIVE_SOURCES); const toggleSourceState = (current, key) => { if (!LIVE_SOURCES.includes(key)) return current || sourceState(); const next = { ...(current || sourceState()) }; next[key] = !next[key]; next.metaads = false; return next; }; const defaultSearchFilters = () => ({ fpFormat: 'video', fpPublisherPlatforms: '', fpLanguages: '', fpLiveStatus: 'live', fpMinDays: '15', fpOrder: 'longest_running', minLikes: '', minDailyLikes: '', orderBy: '', adspySiteType: '', adspyMediaType: '', adspyGender: '', adspyAgeMin: '', adspyAgeMax: '', adspyCountries: '', adspyLang: '', adspyCreatedFrom: '', adspyCreatedTo: '', adspySeenFrom: '', adspySeenTo: '', adspyUsername: '', adspyUserId: '', adspyAffNetwork: '', adspyAffId: '', adspyOfferId: '', adspyButtons: '', adspyUrl: '', adspyLandingUrl: '', bsPlatform: '', bsMinViews: '', bsMinLikes: '', bsMinEngagement: '', bsSort: '', foreplayUrl: '', brandsearchUrl: '', }); const DIRECTION_PLATFORM_DEFAULT_FILTERS = { foreplay: { display_format: ['video'], languages: ['en'], running_duration_min_days: 15, live: true, order: 'longest_running', }, adspy: { mediaType: 'video' }, brandsearch: { display_format: ['video'], brandsearch_status: 'active' }, }; const cloneDirectionPlatformDefaults = (platform) => Object.fromEntries( Object.entries(DIRECTION_PLATFORM_DEFAULT_FILTERS[platform] || {}) .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]), ); const emptyDirectionPlatformFilters = () => Object.fromEntries( LIVE_SOURCES.map(platform => [platform, cloneDirectionPlatformDefaults(platform)]), ); const DIRECTION_FILTER_FIELDS = { foreplay: [ { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image'], ['carousel', 'Carousel']] }, { key: 'publisher_platform', label: 'Publisher', type: 'list', placeholder: 'facebook, instagram' }, { key: 'languages', label: 'Lang', type: 'list', placeholder: 'en' }, { key: 'running_duration_min_days', label: 'Min days', type: 'number', min: 0, placeholder: '14' }, { key: 'live', label: 'Status', type: 'boolean', emptyLabel: 'Any status', trueLabel: 'Live only', falseLabel: 'Inactive only' }, { key: 'order', label: 'Order', type: 'select', options: [['', 'Default'], ['longest_running', 'Longest'], ['saved_newest', 'Newest'], ['saved_oldest', 'Oldest']] }, ], adspy: [ { key: 'siteType', label: 'Network', type: 'select', options: [['', 'FB+IG'], ['facebook', 'Facebook'], ['instagram', 'Instagram']] }, { key: 'mediaType', label: 'Format', type: 'select', options: [['', 'Any format'], ['video', 'Video'], ['photo', 'Photo']] }, { key: 'gender', label: 'Gender', type: 'select', options: [['', 'Any gender'], ['female', 'Female'], ['male', 'Male']] }, { key: 'ages', label: 'Age', type: 'range', min: 18, max: 64, startPlaceholder: '18', endPlaceholder: '64' }, { key: 'totalLikes', label: 'Likes', type: 'range', min: 0, max: 100000, startPlaceholder: '0', endPlaceholder: '100000' }, { key: 'dailyLikes', label: 'Daily', type: 'range', min: 0, max: 1000, startPlaceholder: '0', endPlaceholder: '1000' }, { key: 'orderBy', label: 'Sort', type: 'select', options: [['', 'Default'], ['total_likes', 'Likes'], ['total_shares', 'Shares'], ['created_on_asc', 'Oldest']] }, { key: 'countries', label: 'Geo', type: 'list', placeholder: 'US, GB' }, { key: 'lang', label: 'Lang', type: 'text', placeholder: 'eng' }, { key: 'username', label: 'User', type: 'text', placeholder: 'username' }, { key: 'userId', label: 'Page ID', type: 'number', min: 1, placeholder: 'id' }, { key: 'affNetwork', label: 'Net', type: 'number', min: 1, placeholder: 'id' }, { key: 'affId', label: 'Aff', type: 'text', placeholder: 'aff id' }, { key: 'offerId', label: 'Offer', type: 'text', placeholder: 'offer id' }, { key: 'buttons', label: 'CTA', type: 'list', placeholder: 'Shop Now' }, { key: 'createdBetween', label: 'Created', type: 'dateRange' }, { key: 'seenBetween', label: 'Seen', type: 'dateRange' }, ], brandsearch: [ { key: 'brandsearch_platforms', label: 'Network', type: 'select', array: true, options: [['', 'All BS'], ['meta', 'Meta'], ['tiktok', 'TikTok'], ['instagram', 'Instagram']] }, { key: 'display_format', label: 'Format', type: 'select', array: true, options: [['', 'Any format'], ['video', 'Video'], ['image', 'Image']] }, { key: 'brandsearch_status', label: 'Status', type: 'select', options: [['', 'Any status'], ['active', 'Live only'], ['inactive', 'Inactive only']] }, { key: 'brandsearch_min_views', label: 'Views', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_likes', label: 'Likes', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_engagement_rate', label: 'ER%', type: 'number', min: 0, step: 0.1, placeholder: '0' }, { key: 'brandsearch_sort', label: 'Sort', type: 'select', options: [['', 'Default'], ['play_count', 'Views'], ['like_count', 'Likes'], ['engagement_rate', 'ER%'], ['eu_total_reach', 'Reach'], ['eu_total_spend', 'Spend']] }, { key: 'brandsearch_sort_order', label: 'Order', type: 'select', options: [['', 'Default'], ['desc', 'Desc'], ['asc', 'Asc']] }, { key: 'brandsearch_min_reach', label: 'Reach', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_min_spend', label: 'Min spend', type: 'number', min: 0, placeholder: '0' }, { key: 'brandsearch_max_spend', label: 'Max spend', type: 'number', min: 0, placeholder: '0' }, ], }; const asFilterList = (value) => { if (value === null || value === undefined || value === '') return []; if (Array.isArray(value)) return value.filter(item => item !== null && item !== undefined && item !== ''); if (typeof value === 'string') return value.split(',').map(item => item.trim()).filter(Boolean); return [value]; }; const listText = (value) => asFilterList(value).join(', '); const setIfMissing = (out, key, value) => { if (out[key] === undefined && value !== undefined && value !== null && value !== '') out[key] = value; }; const normalizeDirectionApiFilters = (platform, filters = {}) => { const out = { ...cloneDirectionPlatformDefaults(platform), ...(filters && typeof filters === 'object' && !Array.isArray(filters) ? filters : {}), }; if (platform === 'adspy') { setIfMissing(out, 'siteType', out.adspy_site_type); setIfMissing(out, 'mediaType', out.adspy_media_type); setIfMissing(out, 'gender', out.adspy_gender); setIfMissing(out, 'orderBy', out.adspy_order); setIfMissing(out, 'username', out.adspy_username); setIfMissing(out, 'userId', out.adspy_user_id); setIfMissing(out, 'affNetwork', out.adspy_aff_network); setIfMissing(out, 'affId', out.adspy_aff_id); setIfMissing(out, 'offerId', out.adspy_offer_id); setIfMissing(out, 'buttons', out.adspy_buttons); setIfMissing(out, 'lang', Array.isArray(out.languages) ? out.languages[0] : out.languages); if (out.ages === undefined && (out.adspy_age_min || out.adspy_age_max)) out.ages = [out.adspy_age_min, out.adspy_age_max].filter(Boolean); if (out.totalLikes === undefined && (out.adspy_min_likes || out.adspy_max_likes)) out.totalLikes = [out.adspy_min_likes, out.adspy_max_likes].filter(Boolean); if (out.dailyLikes === undefined && (out.adspy_min_daily_likes || out.adspy_max_daily_likes)) out.dailyLikes = [out.adspy_min_daily_likes, out.adspy_max_daily_likes].filter(Boolean); if (out.createdBetween === undefined && (out.adspy_created_from || out.adspy_created_to)) out.createdBetween = [out.adspy_created_from, out.adspy_created_to].filter(Boolean); if (out.seenBetween === undefined && (out.adspy_seen_from || out.adspy_seen_to)) out.seenBetween = [out.adspy_seen_from, out.adspy_seen_to].filter(Boolean); } if (platform === 'brandsearch') { setIfMissing(out, 'brandsearch_platforms', out.brandsearch_platform); } return out; }; const cleanApiFilters = (filters = {}) => Object.fromEntries( Object.entries(filters || {}).flatMap(([key, value]) => { if (value === null || value === undefined || value === '') return []; if (Array.isArray(value)) { const list = value.map(item => (typeof item === 'string' ? item.trim() : item)).filter(item => item !== null && item !== undefined && item !== ''); return list.length ? [[key, list]] : []; } if (typeof value === 'string') { const text = value.trim(); return text ? [[key, text]] : []; } return [[key, value]]; }), ); function App() { const [authState, setAuthState] = useState({ status: 'loading', user: null }); const queryTab = new URLSearchParams(window.location.search).get('tab'); const initialTab = window.location.pathname === '/admin/access-requests' ? 'accessRequests' : (DASHBOARD_TABS.has(queryTab) ? queryTab : (savedDashboardTab() || 'research')); const [tab, setTab] = useState(initialTab); // search rows (manual keyword / competitor search) const [rows, setRows] = useState([ { id: 1, query: '', competitorId: null, count: 20, filters: defaultSearchFilters(), platforms: sourceState() }, ]); const [competitors, setCompetitors] = useState([]); const [directions, setDirections] = useState([]); const directionsRef = useRef([]); const arDefaultDirectionsLoadedRef = useRef(false); // pipeline stages const [researchAds, setResearchAds] = useState([]); const [filteredAds, setFilteredAds] = useState([]); const [remodeledAds, setRemodeledAds] = useState([]); const [filterPage, setFilterPage] = useState({ loaded: 0, hasMore: false, loading: false }); const [remodelPage, setRemodelPage] = useState({ loaded: 0, hasMore: false, loading: false }); const [autoRemodelAds, setAutoRemodelAds] = useState([]); const [remodelScripts, setRemodelScripts] = useState({}); // { [adId]: [{tag, body}] | 'pending' | 'error' } const remodelPollRef = useRef(null); const [convertedAds, setConvertedAds] = useState([]); const [autoresearchActive, setAutoresearchActive] = useState(false); // true if last Research view should show winners-only badge const [selResearch, setSelResearch] = useState(new Set()); const [selFilter, setSelFilter] = useState(new Set()); const [selRemodel, setSelRemodel] = useState(new Set()); // autoresearch controls const [arSelected, setArSelected] = useState(new Set()); const [arTarget, setArTarget] = useState('1'); const setBoundedArTarget = (value) => setArTarget(sanitizeArTargetInput(value)); const [arMaxAdsPerDirection, setArMaxAdsPerDirection] = useState('100'); const [arMaxAdsPerPlatform, setArMaxAdsPerPlatform] = useState(''); const setBoundedArMaxAdsPerDirection = (value) => setArMaxAdsPerDirection(sanitizeArTargetInput(value, SEARCH_ADS_PER_DIRECTION_MAX)); const setBoundedArMaxAdsPerPlatform = (value) => setArMaxAdsPerPlatform(sanitizeArTargetInput(value, SEARCH_ADS_PER_PLATFORM_MAX)); const [arNumDirections, setArNumDirections] = useState(''); const [arPickedDirections, setArPickedDirections] = useState(new Set()); const [arRunning, setArRunning] = useState(false); const [arProgress, setArProgress] = useState(null); // { totalScored, winners, directionsSatisfied, directionsTotal, crawlState } const [arSources, setArSources] = useState(sourceState()); const [autoresearchPage, setAutoresearchPage] = useState({ loaded: 0, total: null, hasMore: false, loading: false }); const autoresearchLoadedIdsRef = useRef(new Set()); const autoresearchCurrentIdsRef = useRef(new Set()); // Bug retention — each Autoresearch run is tagged with a monotonically // increasing batch id so the UI can show new winners on top while older // batches stay below as history. const autoresearchBatchIdRef = useRef(null); // Platform-level credit balances surfaced in the top bar. Foreplay is live; // the other three are visual placeholders until their backends land. const [platformCredits, setPlatformCredits] = useState({ foreplay: null, adspy: null, brandsearch: null, metaads: null, }); const arPollRef = useRef(null); // ---- Auto-Remodel state ----------------------------------------------- // The orchestrator's directions picker is shared with manual autoresearch // — both pull from the same full direction pool. We keep it in App so the // panel + the autoresearch panel can stay independent components without // losing user picks when one re-renders. const [arOrchPickedDirections, setArOrchPickedDirections] = useState(new Set()); const toggleArOrchPickedDirection = (id) => setArOrchPickedDirections(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const clearArOrchPickedDirections = () => setArOrchPickedDirections(new Set()); const [arRunId, setArRunId] = useState(null); const [arStage, setArStage] = useState(null); const [arLastEvent, setArLastEvent] = useState(null); const [arPickedIds, setArPickedIds] = useState([]); const [arStartPending, setArStartPending] = useState(false); // Refs that mirror the orchestrator state — the SSE callback below is // installed once via useEffect([]), so any state value referenced inside // its closure is captured at mount-time and goes stale. Refs let the // handler read the *current* value when an event arrives. const arRunIdRef = useRef(null); const arStageRef = useRef(null); const arPickedIdsRef = useRef([]); const arTargetAdsRef = useRef(0); const arAcceptedAdIdsRef = useRef(new Set()); const arStartPendingRef = useRef(false); useEffect(() => { arRunIdRef.current = arRunId; }, [arRunId]); useEffect(() => { arStageRef.current = arStage; }, [arStage]); useEffect(() => { arPickedIdsRef.current = arPickedIds; }, [arPickedIds]); useEffect(() => { arTargetAdsRef.current = arTargetAds; }, [arTargetAds]); // Live progress counters — fed by SSE so the panel meta line + progress // bar tick exactly like the autoresearch panel does. ``arNVariants`` is // captured at Run time so we know the variants_total denominator. const [arDirectionsDone, setArDirectionsDone] = useState(0); const [arDirectionsTotal, setArDirectionsTotal] = useState(0); const [arAdsFound, setArAdsFound] = useState(0); const [arTargetAds, setArTargetAds] = useState(0); const [arVariantsDone, setArVariantsDone] = useState(0); const [arNVariants, setArNVariants] = useState(1); const [arVariantsTotalOverride, setArVariantsTotalOverride] = useState(null); const [autoRemodelHydration, setAutoRemodelHydration] = useState({ total: 0, loaded: 0, loading: false }); const [autoRemodelRestoring, setAutoRemodelRestoring] = useState(false); const autoRemodelLoadedIdsRef = useRef(new Set()); const autoRemodelHydratingPageRef = useRef(false); const arNVariantsRef = useRef(1); const arRunningOrch = !!arRunId && !['done', 'failed', 'cancelled'].includes(arStage); useEffect(() => { arNVariantsRef.current = arNVariants; }, [arNVariants]); // Platform toggles for the Auto-Remodel panel. Kept separate from // ``arSources`` (which the manual autoresearch panel owns) so the user // can configure each panel independently. const [arOrchSources, setArOrchSources] = useState(allSourceState()); const toggleArOrchSource = (key) => { if (!LIVE_SOURCES.includes(key)) return; setArOrchSources(s => toggleSourceState(s, key)); }; const selectAllArOrchSources = () => setArOrchSources(allSourceState()); const toggleArPickedDirection = (id) => setArPickedDirections(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const clearArPickedDirections = () => setArPickedDirections(new Set()); const selectAllArPickedDirections = () => setArPickedDirections(new Set(directionsRef.current.map(d => d.id))); const toggleArSource = (key) => { if (!LIVE_SOURCES.includes(key)) return; setArSources(s => toggleSourceState(s, key)); }; const selectAllArSources = () => setArSources(allSourceState()); const [tweaks, setTweaks] = useState({ theme: 'light', density: 'comfortable', cardStyle: 'standard', accent: 'teal' }); const [tweaksOpen, setTweaksOpen] = useState(false); const [toast, setToast] = useState(null); const [searching, setSearching] = useState(false); const [searchingRowIds, setSearchingRowIds] = useState(new Set()); // High-severity per-platform warnings from the last manual Search run // (e.g. "no AdSpy ads matched 'oregano oil' \u2014 showing top 5 instead"). // Rendered as a red banner above the search results panels. const [searchWarnings, setSearchWarnings] = useState([]); const [latestSearchRun, setLatestSearchRun] = useState(null); const [latestAutoresearchRun, setLatestAutoresearchRun] = useState(null); const [filterRunState, setFilterRunState] = useState(null); const [remodelRunState, setRemodelRunState] = useState(null); const [filterBusy, setFilterBusy] = useState(false); const [remodelBusy, setRemodelBusy] = useState(false); const [expandedAd, setExpandedAd] = useState(null); const [pendingAccessCount, setPendingAccessCount] = useState(0); const [managementCollapsed, setManagementCollapsed] = useState(false); const filterPollRef = useRef(null); useEffect(() => { let cancelled = false; const loadAuth = async () => { try { const me = await RESILIA_API.authMe(); if (cancelled) return; if (me && me.authenticated) { setAuthState({ status: 'authenticated', user: me.user }); } else { setAuthState({ status: 'signed_out', user: null }); } } catch (e) { if (!cancelled) setAuthState({ status: 'signed_out', user: null }); } }; const onUnauthorized = () => setAuthState({ status: 'signed_out', user: null }); window.addEventListener('resilia:unauthorized', onUnauthorized); loadAuth(); return () => { cancelled = true; window.removeEventListener('resilia:unauthorized', onUnauthorized); }; }, []); // edit-mode host protocol useEffect(() => { const onMsg = (e) => { if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true); if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); useEffect(() => { document.documentElement.setAttribute('data-theme', tweaks.theme); }, [tweaks.theme]); useEffect(() => { document.documentElement.setAttribute('data-accent', tweaks.accent); }, [tweaks.accent]); useEffect(() => { directionsRef.current = directions; }, [directions]); useEffect(() => { if (!directions.length || arDefaultDirectionsLoadedRef.current) return; const allDirectionIds = directions.map(direction => direction.id).filter(Boolean); if (!allDirectionIds.length) return; setArPickedDirections(new Set(allDirectionIds)); setArNumDirections(String(allDirectionIds.length)); arDefaultDirectionsLoadedRef.current = true; }, [directions]); const refreshAccessRequestCount = async () => { if (!authState.user?.is_admin) { setPendingAccessCount(0); return; } try { const accessCounts = await RESILIA_API.accessRequestCounts(); const nextCounts = accessCounts.counts || accessCounts || {}; setPendingAccessCount(nextCounts.pending || 0); } catch (e) { console.warn('access request count fetch failed', e); } }; const refreshDirectionCatalog = async () => { const [comps, dirs] = await Promise.all([ RESILIA_API.listCompetitors(), RESILIA_API.listDirections(), ]); const loadedDirections = dirs || []; directionsRef.current = loadedDirections; setCompetitors(comps || []); setDirections(loadedDirections); return loadedDirections; }; useEffect(() => { if (authState.status !== 'authenticated') return undefined; refreshAccessRequestCount(); }, [authState.status, authState.user?.is_admin]); useEffect(() => { try { window.localStorage.setItem(TAB_STORAGE_KEY, tab); } catch { // localStorage can be unavailable in hardened/private browser contexts. } }, [tab]); useEffect(() => { if (authState.status === 'authenticated' && !authState.user?.is_admin && ['accessRequests', 'adminDirections', 'adminWinningCriteria', 'adminCosts', 'adminWorkspaces', 'adminUsers'].includes(tab)) { setTab('research'); } }, [authState.status, authState.user?.is_admin, tab]); // Load competitors + directions + any cached session on mount useEffect(() => { if (authState.status !== 'authenticated') return undefined; (async () => { let loadedDirections = []; try { const [comps, dirs] = await Promise.all([ RESILIA_API.listCompetitors(), RESILIA_API.listDirections(), ]); loadedDirections = dirs || []; directionsRef.current = loadedDirections; setCompetitors(comps); setDirections(loadedDirections); } catch (e) { showToast('Failed to load directions: ' + e.message, 'Error'); } // Prime the Foreplay credit badge in the top bar. Other platforms stay // null until their backends land and wire a matching summary field. try { const s = await RESILIA_API.summary(); if (s && typeof s.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: s.credits_remaining })); } } catch (e) { console.warn('initial summary fetch failed', e); } // Restore last search from Postgres cache try { const cached = await RESILIA_API.getCache('last_search_results'); const cachedAds = cached && cached.payload && Array.isArray(cached.payload.ads) ? cached.payload.ads : []; if (cachedAds.length) { const ads = cachedAds.map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search' })); setResearchAds(prev => [...prev.filter(a => a.source === 'autoresearch'), ...ads]); } } catch (e) { console.warn('restore search cache failed', e); } let restoredAutoresearchState = null; try { restoredAutoresearchState = await RESILIA_API.getAutoresearchState(); const state = restoredAutoresearchState?.state || {}; if (restoredAutoresearchState?.running) { const runId = state.run_id || `ar-restored-${Date.now()}`; autoresearchBatchIdRef.current = runId; autoresearchLoadedIdsRef.current = new Set(); autoresearchCurrentIdsRef.current = new Set(); setArRunning(true); setAutoresearchActive(true); setArSelected(new Set(state.selected || [])); setLatestAutoresearchRun({ id: runId, count: 0, status: 'running', labels: state.sources || [], }); setArProgress({ crawlState: state.status || 'running', directionsSatisfied: state.run_directions_done || 0, directionsTotal: state.run_directions_total || 0, platformAdsFetched: state.platform_ads_fetched || {}, }); if (arPollRef.current) clearInterval(arPollRef.current); arPollRef.current = setInterval(pollWinners, 4000); } } catch (e) { console.warn('restore autoresearch running state failed', e); } // Restore last autoresearch winners try { const restored = await loadAutoresearchWinnersPage({ reset: true, directionSource: loadedDirections }); await loadAutoresearchHistory({ directionSource: loadedDirections }); if (restored.ads.length) { setAutoresearchActive(true); } if (restoredAutoresearchState?.running) pollWinners(); } catch (e) { console.warn('restore autoresearch cache failed', e); } try { await loadFilterPage({ reset: true }); const filterState = await refreshFilterRunState(); if (filterState?.running) startFilterStatePolling(); } catch (e) { console.warn('restore filter state failed', e); } try { await loadRemodelPage({ reset: true }); const remodelState = await refreshRemodelRunState(); const restoreAdIds = remodelState?.state?.ad_ids || remodelState?.run?.state?.ad_ids || []; if (remodelState?.running && restoreAdIds.length) { if (remodelPollRef.current) clearInterval(remodelPollRef.current); remodelPollRef.current = setInterval(() => { pollRemodelJobs(restoreAdIds, { source: 'manual' }); refreshRemodelRunState(); }, 3000); pollRemodelJobs(restoreAdIds, { source: 'manual' }); } } catch (e) { console.warn('restore remodel state failed', e); } setAutoRemodelRestoring(true); try { const auto = await RESILIA_API.getAutoRemodelState(); let restoredAutoRemodel = false; if (auto && auto.status === 'ok' && auto.state) { syncAutoRemodelState(auto.state); restoredAutoRemodel = await hydrateAutoRemodelPickedState(auto.state, loadedDirections); } if (!restoredAutoRemodel) { const jobs = await RESILIA_API.listRemodelJobs({}); const autoJobs = Array.isArray(jobs) ? jobs.filter(j => j.source === 'auto_remodel' && j.ad_id) : []; const restoredIds = []; const seenAutoAds = new Set(); const readyAutoJobs = autoJobs.filter(j => j.status === 'done' && j.response_text); const pendingAutoJobs = autoJobs.filter(j => !(j.status === 'done' && j.response_text)); [ ...readyAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)), ...pendingAutoJobs.slice().sort((a, b) => Number(b.id || 0) - Number(a.id || 0)), ] .forEach(j => { if (seenAutoAds.has(j.ad_id)) return; seenAutoAds.add(j.ad_id); restoredIds.push(j.ad_id); }); if (restoredIds.length) { const ids = restoredIds.slice(0, AUTO_REMODEL_COUNT_MAX); setArPickedIds(ids); arPickedIdsRef.current = ids; const ads = await loadAutoRemodelPage({ ids, directionSource: loadedDirections, reset: true }); setArStage('done'); setArTargetAds(ids.length); const idSet = new Set(ids); const restoredJobsForIds = autoJobs.filter(j => idSet.has(j.ad_id)); const restoredDone = restoredJobsForIds.filter(j => j.status === 'done' || j.status === 'failed').length; setArVariantsDone(restoredDone); setArVariantsTotalOverride(restoredJobsForIds.length || null); setArLastEvent({ stage: 'done', picked_ad_ids: ids, picked_count: ids.length, target_ads: ids.length }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); } } await restorePreviousAutoRemodelHistory({ excludeIds: arPickedIdsRef.current, directionSource: loadedDirections, }); } catch (e) { console.warn('restore auto-remodel state failed', e); } finally { setAutoRemodelRestoring(false); } })(); return () => { if (arPollRef.current) clearInterval(arPollRef.current); if (filterPollRef.current) clearInterval(filterPollRef.current); if (remodelPollRef.current) clearInterval(remodelPollRef.current); }; }, [authState.status]); const showToast = (msg, badge) => { setToast({ msg, badge }); setTimeout(() => setToast(null), 3200); }; const directionMetaMap = (items) => Object.fromEntries((items || []).map(d => { const keywords = Array.isArray(d.keywords) ? d.keywords.filter(Boolean) : []; const label = d.description || d.display_name || d.brand_name || d.id; const searchLabel = keywords.length ? keywords.slice(0, 4).join(', ') : (d.brand_name || d.display_name || label || d.id); return [d.id, { label, searchLabel }]; })); const attachDirectionMeta = (ad, metaMap) => { const meta = metaMap[ad.directionId]; ad.directionLabel = meta?.label || ad.directionId || null; ad.directionSearchLabel = meta?.searchLabel || ad.directionLabel || null; return ad; }; const adaptAutoresearchRows = (rows, directionSource = directionsRef.current) => { const dirMap = directionMetaMap(directionSource); const ads = []; const seen = new Set(); for (const row of rows || []) { if (!row?.ad_id || seen.has(row.ad_id)) continue; seen.add(row.ad_id); const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); ads.push({ ...adapted, source: 'autoresearch' }); } return ads; }; const mergeRecentAds = (incoming, existing = []) => { const out = []; const seen = new Set(); [...(incoming || []), ...(existing || [])].forEach(ad => { if (!ad?.id || seen.has(ad.id)) return; seen.add(ad.id); out.push(ad); }); return out; }; const appendOlderAds = (existing = [], incoming = []) => { const out = []; const seen = new Set(); [...(existing || []), ...(incoming || [])].forEach(ad => { if (!ad?.id || seen.has(ad.id)) return; seen.add(ad.id); out.push(ad); }); return out; }; const pipelineFallbackAd = (id, source = 'manual') => ({ id, brand: id || 'Recovered ad', platform: 'foreplay', copy: '', score: null, reasoning: null, daysRunning: 0, views: '—', thumb: RESILIA_ADAPT.gradFor(id || source), duration: '', scrapedAt: '—', cta: null, transcript: [], transcriptRaw: '', source, raw: { ad_id: id, source }, }); const adaptPipelineRows = (rows, source = 'manual') => (rows || []) .filter(row => row?.ad_id) .map(row => ({ ...RESILIA_ADAPT.adaptEvaluatedAd(row), source, filterVerdict: row.filter_verdict || null, pipelineAt: row.filter_verdict?.filtered_at || row.saved_at || null, })); const remodelEntryFromJobs = (jobs) => { const rows = Array.isArray(jobs) ? jobs : []; if (!rows.length) return { status: 'pending', variants: [] }; const done = rows.filter(j => j.status === 'done' && j.response_text); const failed = rows.filter(j => j.status === 'failed'); if (done.length > 0) { return { status: 'ready', variants: done .slice() .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0)) .map(j => ({ jobId: j.id, variantIdx: j.variant_idx || 0, raw: j.response_text, lines: parseRemodelScript(j.response_text), promotedAt: j.promoted_at || null, })), }; } if (failed.length === rows.length) { return { status: 'error', variants: [], error: failed[0]?.error || 'Generation failed', }; } return { status: 'pending', variants: [] }; }; const groupJobsByAd = (jobs) => { const grouped = new Map(); (jobs || []).forEach(job => { if (!job?.ad_id) return; if (!grouped.has(job.ad_id)) grouped.set(job.ad_id, []); grouped.get(job.ad_id).push(job); }); return grouped; }; const loadAutoresearchWinnersPage = async ({ reset = false, offset = null, directionSource = directionsRef.current, } = {}) => { const perDir = boundedPositiveIntOrNull(arTarget) || 1; const pageOffset = offset === null ? (reset ? 0 : autoresearchLoadedIdsRef.current.size) : Math.max(0, parseInt(offset, 10) || 0); setAutoresearchPage(prev => ({ ...prev, loading: true })); try { const page = await RESILIA_API.autoresearchWinners( AUTORESEARCH_PAGE_SIZE, perDir, { offset: pageOffset, paged: true }, ); const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []); const adsRaw = adaptAutoresearchRows(rows, directionSource); const currentBatchId = autoresearchBatchIdRef.current || 'latest-autoresearch'; const ads = adsRaw.map(ad => ({ ...ad, _arBatchId: currentBatchId, currentAutoresearchRun: true, currentRunLabel: 'Current', })); const loadedIds = reset ? new Set() : new Set(autoresearchLoadedIdsRef.current); const currentIds = reset ? new Set() : new Set(autoresearchCurrentIdsRef.current); ads.forEach(ad => loadedIds.add(ad.id)); ads.forEach(ad => currentIds.add(ad.id)); autoresearchLoadedIdsRef.current = loadedIds; autoresearchCurrentIdsRef.current = currentIds; setResearchAds(prev => { const searchAds = prev.filter(a => a.source !== 'autoresearch'); const priorAutoAds = prev .filter(a => ( a.source === 'autoresearch' && (!a.currentAutoresearchRun || (a._arBatchId && a._arBatchId !== currentBatchId)) )) .map(a => ({ ...a, currentAutoresearchRun: false, currentRunLabel: null })); const currentAutoAds = reset ? [] : prev.filter(a => ( a.source === 'autoresearch' && a.currentAutoresearchRun && (a._arBatchId || currentBatchId) === currentBatchId )); const byId = new Map(currentAutoAds.map(ad => [ad.id, ad])); ads.forEach(ad => byId.set(ad.id, ad)); // Layout: search ads on top (Bug 11 quick fix), then current batch // of autoresearch winners, then older autoresearch batches as // history beneath. return [...searchAds, ...Array.from(byId.values()), ...priorAutoAds]; }); const hasMore = Array.isArray(page) ? ads.length >= AUTORESEARCH_PAGE_SIZE : !!page?.has_more; const total = Array.isArray(page) ? null : (page?.total ?? null); const nextState = { loaded: loadedIds.size, total, hasMore, loading: false }; setAutoresearchPage(nextState); setLatestAutoresearchRun(prev => ({ id: currentBatchId, count: currentIds.size, total: total ?? prev?.total ?? null, status: prev?.status || 'restored', labels: prev?.labels || [], })); if (loadedIds.size > 0) setAutoresearchActive(true); return { ads, ...nextState }; } finally { setAutoresearchPage(prev => ({ ...prev, loading: false })); } }; const loadAutoresearchHistory = async ({ directionSource = directionsRef.current, limit = 500, } = {}) => { const page = await RESILIA_API.autoresearchHistory(limit); const rows = Array.isArray(page) ? page : (Array.isArray(page?.ads) ? page.ads : []); const historyAds = adaptAutoresearchRows(rows, directionSource).map(ad => ({ ...ad, _arBatchId: ad._arBatchId || 'history', currentAutoresearchRun: false, currentRunLabel: null, })); if (!historyAds.length) return []; setResearchAds(prev => { const existingIds = new Set(prev.map(ad => ad.id)); const newHistory = historyAds.filter(ad => !existingIds.has(ad.id)); if (!newHistory.length) return prev; const searchAds = prev.filter(ad => ad.source !== 'autoresearch'); const autoAds = prev.filter(ad => ad.source === 'autoresearch'); return [...searchAds, ...autoAds, ...newHistory]; }); setAutoresearchActive(true); return historyAds; }; const loadFilterPage = async ({ reset = false } = {}) => { const offset = reset ? 0 : filterPage.loaded; setFilterPage(prev => ({ ...prev, loading: true })); try { const rows = await RESILIA_API.listFilterVerdicts({ passesOnly: false, limit: PIPELINE_PAGE_SIZE, offset, }); const ads = adaptPipelineRows(rows, 'filter'); setFilteredAds(prev => reset ? mergeRecentAds(ads, []) : appendOlderAds(prev, ads)); const nextState = { loaded: offset + rows.length, hasMore: rows.length >= PIPELINE_PAGE_SIZE, loading: false, }; setFilterPage(nextState); return { ads, ...nextState }; } finally { setFilterPage(prev => ({ ...prev, loading: false })); } }; const loadRemodelPage = async ({ reset = false } = {}) => { const offset = reset ? 0 : remodelPage.loaded; setRemodelPage(prev => ({ ...prev, loading: true })); try { const jobs = await RESILIA_API.listRemodelJobs({ source: 'manual', limit: PIPELINE_PAGE_SIZE, offset, }); const grouped = groupJobsByAd(jobs); const adIds = [...grouped.keys()]; const swipeRows = adIds.length ? await RESILIA_API.getSwipesBatch(adIds) : []; const rowsById = new Map((swipeRows || []).map(row => [row.ad_id, row])); const ads = adIds.map(id => { const row = rowsById.get(id); return row ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' } : pipelineFallbackAd(id, 'remodel'); }); setRemodelScripts(prev => { const next = { ...prev }; grouped.forEach((rows, adId) => { next[adId] = remodelEntryFromJobs(rows); }); return next; }); setRemodeledAds(prev => reset ? mergeRecentAds(ads, []) : appendOlderAds(prev, ads)); const nextState = { loaded: offset + jobs.length, hasMore: jobs.length >= PIPELINE_PAGE_SIZE, loading: false, }; setRemodelPage(nextState); return { ads, ...nextState }; } finally { setRemodelPage(prev => ({ ...prev, loading: false })); } }; const refreshFilterRunState = async () => { const state = await RESILIA_API.getFilterState(); if (state) { setFilterRunState(state); setFilterBusy(!!state.running); if (!state.running && filterPollRef.current) { clearInterval(filterPollRef.current); filterPollRef.current = null; } } return state; }; const startFilterStatePolling = () => { if (filterPollRef.current) clearInterval(filterPollRef.current); filterPollRef.current = setInterval(async () => { try { await Promise.all([ loadFilterPage({ reset: true }), refreshFilterRunState(), ]); } catch (e) { console.warn('filter state poll failed', e); } }, 4000); }; const refreshRemodelRunState = async () => { const state = await RESILIA_API.getRemodelState(); if (state) { setRemodelRunState(state); setRemodelBusy(!!state.running); } return state; }; const autoRemodelFallbackAd = (id, job = {}) => ({ id, brand: job.ad_id || id, platform: job.platform || 'foreplay', copy: '', score: null, reasoning: null, daysRunning: 0, directionLabel: 'Auto-Remodel', directionSearchLabel: 'Auto-Remodel', transcript: [], transcriptRaw: null, source: 'auto_remodel', }); const syncAutoRemodelState = (state) => { if (!state) return; setArRunId(state.run_id || null); arRunIdRef.current = state.run_id || null; setArStage(state.stage || null); arStageRef.current = state.stage || null; setArLastEvent({ ...state, run_id: state.run_id, stage: state.stage }); if (typeof state.directions_done === 'number') setArDirectionsDone(state.directions_done); if (typeof state.directions_total === 'number') setArDirectionsTotal(state.directions_total); if (typeof state.ads_found === 'number') setArAdsFound(state.ads_found); if (typeof state.target_ads === 'number') { setArTargetAds(state.target_ads); arTargetAdsRef.current = state.target_ads; } if (typeof state.n_variants === 'number') { setArNVariants(state.n_variants); arNVariantsRef.current = state.n_variants; } if (Array.isArray(state.picked_ad_ids)) { setArPickedIds(state.picked_ad_ids); arPickedIdsRef.current = state.picked_ad_ids; } }; const searchFiltersPayload = (filters = {}) => { const out = {}; const minLikes = parseInt(filters.minLikes, 10); const minDailyLikes = parseInt(filters.minDailyLikes, 10); const adspyAgeMin = parseInt(filters.adspyAgeMin, 10); const adspyAgeMax = parseInt(filters.adspyAgeMax, 10); const adspyUserId = parseInt(filters.adspyUserId, 10); const adspyAffNetwork = parseInt(filters.adspyAffNetwork, 10); const fpMinDays = parseInt(filters.fpMinDays, 10); const bsMinViews = parseInt(filters.bsMinViews, 10); const bsMinLikes = parseInt(filters.bsMinLikes, 10); const bsMinEngagement = parseFloat(filters.bsMinEngagement); if (filters.fpFormat) out.foreplay_display_format = [filters.fpFormat]; if ((filters.fpPublisherPlatforms || '').trim()) out.foreplay_publisher_platform = filters.fpPublisherPlatforms.split(',').map(s => s.trim()).filter(Boolean); if ((filters.fpLanguages || '').trim()) out.foreplay_languages = filters.fpLanguages.split(',').map(s => s.trim()).filter(Boolean); if (filters.fpLiveStatus === 'live') out.foreplay_live = true; else if (filters.fpLiveStatus === 'inactive') out.foreplay_live = false; if (Number.isFinite(fpMinDays) && fpMinDays > 0) out.foreplay_running_duration_min_days = fpMinDays; if (filters.fpOrder) out.foreplay_order = filters.fpOrder; if (Number.isFinite(minLikes) && minLikes > 0) out.adspy_min_likes = minLikes; if (Number.isFinite(minDailyLikes) && minDailyLikes > 0) out.adspy_min_daily_likes = minDailyLikes; if (filters.orderBy) out.adspy_order = filters.orderBy; if (filters.adspySiteType) out.adspy_site_type = filters.adspySiteType; if (filters.adspyMediaType) out.adspy_media_type = filters.adspyMediaType; if (filters.adspyGender) out.adspy_gender = filters.adspyGender; if (Number.isFinite(adspyAgeMin) && adspyAgeMin > 0) out.adspy_age_min = adspyAgeMin; if (Number.isFinite(adspyAgeMax) && adspyAgeMax > 0) out.adspy_age_max = adspyAgeMax; if ((filters.adspyCountries || '').trim()) out.countries = filters.adspyCountries.split(',').map(s => s.trim()).filter(Boolean); if ((filters.adspyLang || '').trim()) out.languages = [filters.adspyLang.trim()]; if ((filters.adspyCreatedFrom || '').trim()) out.adspy_created_from = filters.adspyCreatedFrom.trim(); if ((filters.adspyCreatedTo || '').trim()) out.adspy_created_to = filters.adspyCreatedTo.trim(); if ((filters.adspySeenFrom || '').trim()) out.adspy_seen_from = filters.adspySeenFrom.trim(); if ((filters.adspySeenTo || '').trim()) out.adspy_seen_to = filters.adspySeenTo.trim(); if ((filters.adspyUsername || '').trim()) out.adspy_username = filters.adspyUsername.trim(); if (Number.isFinite(adspyUserId) && adspyUserId > 0) out.adspy_user_id = adspyUserId; if (Number.isFinite(adspyAffNetwork) && adspyAffNetwork > 0) out.adspy_aff_network = adspyAffNetwork; if ((filters.adspyAffId || '').trim()) out.adspy_aff_id = filters.adspyAffId.trim(); if ((filters.adspyOfferId || '').trim()) out.adspy_offer_id = filters.adspyOfferId.trim(); if ((filters.adspyButtons || '').trim()) out.adspy_buttons = filters.adspyButtons.trim(); if ((filters.adspyLandingUrl || '').trim()) out.adspy_url_search_type = 'lp_urls'; else if ((filters.adspyUrl || '').trim()) out.adspy_url_search_type = 'urls'; if (filters.bsPlatform) out.brandsearch_platforms = [filters.bsPlatform]; if (Number.isFinite(bsMinViews) && bsMinViews > 0) out.brandsearch_min_views = bsMinViews; if (Number.isFinite(bsMinLikes) && bsMinLikes > 0) out.brandsearch_min_likes = bsMinLikes; if (Number.isFinite(bsMinEngagement) && bsMinEngagement > 0) out.brandsearch_min_engagement_rate = bsMinEngagement; if (filters.bsSort) out.brandsearch_sort = filters.bsSort; return Object.keys(out).length ? out : null; }; const searchSourceUrlsPayload = (filters = {}) => { const out = {}; const foreplayUrl = (filters.foreplayUrl || '').trim(); const adspyUrl = (filters.adspyUrl || '').trim(); const adspyLandingUrl = (filters.adspyLandingUrl || '').trim(); const brandsearchUrl = (filters.brandsearchUrl || '').trim(); if (foreplayUrl) out.foreplay = foreplayUrl; if (adspyUrl || adspyLandingUrl) out.adspy = adspyUrl || adspyLandingUrl; if (brandsearchUrl) out.brandsearch = brandsearchUrl; return Object.keys(out).length ? out : null; }; const expandAd = (ad) => { setExpandedAd(ad); if (!ad) return; // Bug: the dashboard used to only hydrate transcripts for BrandSearch // ads. AdSpy and Foreplay video ads now also Whisper-transcribe on // demand server-side (search_service.get_ad_transcript), so trigger the // same hydration whenever the card has no real transcript yet. const hasTranscript = (ad.transcriptRaw && String(ad.transcriptRaw).trim()) || (Array.isArray(ad.transcript) && ad.transcript.length > 0); if (hasTranscript) return; RESILIA_API.fetchTranscript(ad.id).then(detail => { if (!detail || !detail.transcript) return; const patch = (item) => { if (item.id !== ad.id) return item; const adapted = RESILIA_ADAPT.adaptSearchAd({ ...(item.raw || {}), ad_id: item.id, brand: item.brand, platform: item.publisherPlatform, source_platform: item.platform, score: item.score, reasoning: item.reasoning, running_days: item.daysRunning, creative_url: item.creativeUrl, thumbnail_url: item.thumbnailUrl, transcript: detail.transcript, raw: { ...(item.raw || {}), ...(detail.raw || {}) }, metrics: item.raw?.metrics || detail.raw?.metrics || {}, }, item.platform); adapted.directionId = item.directionId; adapted.directionLabel = item.directionLabel; adapted.directionSearchLabel = item.directionSearchLabel; return { ...item, ...adapted, source: item.source }; }; setResearchAds(prev => prev.map(patch)); setFilteredAds(prev => prev.map(patch)); setRemodeledAds(prev => prev.map(patch)); setAutoRemodelAds(prev => prev.map(patch)); setExpandedAd(prev => prev && prev.id === ad.id ? patch(prev) : prev); }).catch(e => console.warn('brandsearch transcript hydration failed', e)); }; // ---- Manual search ------------------------------------------------------ const manualSearchLabelForRow = (row) => { if (row.competitorId) { if (row.competitorId === MANUAL_SEARCH_ALL_COMPETITORS_ID) return 'All competitors'; const competitor = competitors.find(c => String(c.id) === String(row.competitorId)); return competitor?.name || row.competitorId; } const keyword = String(row.query || '').trim(); if (keyword) return keyword; const urls = searchSourceUrlsPayload(row.filters); if (urls?.foreplay_url) return urls.foreplay_url; if (urls?.adspy_url) return urls.adspy_url; if (urls?.adspy_landing_url) return urls.adspy_landing_url; if (urls?.brandsearch_url) return urls.brandsearch_url; return null; }; const runSearchRows = async (targetRows, { global = false } = {}) => { const activeRows = (targetRows || []).filter(row => { const sourceUrls = searchSourceUrlsPayload(row.filters); return row.query || row.competitorId || sourceUrls; }); if (!activeRows.length) { showToast('No searchable row configured. Add a keyword, competitor, or source URL.', 'Search'); return []; } const activeRowIds = activeRows.map(row => row.id).filter(id => id !== null && id !== undefined); const searchBatchId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const searchRunLabels = [...new Set(activeRows.map(manualSearchLabelForRow).filter(Boolean))]; if (global) setSearching(true); setSearchingRowIds(prev => { const next = new Set(prev); activeRowIds.forEach(id => next.add(id)); return next; }); if (global) { setResearchAds(prev => prev.map(ad => ( ad.source === 'autoresearch' ? ad : { ...ad, currentSearchRun: false } ))); } setSearchWarnings([]); const collectedWarnings = []; const rowResults = new Map(); const updateSearchSummary = () => { const count = [...rowResults.values()].reduce((n, ads) => n + ads.length, 0); setLatestSearchRun({ id: searchBatchId, count, labels: searchRunLabels }); }; const runOneRow = async (row) => { const rowKey = row.id ?? `${searchBatchId}_${rowResults.size}`; const rowRunId = `${searchBatchId}_${rowKey}`; const sourceUrls = searchSourceUrlsPayload(row.filters); const searchReturnLabel = manualSearchLabelForRow(row); const platforms = Object.keys(row.platforms).filter(k => row.platforms[k]); const payload = { query: row.query, competitorId: row.competitorId, count: Math.min(row.count, 100), platforms, filters: searchFiltersPayload(row.filters), sourceUrls, }; try { let resp = await RESILIA_API.search(payload); if (resp && resp.status === 'started' && resp.run_id && RESILIA_API.waitForSearchRun) { resp = await RESILIA_API.waitForSearchRun(resp.run_id); } const ads = (resp.ads || []).map(a => ({ ...RESILIA_ADAPT.adaptSearchAd(a, 'foreplay'), source: 'search', searchReturnLabel, searchRunId: rowRunId, searchBatchId, searchRowId: rowKey, currentSearchRun: true, })); const seen = new Set(); const deduped = []; for (const ad of ads) { if (seen.has(ad.id)) continue; seen.add(ad.id); deduped.push(ad); } rowResults.set(rowKey, deduped); setResearchAds(prev => { const autoresearchAds = prev.filter(a => a.source === 'autoresearch'); const priorSearchAds = prev.filter(a => a.source !== 'autoresearch'); const newIds = new Set(deduped.map(a => a.id)); const remainingPrior = priorSearchAds .filter(a => !newIds.has(a.id)) .map(a => ( a.searchRowId === rowKey ? { ...a, currentSearchRun: false } : a )); return [...deduped, ...remainingPrior, ...autoresearchAds]; }); updateSearchSummary(); if (resp && typeof resp.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: resp.credits_remaining })); } if (resp && Array.isArray(resp.warnings) && resp.warnings.length) { for (const w of resp.warnings) { if (w && typeof w === 'object') { collectedWarnings.push({ ...w, query: row.query || null }); } } const labels = resp.warnings.map(w => w.source).filter(Boolean).join(', '); showToast(`Search completed with ${labels || resp.warnings.length} warning(s).`, 'Warn'); } return deduped; } catch (e) { rowResults.set(rowKey, []); updateSearchSummary(); showToast(`Search failed for ${row.query || row.competitorId}: ${e.message}`, 'Error'); return []; } finally { if (row.id !== null && row.id !== undefined) { setSearchingRowIds(prev => { const next = new Set(prev); next.delete(row.id); return next; }); } } }; try { const results = (await Promise.all(activeRows.map(runOneRow))).flat(); // dedupe by ad id across rows const seen = new Set(); const deduped = []; for (const a of results) { if (seen.has(a.id)) continue; seen.add(a.id); deduped.push(a); } setLatestSearchRun({ id: searchBatchId, count: deduped.length, labels: searchRunLabels }); if (deduped.length === 0) { showToast('No ads returned. Check your query or competitor.', 'Search'); } else { showToast(`${deduped.length} ads fetched (scored).`, 'Search'); } setSearchWarnings(collectedWarnings); return deduped; } finally { if (global) setSearching(false); setSearchingRowIds(prev => { const next = new Set(prev); activeRowIds.forEach(id => next.delete(id)); return next; }); } }; const runSearch = async () => { await runSearchRows(rows, { global: true }); }; const runSearchRow = async (rowId) => { const row = rows.find(item => item.id === rowId); if (!row) return; await runSearchRows([row]); }; // ---- Autoresearch ------------------------------------------------------- const pollWinners = async () => { try { // Checkpoint-aware: only returns winners evaluated since the last // /api/autoresearch/checkpoint call, so previous runs never leak in. const page = await loadAutoresearchWinnersPage({ reset: autoresearchLoadedIdsRef.current.size === 0, offset: 0, directionSource: directionsRef.current, }); const loadedWinners = page.loaded ?? autoresearchLoadedIdsRef.current.size; const summary = await RESILIA_API.summary(); if (summary) { const runTotal = summary.run_directions_total || 0; const runTotalScored = runTotal > 0 ? (summary.run_total_scored ?? 0) : summary.total_scored; setArProgress({ totalScored: runTotalScored, winners: loadedWinners, totalWinners: runTotal > 0 ? (summary.run_winners ?? loadedWinners) : summary.winners, // Prefer the per-run counter while a crawl is active so the bar // tracks "directions finished this run", not lifetime DB state. directionsSatisfied: runTotal > 0 ? summary.run_directions_done : summary.directions_satisfied, directionsTotal: runTotal > 0 ? runTotal : summary.directions_total, crawlState: summary.crawl_state, creditsRemaining: summary.credits_remaining, platformAdsFetched: summary.platform_ads_fetched || {}, }); if (typeof summary.credits_remaining === 'number') { setPlatformCredits(pc => ({ ...pc, foreplay: summary.credits_remaining })); } } if (summary && summary.crawl_state !== 'idle') { setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'running', count: autoresearchCurrentIdsRef.current.size, }) : prev); } if (summary && summary.crawl_state === 'idle') { setArRunning(false); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'done', count: autoresearchCurrentIdsRef.current.size }) : prev); if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; } showToast(`Autoresearch complete. ${loadedWinners} winners loaded.`, 'Autoresearch'); } } catch (e) { console.warn('poll winners failed', e); } }; const runAutoresearch = async () => { const directionPool = directions; if (directionPool.length === 0) { showToast('No directions configured for the selected sources.', 'Autoresearch'); return; } // Direction selection: explicit checkbox picks win; otherwise run the // full direction pool. Autoresearch should never silently sample only a // few directions when the user expects the whole catalog. let ids; if (arPickedDirections.size > 0) { ids = directions.filter(d => arPickedDirections.has(d.id)).map(d => d.id); } else { ids = directionPool.map(d => d.id); setArPickedDirections(new Set(ids)); setArNumDirections(String(ids.length)); } if (ids.length === 0) { showToast('No directions resolved — check the picker or the cap.', 'Autoresearch'); return; } const sources = Object.keys(arSources).filter(k => arSources[k]); if (sources.length === 0) { showToast('Pick at least one source (Foreplay, AdSpy, …).', 'Autoresearch'); return; } setArSelected(new Set(ids)); setAutoresearchActive(true); setArProgress({ crawlState: 'starting', winners: 0, totalScored: 0, directionsSatisfied: 0, directionsTotal: ids.length * sources.length }); // Bug retention — keep prior autoresearch winners on screen as a history // strip below the new batch. Stamp untagged old ads with a 'previous' // batch id, then bump the current batch id so new ads land on top. setResearchAds(prev => prev.map(ad => ( ad.source === 'autoresearch' && !ad._arBatchId ? { ...ad, _arBatchId: 'previous', currentAutoresearchRun: false, currentRunLabel: null } : ad.source === 'autoresearch' ? { ...ad, currentAutoresearchRun: false, currentRunLabel: null } : ad ))); const newBatchId = `ar-${Date.now()}`; autoresearchBatchIdRef.current = newBatchId; autoresearchLoadedIdsRef.current = new Set(); autoresearchCurrentIdsRef.current = new Set(); setAutoresearchPage({ loaded: 0, total: null, hasMore: false, loading: false }); setLatestAutoresearchRun({ id: newBatchId, count: 0, total: null, status: 'starting', labels: sources, }); try { const perDirectionTarget = boundedPositiveIntOrNull(arTarget); if (arTarget && String(perDirectionTarget) !== arTarget) { setArTarget(perDirectionTarget === null ? '' : String(perDirectionTarget)); } const maxAdsPerDirection = boundedPositiveIntOrNull( arMaxAdsPerDirection, SEARCH_ADS_PER_DIRECTION_MAX, ); const maxAdsPerPlatform = boundedPositiveIntOrNull( arMaxAdsPerPlatform, SEARCH_ADS_PER_PLATFORM_MAX, ); // Mark a fresh checkpoint on the backend — winners from prior runs // won't show up in /api/autoresearch/winners anymore. await RESILIA_API.setAutoresearchCheckpoint({ directionIds: ids, perDirectionTarget, platforms: sources, maxAdsPerDirection, maxAdsPerPlatform, }); await RESILIA_API.startAutoresearch({ directionIds: ids, perDirectionTarget, platforms: sources, maxAdsPerDirection, maxAdsPerPlatform, force: true, }); setArRunning(true); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'running' }) : prev); showToast(`Autoresearch started for ${ids.length} direction(s) across ${sources.length} source(s).`, 'Autoresearch'); if (arPollRef.current) clearInterval(arPollRef.current); arPollRef.current = setInterval(pollWinners, 4000); pollWinners(); } catch (e) { showToast('Autoresearch failed to start: ' + e.message, 'Error'); } }; const loadMoreAutoresearchWinners = async () => { try { await loadAutoresearchWinnersPage(); } catch (e) { showToast('Failed to load more Autoresearch winners: ' + e.message, 'Error'); } }; const stopAutoresearch = async () => { try { await RESILIA_API.stopAutoresearch(); setArRunning(false); setLatestAutoresearchRun(prev => prev ? ({ ...prev, status: 'stopping' }) : prev); if (arPollRef.current) { clearInterval(arPollRef.current); arPollRef.current = null; } showToast('Autoresearch stopping…', 'Autoresearch'); } catch (e) { showToast('Failed to stop: ' + e.message, 'Error'); } }; // ---- Auto-Remodel orchestrator ---------------------------------------- // Once the orchestrator picks ads, hydrate the swipe rows for them and // push them through the existing Research → Filter → Remodel views as the // pipeline transitions stages. This reuses the same panels the manual // flow uses (no duplicate UI to maintain). const hydratePickedAds = async (adIds, directionSource = directionsRef.current) => { if (!adIds || adIds.length === 0) return []; const dirMap = directionMetaMap(directionSource); const uniqueIds = [...new Set(adIds.filter(Boolean))]; const getSwipeWithRetry = async (id) => { for (let attempt = 0; attempt < 6; attempt += 1) { const swipe = await RESILIA_API.getSwipe(id); if (swipe) return swipe; await new Promise(resolve => setTimeout(resolve, 500)); } return null; }; const getSwipesWithRetry = async (ids) => { const rowsById = new Map(); try { for (let attempt = 0; attempt < 6 && rowsById.size < ids.length; attempt += 1) { const missingIds = ids.filter(id => !rowsById.has(id)); const rows = await RESILIA_API.getSwipesBatch(missingIds); rows.forEach(row => { if (row?.ad_id) rowsById.set(row.ad_id, row); }); if (rowsById.size >= ids.length) break; await new Promise(resolve => setTimeout(resolve, 500)); } return ids.map(id => rowsById.get(id)).filter(Boolean); } catch (e) { console.warn('swipe batch hydration failed; falling back to single-row fetches', e); return Promise.all(ids.map(getSwipeWithRetry)); } }; const swipeRows = await getSwipesWithRetry(uniqueIds); const rows = await Promise.all(swipeRows.map(async (swipe) => { const id = swipe?.ad_id; try { if (!swipe) return null; let row = swipe; if (!row.transcript) { const detail = await RESILIA_API.fetchTranscript(id); if (detail && detail.transcript) { row = { ...row, transcript: detail.transcript, raw: { ...(row.raw || {}), ...(detail.raw || {}) } }; } } // ``adaptEvaluatedAd`` expects evaluated_ads-shaped rows; swipe rows // have the same column names for everything we need (ad_id, brand, // platform, transcript, score, reasoning, running_days, raw, …). const adapted = RESILIA_ADAPT.adaptEvaluatedAd(row); attachDirectionMeta(adapted, dirMap); return { ...adapted, source: 'auto_remodel' }; } catch (e) { console.warn('hydratePickedAds failed for', id, e); return null; } })); return rows.filter(Boolean); }; const restorePreviousAutoRemodelHistory = async ({ excludeIds = arPickedIdsRef.current, directionSource = directionsRef.current, limit = 200, } = {}) => { const excluded = new Set((excludeIds || []).filter(Boolean)); const jobs = await RESILIA_API.listRemodelJobs({ source: 'auto_remodel', limit, }); const historyIds = []; const seen = new Set(); (Array.isArray(jobs) ? jobs : []).forEach(job => { const id = job?.ad_id; if (!id || excluded.has(id) || seen.has(id)) return; seen.add(id); historyIds.push(id); }); if (!historyIds.length) return []; const historyAds = (await hydratePickedAds(historyIds, directionSource)).map(ad => ({ ...ad, _arRunId: ad._arRunId || 'previous', currentAutoRemodelRun: false, })); if (!historyAds.length) return []; setAutoRemodelAds(prev => { const existingIds = new Set(prev.map(ad => ad.id)); const additions = historyAds.filter(ad => !existingIds.has(ad.id)); return additions.length ? [...prev, ...additions] : prev; }); setRemodelScripts(prev => { const next = { ...prev }; historyAds.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); pollRemodelJobs(historyAds.map(ad => ad.id), autoRemodelJobOptions({ markMissingAsError: true })); return historyAds; }; const loadAutoRemodelPage = async ({ ids = arPickedIdsRef.current, directionSource = directionsRef.current, reset = false, pageSize = AUTO_REMODEL_PAGE_SIZE, } = {}) => { const allIds = [...new Set((ids || []).filter(Boolean))]; if (!allIds.length) return []; if (autoRemodelHydratingPageRef.current) return []; autoRemodelHydratingPageRef.current = true; const loadedIds = reset ? new Set() : new Set(autoRemodelLoadedIdsRef.current); if (reset) { autoRemodelLoadedIdsRef.current = loadedIds; } const pageIds = allIds.filter(id => !loadedIds.has(id)).slice(0, pageSize); if (!pageIds.length) { setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false }); autoRemodelHydratingPageRef.current = false; return []; } setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: true }); try { const hydratedAds = await hydratePickedAds(pageIds, directionSource); const currentRunId = arRunIdRef.current; const hydratedById = new Map(hydratedAds.map(ad => [ad.id, ad])); const pageAds = pageIds.map(id => { const base = hydratedById.get(id) || autoRemodelFallbackAd(id); const runId = currentRunId || base._arRunId || 'current'; return { ...base, _arRunId: runId, currentAutoRemodelRun: true }; }); pageIds.forEach(id => loadedIds.add(id)); autoRemodelLoadedIdsRef.current = loadedIds; setAutoRemodelAds(prev => { // Current-run ads stay current even when they streamed via ad_scored // before the picked-id list caught up. const nextById = new Map(prev.map(ad => [ad.id, ad])); pageAds.forEach(ad => nextById.set(ad.id, ad)); const allIdSet = new Set(allIds); const currentIds = new Set(allIds); [...nextById.values()].forEach(ad => { if (currentRunId && ad._arRunId === currentRunId) currentIds.add(ad.id); }); const currentOrdered = allIds .filter(id => nextById.has(id)) .map(id => ({ ...nextById.get(id), _arRunId: currentRunId || nextById.get(id)._arRunId || 'current', currentAutoRemodelRun: true })); const streamedCurrent = [...nextById.values()] .filter(ad => currentIds.has(ad.id) && !allIdSet.has(ad.id)) .map(ad => ({ ...ad, _arRunId: currentRunId || ad._arRunId || 'current', currentAutoRemodelRun: true })); const remainder = [...nextById.values()] .filter(ad => !currentIds.has(ad.id)) .map(ad => ({ ...ad, currentAutoRemodelRun: false })); return [...currentOrdered, ...streamedCurrent, ...remainder]; }); setRemodelScripts(prev => { const next = { ...prev }; pageAds.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); setAutoRemodelHydration({ total: allIds.length, loaded: loadedIds.size, loading: false }); const pageAdIds = pageAds.map(ad => ad.id); if (pageAdIds.length) pollRemodelJobs(pageAdIds, autoRemodelJobOptions()); return pageAds; } finally { setAutoRemodelHydration(prev => ({ ...prev, loading: false })); autoRemodelHydratingPageRef.current = false; } }; const hydrateAutoRemodelPickedState = async ( state, directionSource = directionsRef.current, { announce = false, focus = false } = {}, ) => { if (!state || !Array.isArray(state.picked_ad_ids) || state.picked_ad_ids.length === 0) return false; const pickedIds = state.picked_ad_ids.slice(0, AUTO_REMODEL_COUNT_MAX); if (!pickedIds.length) return false; arPickedIdsRef.current = pickedIds; setArPickedIds(pickedIds); if (typeof state.target_ads === 'number') setArTargetAds(state.target_ads); if (typeof state.ads_found === 'number') setArAdsFound(Math.min(state.ads_found, pickedIds.length)); const ads = await loadAutoRemodelPage({ ids: pickedIds, directionSource, reset: true }); if (!ads.length) return false; setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); if (focus) setTab('autoRemodel'); if (announce) { showToast( `Auto-Remodel: showing ${ads.length} of ${pickedIds.length} saved ad${pickedIds.length === 1 ? '' : 's'} while variants generate.`, 'Auto-Remodel', ); } return true; }; useEffect(() => { const ids = arPickedIds || []; if (!ids.length || !['picked', 'filtering', 'filter_done', 'remodeling', 'done'].includes(arStage)) return; if (autoRemodelLoadedIdsRef.current.size > 0) return; let cancelled = false; (async () => { const ads = await loadAutoRemodelPage({ ids, reset: true }); if (cancelled || ads.length === 0) return; setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); })(); return () => { cancelled = true; }; // arPickedIds is an array from SSE/state restore; use its contents as the trigger. // eslint-disable-next-line react-hooks/exhaustive-deps }, [arStage, arPickedIds.join('|')]); // SSE subscription. The same channel powers the existing autoresearch // panel's live progress; we layer Auto-Remodel on top with no new // transport. Returned cleanup closes the EventSource on unmount / HMR. useEffect(() => { if (authState.status !== 'authenticated') return undefined; const close = RESILIA_API.subscribe((ev) => { if (!ev || !ev.type) return; if (ev.type === 'auto_remodel_event') { // Late subscribers latch onto whichever run is broadcasting — keep // the first run_id we see for this session unless dismissed. setArRunId(prev => prev || ev.run_id); if (!arRunIdRef.current && ev.run_id) arRunIdRef.current = ev.run_id; if (arRunIdRef.current && ev.run_id && ev.run_id !== arRunIdRef.current) return; setArStage(ev.stage || null); arStageRef.current = ev.stage || null; setArLastEvent(ev); // Crawl-side progress counters from the orchestrator. if (typeof ev.directions_done === 'number') setArDirectionsDone(ev.directions_done); if (typeof ev.directions_total === 'number') setArDirectionsTotal(ev.directions_total); if (typeof ev.ads_found === 'number') { const target = arTargetAdsRef.current || ev.target_ads || 0; setArAdsFound(target > 0 ? Math.min(ev.ads_found, target) : ev.ads_found); } if (typeof ev.target_ads === 'number') { setArTargetAds(ev.target_ads); arTargetAdsRef.current = ev.target_ads; } if (Array.isArray(ev.picked_ad_ids) && ev.picked_ad_ids.length) { const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length; const cappedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, target))); arPickedIdsRef.current = cappedIds; setArPickedIds(cappedIds); } // Pipeline now skips the Filter step entirely (Sonnet pre-approves // every pick at score ≥ 7.5). Stages we react to: // * picked (with non-empty picks) // → hydrate ads, switch to Remodel tab, push to // remodeledAds with pending variant slots, start // polling. // * picked (empty picks) // → orchestrator's fresh-run filter found nothing // new. Stay on the current tab; toast explains. // * remodeling → no-op (just a status update). // * done/failed → final poll + toast. (async () => { if (ev.stage === 'picked' && Array.isArray(ev.picked_ad_ids)) { if (ev.picked_ad_ids.length === 0) { // Critical: never switch tabs on empty picks. The user clicked // Run expecting ads — switching to a blank Remodel view feels // like the app broke. Stay put; the orchestrator will fire // ``done`` next anyway. showToast( 'Auto-Remodel: no fresh ads found this run. Try different directions or wait for new winners to surface.', 'Auto-Remodel', ); return; } const target = ev.target_ads || arTargetAdsRef.current || ev.picked_ad_ids.length; const pickedIds = ev.picked_ad_ids.slice(0, Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, target))); // Bug — the orchestrator emits ``picked`` once per batch as // ``_queue_fresh_remodels`` runs (not just at the very end), and // ``ev.picked_ad_ids`` is the running cumulative list. Calling // ``loadAutoRemodelPage({ reset: true })`` on every batch wiped // every ad of the current run from screen, flashed the // "Restoring Auto-Remodel results — Loading saved ads and // scripts" empty state (which is keyed on // ``ads.length === 0 && (restoring || loadingMore)``), then // re-hydrated from scratch — and any remodelled script that had // already streamed in disappeared until the next ``remodel_*`` // event re-populated it. Hydrate only the genuinely new ids and // leave the existing rendered ads / scripts in place. const alreadyLoaded = autoRemodelLoadedIdsRef.current; const newIds = (Array.isArray(ev.new_picked_ad_ids) && ev.new_picked_ad_ids.length ? ev.new_picked_ad_ids : pickedIds ).filter(id => id && !alreadyLoaded.has(id)); if (newIds.length === 0) { // Nothing new to render in this batch — still refresh the // remodel-job poll so the variant counter keeps ticking. if (remodelPollRef.current) clearInterval(remodelPollRef.current); const allLoadedIds = [...autoRemodelLoadedIdsRef.current]; if (allLoadedIds.length) { remodelPollRef.current = setInterval( () => pollRemodelJobs(allLoadedIds, autoRemodelJobOptions()), 3000, ); } return; } // Incremental hydration: keeps existing ads + scripts on screen // and just appends the new picks to the bottom. const ads = await loadAutoRemodelPage({ ids: pickedIds, reset: false }); setRemodelScripts(prev => { const next = { ...prev }; ads.forEach(a => { if (!next[a.id]) next[a.id] = { status: 'pending', variants: [] }; }); return next; }); if (remodelPollRef.current) clearInterval(remodelPollRef.current); const allLoadedIds = [...autoRemodelLoadedIdsRef.current]; if (allLoadedIds.length) { remodelPollRef.current = setInterval( () => pollRemodelJobs(allLoadedIds, autoRemodelJobOptions()), 3000, ); } showToast( `Auto-Remodel: +${newIds.length} new ad${newIds.length === 1 ? '' : 's'} (${pickedIds.length}/${target}).`, 'Auto-Remodel', ); } else if (ev.stage === 'done') { // Suppress the redundant "done" toast when we already toasted // about an empty pick — same run, same news. if ((ev.picked ?? 0) > 0) { showToast( `Auto-Remodel done — ${ev.completed ?? 0}/${ev.jobs_created ?? 0} jobs ok`, ev.failed ? 'Warn' : 'Auto-Remodel', ); } const loadedIds = [...autoRemodelLoadedIdsRef.current]; if (loadedIds.length) pollRemodelJobs(loadedIds, autoRemodelJobOptions({ markMissingAsError: true })); } else if (ev.stage === 'failed') { showToast(`Auto-Remodel failed: ${ev.error || 'unknown'}`, 'Error'); } else if (ev.stage === 'cancelled') { showToast('Auto-Remodel stopped.', 'Auto-Remodel'); } })(); } else if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed' || ev.type === 'remodel_edited') { // Variant-level updates: refresh just the affected ad's scripts via // the existing poll helper (handles the parsing + status logic). const isAutoRemodelAd = ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id); if (isAutoRemodelAd) { pollRemodelJobs([ev.ad_id], autoRemodelJobOptions()); // Each remodel_completed/_failed represents one variant finishing // (in any state) — tick the panel's variant counter. if (ev.type === 'remodel_completed' || ev.type === 'remodel_failed') { setArVariantsDone(n => n + 1); } } else if (ev.ad_id) { pollRemodelJobs([ev.ad_id], { source: 'manual' }); RESILIA_API.getSwipe(ev.ad_id) .then(row => { const ad = row ? { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'remodel' } : pipelineFallbackAd(ev.ad_id, 'remodel'); setRemodeledAds(prev => mergeRecentAds([ad], prev)); }) .catch(e => console.warn('manual remodel swipe refresh failed', e)); } } else if (ev.type === 'filter_ad_verdict' && ev.ad_id) { RESILIA_API.getSwipe(ev.ad_id) .then(row => { if (!row) return; const ad = { ...RESILIA_ADAPT.adaptEvaluatedAd(row), source: 'filter', filterVerdict: row.filter_verdict || null, pipelineAt: row.filter_verdict?.filtered_at || row.saved_at || null, }; setFilteredAds(prev => mergeRecentAds([ad], prev)); }) .catch(e => console.warn('filter verdict swipe refresh failed', e)); } else if (ev.type === 'filter_completed') { loadFilterPage({ reset: true }).catch(e => console.warn('filter restore after completion failed', e)); } else if (ev.type === 'remodel_promoted') { if (ev.ad_id && arPickedIdsRef.current.includes(ev.ad_id)) { pollRemodelJobs([ev.ad_id], autoRemodelJobOptions()); } showToast(`Promoted to Video Queue (job ${ev.job_id})`, 'Promote'); } else if (ev.type === 'filter_verdicts_deleted') { const deleted = new Set(ev.ad_ids || []); setFilteredAds(prev => prev.filter(a => !deleted.has(a.id))); } else if (ev.type === 'remodel_jobs_deleted') { const deleted = new Set(ev.ad_ids || []); setRemodeledAds(prev => prev.filter(a => !deleted.has(a.id))); setRemodelScripts(prev => { const next = { ...prev }; deleted.forEach(id => { delete next[id]; }); return next; }); } else if (ev.type === 'evaluated_deleted') { showToast(`Bulk delete — ${ev.count} ad(s) removed`, 'Delete'); } else if (ev.type === 'ad_scored' && ev.passes === true) { // The crawler fires ``ad_scored`` for every ad it evaluates — when // ``passes`` is true the ad just qualified. Stream qualifying ads // into Auto-Remodel results immediately so the user sees winners // landing while the all-source crawl is still running. const stageNow = arStageRef.current; const active = !!arRunIdRef.current && stageNow !== 'done' && stageNow !== 'failed'; if (!active) return; const target = arTargetAdsRef.current || 5; if (arAcceptedAdIdsRef.current.has(ev.ad_id)) return; if (arAcceptedAdIdsRef.current.size >= target) return; arAcceptedAdIdsRef.current.add(ev.ad_id); setArAdsFound(Math.min(arAcceptedAdIdsRef.current.size, target)); const dirMap = directionMetaMap(directionsRef.current); const synth = { ad_id: ev.ad_id, direction_id: ev.direction_id, score: ev.score, passes: true, reasoning: ev.reasoning, transcript: ev.transcript_preview, brand: ev.brand, platform: ev.platform, source_platform: ev.source_platform, running_days: ev.running_days, creative_url: ev.creative_url, thumbnail_url: ev.thumbnail_url, link_url: ev.link_url, metrics: ev.metrics || {}, raw: { source_platform: ev.source_platform, metrics: ev.metrics || {} }, }; const adapted = RESILIA_ADAPT.adaptEvaluatedAd(synth); attachDirectionMeta(adapted, dirMap); const ad = { ...adapted, source: 'auto_remodel', _arRunId: arRunIdRef.current, currentAutoRemodelRun: true }; RESILIA_API.fetchTranscript(ev.ad_id).then(detail => { if (!detail || !detail.transcript) return; const patchTranscript = (item) => { if (item.id !== ev.ad_id) return item; const patched = RESILIA_ADAPT.adaptEvaluatedAd({ ...synth, transcript: detail.transcript, raw: { ...(synth.raw || {}), ...(detail.raw || {}) }, }); patched.directionLabel = item.directionLabel; patched.directionSearchLabel = item.directionSearchLabel; return { ...item, ...patched, source: item.source, _arRunId: item._arRunId }; }; setAutoRemodelAds(prev => prev.map(patchTranscript)); }).catch(e => console.warn('auto-remodel transcript hydration failed', e)); setAutoRemodelAds(prev => { if (prev.some(a => a.id === ad.id)) return prev; // Cap by current-run pick count, not by every auto_remodel ad on // screen — ads from prior runs are kept below the new ones for // history (Bug 8) and must not block new picks. const currentRunId = arRunIdRef.current; const currentRunAds = prev.filter( a => a.source === 'auto_remodel' && a._arRunId === currentRunId, ); if (currentRunAds.length >= target) return prev; // Append to the end of the current-run section so within-run order // matches scoring order, while previous-run ads stay below. const otherAds = prev.filter( a => !(a.source === 'auto_remodel' && a._arRunId === currentRunId), ); return [...currentRunAds, ad, ...otherAds]; }); setRemodelScripts(prev => { if (prev[ad.id]) return prev; return { ...prev, [ad.id]: { status: 'scored', variants: [] } }; }); } }); return close; // We intentionally don't include arRunId/arPickedIds in deps — re-creating // the SSE connection on every state change would thrash the server. The // closure reads the latest values via the setState callbacks. // eslint-disable-next-line react-hooks/exhaustive-deps }, [authState.status]); const startAutoRemodel = async ({ mode, directionId, directionIds, count, nVariants, platforms, maxAdsPerDirection, maxAdsPerPlatform, }) => { const activeStage = arStageRef.current; const alreadyActive = arRunIdRef.current && !['done', 'failed', 'cancelled'].includes(activeStage); if (arStartPendingRef.current || alreadyActive) { showToast('Auto-Remodel is already starting or running.', 'Auto-Remodel'); return; } arStartPendingRef.current = true; setArStartPending(true); try { const out = await RESILIA_API.runAutoRemodel({ mode, directionId, directionIds, count, nVariants, platforms, maxAdsPerDirection, maxAdsPerPlatform, }); if (out.status === 'already_running') { if (out.state) syncAutoRemodelState(out.state); else if (out.run_id) { setArRunId(out.run_id); arRunIdRef.current = out.run_id; } showToast('Auto-Remodel is already running.', 'Auto-Remodel'); return; } // Fresh run — clear stale state and keep the user on Auto-Remodel so // live scored ads and picked ads appear in the same panel. Stop the // manual-flow remodel poller too, otherwise it could overwrite the // orchestrator's state. if (remodelPollRef.current) { clearInterval(remodelPollRef.current); remodelPollRef.current = null; } setArRunId(out.run_id); arRunIdRef.current = out.run_id; setArStage('queued'); arStageRef.current = 'queued'; setArLastEvent(null); setArPickedIds([]); arPickedIdsRef.current = []; // Keep previously-rendered Auto-Remodel ads on screen as a run history // strip beneath the new run's results (Bug 8). Tag them with whatever // ``_arRunId`` they already had — or 'previous' if they predate the // tag — so the new run's hydration doesn't touch them. setAutoRemodelAds(prev => prev.map(ad => ( ad._arRunId ? { ...ad, currentAutoRemodelRun: false } : { ...ad, _arRunId: 'previous', currentAutoRemodelRun: false } ))); // Loaded ids are scoped to the active run. Previous ads stay rendered, // but must not block a newly picked copy from hydrating as current. autoRemodelLoadedIdsRef.current = new Set(); setAutoRemodelHydration({ total: 0, loaded: 0, loading: false }); arAcceptedAdIdsRef.current = new Set(); setArDirectionsDone(0); setArDirectionsTotal(0); setArAdsFound(0); const requestedTarget = Math.max(1, Math.min(AUTO_REMODEL_COUNT_MAX, parseInt(count, 10) || 5)); setArTargetAds(requestedTarget); arTargetAdsRef.current = requestedTarget; setArVariantsDone(0); const requestedVariants = Math.max(1, parseInt(nVariants, 10) || 1); setArNVariants(requestedVariants); arNVariantsRef.current = requestedVariants; setArVariantsTotalOverride(null); // Bug 8 — do NOT clear autoRemodelAds or remodelScripts here. Previous- // run picks and their generated scripts stay visible underneath the new // run's results so the user keeps their history when they re-run. setTab('autoRemodel'); // Note — we deliberately do NOT clear ``researchAds`` here. Auto-Remodel // results live in ``autoRemodelAds`` (rendered on the Auto-Remodel tab); // the Research tab's autoresearch winners are independent state that the // user expects to find intact when they switch back. A previous version // wiped ``researchAds.filter(a => a.source === 'autoresearch')`` here, // which made the Research tab look empty after every Auto-Remodel run. showToast(`Auto-Remodel started (${out.run_id}) — staying here until picks land.`, 'Auto-Remodel'); (async () => { for (let attempt = 0; attempt < 8; attempt += 1) { const snapshot = await RESILIA_API.getAutoRemodelState(out.run_id); if (snapshot && snapshot.status === 'ok' && snapshot.state) { syncAutoRemodelState(snapshot.state); const hydrated = await hydrateAutoRemodelPickedState( snapshot.state, directionsRef.current, { announce: attempt > 0 }, ); if (hydrated) return; } await new Promise(resolve => setTimeout(resolve, 250)); } })().catch(e => console.warn('auto-remodel hydrate failed', e)); } catch (e) { showToast('Failed to start Auto-Remodel: ' + e.message, 'Error'); } finally { arStartPendingRef.current = false; setArStartPending(false); } }; const loadMoreAutoRemodelAds = async () => { try { await loadAutoRemodelPage({ ids: arPickedIdsRef.current }); } catch (e) { showToast('Failed to load more Auto-Remodel ads: ' + e.message, 'Error'); } }; const stopAutoRemodel = async () => { try { const out = await RESILIA_API.stopAutoRemodel(); if (out.status === 'not_running') { setArStage('cancelled'); arStageRef.current = 'cancelled'; setArLastEvent({ stage: 'cancelled', reason: 'No active Auto-Remodel run.' }); showToast('Auto-Remodel is not running.', 'Auto-Remodel'); return; } setArStage('cancelled'); arStageRef.current = 'cancelled'; setArLastEvent({ stage: 'cancelled', run_id: out.run_id, reason: 'Stopped by user' }); showToast('Auto-Remodel stopping...', 'Auto-Remodel'); } catch (e) { showToast('Failed to stop Auto-Remodel: ' + e.message, 'Error'); } }; const regenerateRemodelJob = async (jobId) => { try { const out = await RESILIA_API.retryRemodelJob(jobId); const adId = Object.keys(remodelScripts).find(id => { const e = remodelScripts[id]; return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId); }); if (adId) { setRemodelScripts(prev => ({ ...prev, [adId]: { status: 'pending', variants: [] } })); if (remodelPollRef.current) clearInterval(remodelPollRef.current); remodelPollRef.current = setInterval(() => pollRemodelJobs([adId]), 3000); pollRemodelJobs([adId]); } showToast(`Regeneration started (${out.run_id || jobId}).`, 'Remodel'); } catch (e) { showToast('Regenerate failed: ' + e.message, 'Error'); } }; const discardRemodelJob = async (jobId) => { if (!confirm('Discard this remodel variant?')) return; try { await RESILIA_API.deleteRemodelJob(jobId); setRemodelScripts(prev => { const next = { ...prev }; for (const adId of Object.keys(next)) { const entry = next[adId]; if (!entry || !Array.isArray(entry.variants)) continue; const variants = entry.variants.filter(v => v.jobId !== jobId); next[adId] = { ...entry, variants, status: variants.length ? entry.status : 'error' }; } return next; }); showToast('Variant discarded.', 'Remodel'); } catch (e) { showToast('Discard failed: ' + e.message, 'Error'); } }; const promoteRemodelJob = async (jobId) => { try { const out = await RESILIA_API.promoteRemodelJob(jobId); const job = out.job || out; const ad = remodeledAds.find(a => a.id === job.ad_id) || autoRemodelAds.find(a => a.id === job.ad_id); if (ad) { setConvertedAds(prev => prev.some(a => a.id === ad.id) ? prev : [...prev, ad]); } if (job.ad_id) pollRemodelJobs([job.ad_id]); showToast(`Promoted to Video Queue (job ${job.id || jobId}).`, 'Promote'); return job; } catch (e) { showToast('Promote failed: ' + e.message, 'Error'); return null; } }; const promoteAllAutoRemodelJobs = async () => { const jobIds = []; autoRemodelAds.forEach(ad => { const entry = remodelScripts[ad.id]; if (!entry || !Array.isArray(entry.variants)) return; entry.variants.forEach(v => { if (v.jobId) jobIds.push(v.jobId); }); }); if (!jobIds.length) { showToast('No Auto-Remodel scripts are ready.', 'Promote'); return; } try { const promoted = []; for (const jobId of jobIds) { const out = await RESILIA_API.promoteRemodelJob(jobId); const job = out.job || out; if (job) promoted.push(job); } const promotedAdIds = new Set(promoted.map(j => j.ad_id).filter(Boolean)); setConvertedAds(prev => { const seen = new Set(prev.map(a => a.id)); const next = [...prev]; autoRemodelAds.forEach(ad => { if (promotedAdIds.has(ad.id) && !seen.has(ad.id)) next.push(ad); }); return next; }); if (promotedAdIds.size) pollRemodelJobs([...promotedAdIds]); showToast(`${promoted.length} Auto-Remodel script${promoted.length === 1 ? '' : 's'} promoted to Video Queue.`, 'Promote'); } catch (e) { showToast('Promote all failed: ' + e.message, 'Error'); } }; // ---- Research-tab bulk delete ----------------------------------------- const bulkDeleteResearch = async () => { const ids = [...selResearch]; if (!ids.length) return; if (!confirm(`Remove ${ids.length} selected Research card${ids.length === 1 ? '' : 's'}?\n\nSearch history, AutoSearch history, and evaluated rows are updated. Swipe-file rows stay available.`)) return; try { const out = await RESILIA_API.bulkDeleteEvaluated(ids); setResearchAds(prev => prev.filter(a => !selResearch.has(a.id))); setSelResearch(new Set()); showToast(`${out.removed} ad(s) removed.`, 'Delete'); } catch (e) { showToast('Bulk delete failed: ' + e.message, 'Error'); } }; const loadMoreFilterAds = async () => { try { await loadFilterPage(); } catch (e) { showToast('Failed to load more filtered ads: ' + e.message, 'Error'); } }; const loadMoreRemodelAds = async () => { try { await loadRemodelPage(); } catch (e) { showToast('Failed to load more remodels: ' + e.message, 'Error'); } }; const bulkDeleteFilter = async () => { const ids = [...selFilter]; if (!ids.length) return; if (!confirm(`Remove ${ids.length} filtered video${ids.length === 1 ? '' : 's'} from the Filter tab?\n\nSwipe-file rows stay available for search and future runs.`)) return; try { const out = await RESILIA_API.bulkDeleteFilterVerdicts(ids); setFilteredAds(prev => prev.filter(a => !selFilter.has(a.id))); setSelFilter(new Set()); showToast(`${out.removed} filtered video${out.removed === 1 ? '' : 's'} removed.`, 'Delete'); } catch (e) { showToast('Filter delete failed: ' + e.message, 'Error'); } }; const bulkDeleteRemodel = async () => { const ids = [...selRemodel]; if (!ids.length) return; if (!confirm(`Delete remodel jobs for ${ids.length} video${ids.length === 1 ? '' : 's'}?\n\nThis removes the scripts from the Remodel tab.`)) return; try { const out = await RESILIA_API.bulkDeleteRemodelJobs(ids); setRemodeledAds(prev => prev.filter(a => !selRemodel.has(a.id))); setRemodelScripts(prev => { const next = { ...prev }; ids.forEach(id => { delete next[id]; }); return next; }); setSelRemodel(new Set()); showToast(`${out.removed} remodel job${out.removed === 1 ? '' : 's'} deleted.`, 'Delete'); } catch (e) { showToast('Remodel delete failed: ' + e.message, 'Error'); } }; // ---- Pipeline transitions ---------------------------------------------- const toggleSel = (setter) => (id) => { setter(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); }; const ensureAdsInSwipe = async (picked) => { // Filter/Remodel backends read transcripts from swipe_file, so ads // coming straight from search need to be persisted first. Autoresearch // ads are already in swipe_file via the promote-winners flow. for (const ad of picked) { try { await RESILIA_API.addToSwipe({ adId: ad.id, raw: ad.raw || {}, directionId: ad.directionId || null, }); } catch (e) { console.warn('addToSwipe failed for', ad.id, e); } } }; const passToFilter = async () => { const picked = researchAds.filter(a => selResearch.has(a.id)); if (picked.length === 0) return; setFilterBusy(true); const seededAds = picked.map(a => ({ ...a, source: 'filter' })); setFilteredAds(prev => mergeRecentAds(seededAds, prev)); setSelResearch(new Set()); setTab('filter'); try { await ensureAdsInSwipe(picked); await RESILIA_API.runFilter({ adIds: picked.map(a => a.id) }); await refreshFilterRunState(); startFilterStatePolling(); showToast(`Filter started on ${picked.length} ads.`, 'Filter'); } catch (e) { showToast('Filter failed: ' + e.message, 'Error'); } finally { if (!filterPollRef.current) setFilterBusy(false); } }; const passWithoutFilter = async () => { const picked = researchAds.filter(a => selResearch.has(a.id)); if (picked.length === 0) return; const seededAds = picked.map(a => ({ ...a, source: 'remodel' })); setRemodeledAds(prev => mergeRecentAds(seededAds, prev)); setSelResearch(new Set()); setTab('remodel'); try { await ensureAdsInSwipe(picked); await startRemodelAndPoll(picked); showToast(`${picked.length} ads passed without filtering. Remodeling…`, 'Bypass'); } catch (e) { showToast('Pass failed: ' + e.message, 'Error'); } }; // Extract just the REMODELED SCRIPT prose from the remodeler output — skip // the analysis header and any surrounding metadata. Each paragraph becomes // one untagged line so the UI renders it as a readable transcript rather // than a HOOK/PROBLEM/SOLUTION breakdown. const parseRemodelScript = (text) => { if (!text) return []; const scriptMatch = text.match(/###\s*REMODELED\s+SCRIPT\s*\n([\s\S]*?)(?=\n###\s|$)/i); const body = scriptMatch ? scriptMatch[1].trim() : text.trim(); const paras = body.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); return paras.map(p => ({ tag: '', body: p.replace(/^\*\*[^*]+\*\*\s*/, '').replace(/\n+/g, ' '), })); }; // ``editRemodelScript`` now takes a job id directly (RemodelView passes // the jobId of the specific variant being edited). Each ad can have // multiple variants in flight, so an adId-only lookup would be ambiguous. const editRemodelScript = async (jobId, newText) => { if (!jobId) { showToast('No remodel job to edit yet — wait for it to finish.', 'Remodel'); return; } try { await RESILIA_API.updateRemodelScript({ jobId, responseText: newText }); // Refresh by re-polling the affected ad — keeps the variants array in // sync with the DB without a bespoke patch path. const adId = Object.keys(remodelScripts).find(id => { const e = remodelScripts[id]; return e && Array.isArray(e.variants) && e.variants.some(v => v.jobId === jobId); }); if (adId) pollRemodelJobs([adId]); showToast('Edit saved.', 'Remodel'); } catch (e) { showToast('Save failed: ' + e.message, 'Error'); throw e; } }; // Each ad can spawn N variants (one Sonnet call per variant). We track // them as ``{ status, variants: [{ jobId, variantIdx, raw, lines }] }`` // so the Remodel view can render every finished variant as its own // editable card. Polling stops only when every variant for every ad has // reached done/failed. const autoRemodelJobOptions = (extra = {}) => ({ source: 'auto_remodel', limitPerAd: Math.max(1, parseInt(arNVariantsRef.current, 10) || 1), ...extra, }); const pollRemodelJobs = async (adIds, options = {}) => { const markMissingAsError = !!options.markMissingAsError; try { const cleanIds = [...new Set((adIds || []).filter(Boolean))]; if (!cleanIds.length) return; const jobs = await RESILIA_API.listRemodelJobs({ adIds: cleanIds, source: options.source || null, limitPerAd: options.limitPerAd || null, limit: Math.min(1000, cleanIds.length * Math.max(1, options.limitPerAd || 10)), }); const jobsByAd = new Map(); (jobs || []).forEach(job => { if (!job?.ad_id) return; if (!jobsByAd.has(job.ad_id)) jobsByAd.set(job.ad_id, []); jobsByAd.get(job.ad_id).push(job); }); setRemodelScripts(prev => { const next = { ...prev }; let stillPending = 0; cleanIds.forEach((id) => { const jobs = jobsByAd.get(id) || []; if (jobs.length === 0) { if (markMissingAsError) { next[id] = { status: 'error', variants: [], error: 'No remodel job was queued for this ad. The picked ad may be missing a swipe row or transcript.', }; } else { next[id] = { status: 'pending', variants: [] }; stillPending++; } return; } const done = jobs.filter(j => j.status === 'done' && j.response_text); const failed = jobs.filter(j => j.status === 'failed'); const inFlight = jobs.length - done.length - failed.length; if (done.length > 0) { next[id] = { status: 'ready', variants: done .slice() .sort((a, b) => (a.variant_idx || 0) - (b.variant_idx || 0)) .map(j => ({ jobId: j.id, variantIdx: j.variant_idx || 0, raw: j.response_text, lines: parseRemodelScript(j.response_text), promotedAt: j.promoted_at || null, })), }; if (inFlight > 0) stillPending++; } else if (failed.length === jobs.length && jobs.length > 0) { next[id] = { status: 'error', variants: [], error: failed[0]?.error || 'Generation failed', }; } else { next[id] = { status: 'pending', variants: [] }; stillPending++; } }); if (stillPending === 0 && remodelPollRef.current) { clearInterval(remodelPollRef.current); remodelPollRef.current = null; } return next; }); } catch (e) { console.warn('poll remodel jobs failed', e); } }; const startRemodelAndPoll = async (picked) => { const adIds = picked.map(a => a.id); setRemodelScripts(prev => { const next = { ...prev }; adIds.forEach(id => { next[id] = 'pending'; }); return next; }); await RESILIA_API.runRemodel({ adIds, nVariants: 1 }); await refreshRemodelRunState(); if (remodelPollRef.current) clearInterval(remodelPollRef.current); remodelPollRef.current = setInterval(() => { pollRemodelJobs(adIds, { source: 'manual' }); refreshRemodelRunState(); }, 3000); pollRemodelJobs(adIds, { source: 'manual' }); }; const runRemodel = async () => { const picked = filteredAds.filter(a => selFilter.has(a.id)); if (picked.length === 0) return; setRemodelBusy(true); try { await ensureAdsInSwipe(picked); await startRemodelAndPoll(picked); setRemodeledAds(prev => mergeRecentAds(picked.map(a => ({ ...a, source: 'remodel' })), prev)); setSelFilter(new Set()); setTab('remodel'); showToast(`Remodel started on ${picked.length} ads (1 variant each).`, 'Remodel'); } catch (e) { showToast('Remodel failed: ' + e.message, 'Error'); } finally { if (!remodelPollRef.current) setRemodelBusy(false); } }; const convert = () => { const picked = remodeledAds.filter(a => selRemodel.has(a.id)); setConvertedAds(picked); setSelRemodel(new Set()); setTab('video'); showToast(`${picked.length} ads queued for video conversion.`, 'Video'); }; const signIn = () => { window.location.assign(RESILIA_API.loginUrl()); }; const signOut = async () => { await RESILIA_API.logout(); setAuthState({ status: 'signed_out', user: null }); }; const pipelineStatusText = (pipelineState, label) => { const run = pipelineState?.run; if (!run) return null; const effectiveStatus = pipelineState?.state?.effective_status || run.status; const total = run.total || pipelineState?.state?.job_count || 0; const completed = run.completed || 0; const failed = run.failed || 0; if (effectiveStatus === 'running') { return `${label} running: ${completed}/${total || '?'} processed${failed ? `, ${failed} failed` : ''}.`; } if (effectiveStatus === 'interrupted') { return `${label} was interrupted after ${completed}/${total || '?'} processed. Saved results are shown.`; } if (effectiveStatus === 'failed') { return `${label} finished with failures: ${completed}/${total || '?'} processed, ${failed} failed.`; } if (effectiveStatus === 'done' && total > 0) { return `${label} restored: ${completed}/${total} processed.`; } return null; }; const counts = { research: researchAds.length, autoRemodel: autoRemodelAds.length || arPickedIds.length, filter: filteredAds.length, remodel: remodeledAds.length, video: convertedAds.length, }; const isAdmin = !!authState.user?.is_admin; // group by platform const groupByPlatform = (ads) => { const g = { foreplay: [], adspy: [], brandsearch: [], metaads: [] }; ads.forEach(a => g[a.platform]?.push(a)); return g; }; if (authState.status !== 'authenticated') { return (
Resilia
); } return (
setTab('accessRequests')} onLogout={signOut} />
{isAdmin && ( setManagementCollapsed(v => !v)} onChange={setTab} /> )}
{tab === 'adminDirections' && isAdmin && ( )} {tab === 'adminWinningCriteria' && isAdmin && ( )} {tab === 'adminCosts' && isAdmin && ( )} {tab === 'adminWorkspaces' && isAdmin && ( )} {tab === 'accessRequests' && isAdmin && ( )} {tab === 'adminUsers' && isAdmin && ( )} {tab === 'research' && ( )} {tab === 'filter' && ( )} {tab === 'autoRemodel' && ( )} {tab === 'remodel' && ( )} {tab === 'video' && ( )}
{tab === 'research' && ( setSelResearch(new Set())} onSelectAll={() => setSelResearch(new Set(researchAds.map(a => a.id)))} /> )} {tab === 'filter' && ( setSelFilter(new Set())} onSelectAll={() => setSelFilter(new Set(filteredAds.map(a => a.id)))} /> )} {tab === 'remodel' && ( setSelRemodel(new Set())} onSelectAll={() => setSelRemodel(new Set(remodeledAds.map(a => a.id)))} /> )} setExpandedAd(null)} /> {toast && (
{toast.badge} {toast.msg}
)} {!tweaksOpen && ( )} {tweaksOpen && ( { setTweaks(next); window.parent.postMessage({ type: '__edit_mode_set_keys', edits: next }, '*'); }} onClose={() => setTweaksOpen(false)} /> )}
); } function ManagementSidebar({ active, collapsed, pendingAccessCount, onToggle, onChange }) { const items = [ { id: 'adminDirections', label: 'Direction Manager', icon: 'target', meta: 'Directions' }, { id: 'adminWinningCriteria', label: 'Winning Ads Criteria', icon: 'trend', meta: 'Scoring rules' }, { id: 'adminCosts', label: 'Cost Dashboard', icon: 'bolt', meta: 'Spend' }, { id: 'adminWorkspaces', label: 'User Work Viewer', icon: 'users', meta: 'User snapshots' }, { id: 'adminUsers', label: 'User Manager', icon: 'users', meta: 'Grant or revoke admin' }, { id: 'accessRequests', label: 'Access Manager', icon: 'shield', meta: `${pendingAccessCount || 0} pending`, count: pendingAccessCount || 0 }, ]; return ( ); } const blankDirectionForm = () => ({ display_name: '', search_method: 'keyword_discovery', keywords: '', platforms: { foreplay: true, adspy: true, brandsearch: true }, target_n: '5', status: 'active', platform_filters: emptyDirectionPlatformFilters(), }); function AdminDirectionManager({ onCatalogChanged }) { const [directions, setDirections] = useState([]); const [selectedId, setSelectedId] = useState(null); const [form, setForm] = useState(blankDirectionForm()); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const activeDirections = directions.filter(d => d.status !== 'archived'); const archivedCount = directions.length - activeDirections.length; const selected = directions.find(d => d.id === selectedId) || null; const splitKeywords = (value) => String(value || '') .split(/[\n,]+/) .map(item => item.trim()) .filter(Boolean); const keywordText = (direction) => { const keywords = Array.isArray(direction?.keywords) ? direction.keywords : []; return keywords.filter(Boolean).join('\n'); }; const platformsForDirection = (direction) => { const saved = Array.isArray(direction?.platforms) ? direction.platforms.map(item => String(item).toLowerCase()).filter(item => LIVE_SOURCES.includes(item)) : []; if (saved.length) return saved; const platformKeywords = direction?.platform_keywords || {}; const keyed = LIVE_SOURCES.filter(platform => Array.isArray(platformKeywords[platform]) && platformKeywords[platform].length); if (keyed.length) return keyed; return LIVE_SOURCES; }; const platformStateForDirection = (direction) => { const selected = new Set(platformsForDirection(direction)); return Object.fromEntries(LIVE_SOURCES.map(platform => [platform, selected.has(platform)])); }; const selectedFormPlatforms = () => LIVE_SOURCES.filter(platform => form.platforms?.[platform]); const platformFiltersForDirection = (direction) => Object.fromEntries( LIVE_SOURCES.map(platform => { const config = direction?.platform_configs?.[platform] || {}; const filters = config && typeof config === 'object' && !Array.isArray(config) && config.api_filters ? config.api_filters : config; return [platform, normalizeDirectionApiFilters(platform, filters || {})]; }), ); const methodLabel = (method) => method === 'brand_lookup' ? 'Brand lookup' : 'Keyword discovery'; const load = async () => { setLoading(true); setError(''); try { const directionData = await RESILIA_API.adminListDirections(); const nextDirections = directionData.directions || []; setDirections(nextDirections); if (!selectedId && nextDirections.length) setSelectedId(nextDirections[0].id); } catch (e) { setError(e.message || 'Admin data failed to load'); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); useEffect(() => { if (!selected) return; setForm({ display_name: selected.display_name || selected.brand_name || selected.id || '', search_method: selected.search_method || (selected.type === 'competitor' ? 'brand_lookup' : 'keyword_discovery'), keywords: keywordText(selected), platforms: platformStateForDirection(selected), target_n: String(selected.target_n || 5), status: selected.status || 'active', platform_filters: platformFiltersForDirection(selected), }); }, [selected]); const updateForm = (patch) => setForm(current => ({ ...current, ...patch })); const updatePlatform = (platform, value) => setForm(current => ({ ...current, platforms: { ...current.platforms, [platform]: value }, })); const updatePlatformFilterValue = (platform, key, value) => setForm(current => { const filters = { ...(current.platform_filters?.[platform] || {}) }; if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) delete filters[key]; else filters[key] = value; return { ...current, platform_filters: { ...current.platform_filters, [platform]: filters }, }; }); const updatePlatformFilterRange = (platform, key, index, value, numeric) => setForm(current => { const filters = { ...(current.platform_filters?.[platform] || {}) }; const existing = asFilterList(filters[key]); const next = [existing[0] || '', existing[1] || '']; if (value === '') next[index] = ''; else if (numeric) { const numberValue = Number(value); next[index] = Number.isFinite(numberValue) ? numberValue : value; } else { next[index] = value; } const compact = next.filter(item => item !== null && item !== undefined && item !== ''); if (compact.length) filters[key] = compact; else delete filters[key]; return { ...current, platform_filters: { ...current.platform_filters, [platform]: filters }, }; }); const newDirection = () => { setSelectedId(null); setForm(blankDirectionForm()); setError(''); }; const syncDirectionKeywords = async (directionId, platforms, keywords) => { if (!selectedId || !directionId) return; const desired = new Set(); platforms.forEach(platform => keywords.forEach(keyword => desired.add(`${platform}\u0000${keyword.toLowerCase()}`))); const existingItems = Array.isArray(selected?.keyword_items) ? selected.keyword_items : []; const existing = new Set( existingItems.map(item => `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`), ); for (const item of existingItems) { const key = `${String(item.platform || 'all').toLowerCase()}\u0000${String(item.keyword || '').trim().toLowerCase()}`; if (!desired.has(key)) await RESILIA_API.adminArchiveDirectionKeyword(directionId, item.id); } for (const platform of platforms) { for (const keyword of keywords) { const key = `${platform}\u0000${keyword.toLowerCase()}`; if (!existing.has(key)) await RESILIA_API.adminAddDirectionKeyword(directionId, { platform, keyword }); } } }; const renderPlatformFilterField = (platform, field, disabled) => { const filters = form.platform_filters?.[platform] || {}; const value = filters[field.key]; const title = `${PLATFORMS[platform]?.label || platform} ${field.key}`; if (field.type === 'select') { const currentValue = Array.isArray(value) ? (value[0] || '') : (value || ''); return ( ); } if (field.type === 'boolean') { const currentValue = value === true ? 'true' : value === false ? 'false' : ''; return ( ); } if (field.type === 'list') { return ( ); } if (field.type === 'range' || field.type === 'dateRange') { const range = asFilterList(value); const numeric = field.type === 'range'; return ( ); } return ( ); }; const saveDirection = async () => { setBusy(true); setError(''); try { const name = form.display_name.trim(); if (!name) throw new Error('Name is required'); const selectedPlatforms = selectedFormPlatforms(); if (!selectedPlatforms.length) throw new Error('Select at least one platform'); const keywords = splitKeywords(form.keywords); const firstLookup = keywords[0] || ''; const platformFilters = Object.fromEntries( LIVE_SOURCES.map(platform => [platform, { api_filters: cleanApiFilters(form.platform_filters?.[platform] || {}) }]), ); const body = { id: selectedId || null, type: form.search_method === 'brand_lookup' ? 'competitor' : 'keyword', display_name: name, description: null, search_method: form.search_method, brand_name: form.search_method === 'brand_lookup' ? name : null, brand_domain: form.search_method === 'brand_lookup' && firstLookup.includes('.') ? firstLookup : null, target_n: Math.max(1, Math.min(1000, parseInt(form.target_n, 10) || 5)), status: form.status || 'active', api_filters: {}, keywords: [], platforms: selectedPlatforms, platform_keywords: Object.fromEntries(selectedPlatforms.map(platform => [platform, keywords])), platform_configs: platformFilters, }; const out = await RESILIA_API.adminSaveDirection(body); const savedId = out.direction?.id || selectedId || null; if (savedId) await syncDirectionKeywords(savedId, selectedPlatforms, keywords); await load(); await onCatalogChanged?.(); setSelectedId(savedId || out.direction?.id || body.id); } catch (e) { setError(e.message || 'Save failed'); } finally { setBusy(false); } }; const archiveDirection = async () => { if (!selectedId || !confirm(`Archive ${selectedId}?`)) return; setBusy(true); setError(''); try { await RESILIA_API.adminArchiveDirection(selectedId); setSelectedId(null); setForm(blankDirectionForm()); await load(); await onCatalogChanged?.(); } catch (e) { setError(e.message || 'Archive failed'); } finally { setBusy(false); } }; return (
Direction Manager
{activeDirections.length} active · {archivedCount} archived
{error &&
{error}
}