// Static Ads — image-ad generation surface. Standalone product: it drives the
// existing static_ads backend (POC orchestrator -> RabbitMQ lane -> S3) and only
// renders the run form, live progress, and the variants gallery/history. No
// pipeline logic lives here. Mirrors AutoRemodelPanel.jsx conventions.
const { useState: useSAState, useEffect: useSAEffect } = React;
const STATIC_ADS_VARIANTS_MAX = 10;
const STATIC_ADS_VARIANTS_DEFAULT = 4;
// Backend only emits coarse status + a 0.0–1.0 fraction (0.05 the whole time a
// run is "running"), so the named sub-steps from the ticket are not authoritative.
const SA_STRICTNESS_OPTIONS = ['strict', 'medium', 'loose'];
function _saStatusText(status) {
switch (status) {
case 'queued': return 'Queued…';
case 'running': return 'Generating variants…';
case 'done': return 'Complete';
case 'failed': return 'Failed';
case 'cancelled': return 'Stopped';
default: return status || 'Idle';
}
}
function _saStatusClass(status) {
if (status === 'done') return 'ok';
if (status === 'failed') return 'err';
if (status === 'cancelled') return 'warn';
return 'run';
}
function _saMoney(n) {
const v = Number(n);
if (!Number.isFinite(v)) return '$0.00';
return `$${v.toFixed(v < 1 ? 4 : 2)}`;
}
function _saAge(iso) {
if (!iso) return '';
const t = Date.parse(iso);
if (!Number.isFinite(t)) return '';
return window.formatRelativeTime ? window.formatRelativeTime(Date.now() - t) : '';
}
// Canonical per-step sequence (#221) — must match STATIC_ADS_STEPS in
// static_ads_service.py, in real execution order (validation runs early).
const SA_STEPS = [
{ key: 'research', label: 'Researching competitor ads' },
{ key: 'validation', label: 'Scoring & winning-ad benchmark' },
{ key: 'prompt_writing', label: 'Writing the image prompt' },
{ key: 'banana_generation', label: 'Generating images' },
{ key: 'compliance', label: 'Finalizing' },
{ key: 'done', label: 'Complete' },
];
const _saStepIndex = (key) => SA_STEPS.findIndex(s => s.key === key); // -1 if unknown
// Friendly failure text (backend classifies into state.error_message, #221);
// falls back gracefully for older rows that predate classification.
function _saFriendlyError(run) {
return (run && run.state && run.state.error_message)
|| 'Generation failed — please try again.';
}
// Per-step progress stepper. Completed steps = check, current = spinner,
// the failed step = ✕, the rest = dim. Driven by the live polled run state.
function StaticAdsStepper({ currentStep, failedStep, status }) {
const done = status === 'done';
const failed = status === 'failed';
const curIdx = done
? SA_STEPS.length - 1
: failed ? _saStepIndex(failedStep) : _saStepIndex(currentStep);
return (
{SA_STEPS.map((s, i) => {
let cls = 'pending';
if (done || (curIdx >= 0 && i < curIdx)) cls = 'done';
else if (curIdx >= 0 && i === curIdx) cls = failed ? 'failed' : 'active';
return (
-
{cls === 'done' ?
: cls === 'failed' ?
: cls === 'active' ?
: {i + 1}}
{s.label}
);
})}
);
}
// One variant tile. Image link is short-lived (15-min S3 presign); on load
// error we show a graceful "expired" placeholder rather than a broken image.
// Per-variant Save / Regenerate / Delete wired in #221 (run-scoped via runId).
function StaticAdsVariantCard({ variant, runId, onOpen, onSave, onRegenerate, onDelete, busy }) {
const [errored, setErrored] = useSAState(false);
const [regenOpen, setRegenOpen] = useSAState(false);
const [regenPrompt, setRegenPrompt] = useSAState('');
const idx = variant?.variant_idx;
const url = variant?.image_url;
const saved = !!variant?.saved;
const actionsEnabled = !!runId && !busy;
const openRegen = () => { setRegenPrompt(variant?.prompt || ''); setRegenOpen(true); };
const confirmRegen = () => {
const override = (regenPrompt || '').trim();
onRegenerate && onRegenerate(runId, idx, override && override !== variant?.prompt ? override : null);
setRegenOpen(false);
};
return (
{url && !errored ? (

setErrored(true)}
onClick={() => onOpen && onOpen(url)}
/>
) : (
{errored ? 'Image link expired' : 'No image'}
)}
{saved &&
Keeper}
#{idx}
{_saMoney(variant?.cost_usd)}
Compliance: {variant?.compliance_score == null ? '—' : variant.compliance_score}
{variant?.prompt && (
Prompt
{variant.prompt}
)}
{regenOpen && (
)}
);
}
function StaticAdsGallery({ variants, runId, onOpen, onSaveVariant, onRegenerateVariant, onDeleteVariant, busy }) {
const list = Array.isArray(variants) ? variants : [];
if (!list.length) return null;
return (
{list.map(v => (
))}
);
}
window.StaticAdsPanel = function StaticAdsPanel({
running = false,
status = null,
state = null,
activeRun = null,
runs = [],
brandOptions = [],
keywordSuggestions = [],
starting = false,
error = null,
onRun,
onStop,
onDeleteRun,
onSaveVariant,
onRegenerateVariant,
onDeleteVariant,
onRefresh,
}) {
const [keyword, setKeyword] = useSAState('');
const [brand, setBrand] = useSAState('');
const [nVariants, setNVariants] = useSAState(STATIC_ADS_VARIANTS_DEFAULT);
const [strictness, setStrictness] = useSAState('medium'); // disabled placeholder
const [lightbox, setLightbox] = useSAState(null);
const [lightboxError, setLightboxError] = useSAState(false);
const [expandedRunId, setExpandedRunId] = useSAState(null);
const [refreshing, setRefreshing] = useSAState(false);
const handleRefresh = async () => {
if (refreshing || !onRefresh) return;
setRefreshing(true);
try { await onRefresh(); } finally { setRefreshing(false); }
};
useSAEffect(() => { setLightboxError(false); }, [lightbox]);
useSAEffect(() => {
if (!lightbox) return undefined;
const onKey = (e) => { if (e.key === 'Escape') setLightbox(null); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [lightbox]);
const activeStatus = status || activeRun?.status || null;
const activeState = state || activeRun?.state || {};
const showProgress = !!activeRun && (running || ['queued', 'running', 'done', 'failed', 'cancelled'].includes(activeStatus));
const isComplete = activeStatus === 'done';
const indeterminate = activeStatus === 'queued' || activeStatus === 'running';
const pct = isComplete ? 100
: (activeStatus === 'failed' || activeStatus === 'cancelled') ? 100
: Math.max(4, Math.round(Number(activeState?.progress || 0) * 100));
const canRun = !!keyword.trim() && !starting && !running;
const submit = () => {
if (!canRun || !onRun) return;
onRun({ keyword: keyword.trim(), brand: brand || null, nVariants: Number(nVariants) || 1, strictness });
};
const activeRunId = activeRun?.run_id || null;
const historyRuns = (Array.isArray(runs) ? runs : []).filter(r => r && r.run_id !== activeRunId);
return (
{/* ---- run configuration ---- */}
Static Ads
Generate static image-ad variants — Foreplay → scoring → Gate A → Banana
{onRefresh && (
)}
{error &&
{error}
}
{/* ---- live progress + current run gallery ---- */}
{showProgress && (
{isComplete ? (
) : (
)}
{_saStatusText(activeStatus)}
{activeRun?.keyword}{activeRun?.brand ? ` · ${activeRun.brand}` : ''}
{' · '}{activeRun?.n_variants_done || 0}/{activeRun?.n_variants_requested || 0} variants
{' · '}{_saMoney(activeRun?.cost_usd)}
{running && (
)}
{indeterminate && (
)}
{activeStatus === 'failed' && (
{_saFriendlyError(activeRun)}
{activeRun?.error && (
Technical details
{activeRun.error}
)}
)}
{activeStatus !== 'failed' && activeState?.regen_error_message && (
{activeState.regen_error_message}
)}
)}
{/* ---- run history ---- */}
Run history
{historyRuns.length} prior run{historyRuns.length === 1 ? '' : 's'}
{historyRuns.length === 0 ? (
No prior runs yet. Generate your first set of variants above.
) : (
{historyRuns.map(run => {
const open = expandedRunId === run.run_id;
return (
setExpandedRunId(open ? null : run.run_id)}>
{_saStatusText(run.status)}
{run.keyword || '(no keyword)'}
{run.brand && {run.brand}}
{run.n_variants_done || 0}/{run.n_variants_requested || 0} variants
{_saMoney(run.cost_usd)}
{_saAge(run.started_at)}
{open && (
{run.status === 'failed' && (
{_saFriendlyError(run)}
{run.error && (
Technical details
{run.error}
)}
)}
{Array.isArray(run.variants) && run.variants.length
?
: run.status !== 'failed' &&
No variants on this run.
}
)}
);
})}
)}
{/* ---- full-size lightbox ---- */}
{lightbox && (
setLightbox(null)}>
{lightboxError ? (
e.stopPropagation()}>
Image link expired — close and Refresh to reload.
) : (

e.stopPropagation()}
onError={() => setLightboxError(true)}
/>
)}
)}
);
};