/* global React */
(function () {
const { useState, useEffect, useRef, useCallback, memo } = React;
const T = window.SC_T;
const Api = window.SC_Api;
const LIMITS = {
yandex_title: 56,
yandex_title2: 56,
yandex_desc: 81,
google_title1: 30,
google_desc1: 90,
};
const EMPTY_SET = new Set();
const COLS = [
{ field: 'yandex_title', label: 'Заголовок 1', group: 'yandex' },
{ field: 'yandex_title2', label: 'Заголовок 2', group: 'yandex' },
{ field: 'yandex_desc', label: 'Текст', group: 'yandex' },
{ field: 'google_title1', label: 'Заголовок', group: 'google' },
{ field: 'google_desc1', label: 'Описание', group: 'google' },
];
// ── Ячейка редактирования ─────────────────────────────────────────────────
const AdCell = memo(function AdCell({ field, value, saving, onChange }) {
const limit = LIMITS[field];
const over = (value || '').length > limit;
const isLong = limit >= 81;
return (
{isLong ? (
{over ? (
слишком много символов
) : saving ? (
сохраняется…
) : null}
= limit * 0.85 ? T.warn : T.inkSubtle,
}}>
{(value || '').length}/{limit}
);
});
// ── Строка фразы ─────────────────────────────────────────────────────────
const PhraseRow = memo(function PhraseRow({
index, phrase, ad, status, savingFields, globalUrl, useCustomUrl, onFieldChange, onCustomUrlToggle,
}) {
const [hover, setHover] = useState(false);
const statusColor = status === 'done' ? T.ok : status === 'generating' ? T.accent : T.inkSubtle;
const rowBg = hover ? '#EDEFF2' : '#F8F9FB';
const handleChange = useCallback(
(field, value) => onFieldChange(phrase, field, value),
[phrase, onFieldChange],
);
const handleUrlToggle = useCallback((e) => {
onCustomUrlToggle(phrase, e.target.checked);
}, [phrase, onCustomUrlToggle]);
const effectiveUrl = useCustomUrl ? (ad?.url || '') : globalUrl;
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{ borderBottom: `1px solid ${T.border}`, transition: 'background .1s' }}
>
{/* # */}
|
{index}
|
{/* Фраза + URL */}
{phrase}
{status === 'done' ? '✓ готово' : status === 'generating' ? '⟳ генерируется…' : '— ожидает'}
{/* URL */}
{useCustomUrl ? (
handleChange('url', e.target.value)}
placeholder="https://…"
style={{
width: '100%', fontFamily: 'inherit', fontSize: 11,
border: `1px solid ${T.border}`, borderRadius: 4,
padding: '3px 6px', background: T.bg, color: T.ink, outline: 'none',
}}
/>
) : globalUrl ? (
{globalUrl}
) : (
ссылка не задана
)}
|
{COLS.map(({ field }) => (
|
))}
);
});
// ── Главный компонент ─────────────────────────────────────────────────────
function AdGenScreen({ jobId, phrases, onBack }) {
const [prompt, setPrompt] = useState('');
const [globalUrl, setGlobalUrl] = useState('');
const [ads, setAds] = useState({});
const [statuses, setStatuses] = useState({});
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState({ done: 0, total: 0 });
const [savingFields, setSavingFields] = useState({});
const [startFrom, setStartFrom] = useState(1);
const [customUrls, setCustomUrls] = useState(new Set());
const adsRef = useRef({});
const saveTimers = useRef({});
const urlSaveTimer = useRef(null);
const abortRef = useRef(null);
useEffect(() => {
if (!jobId) return;
Promise.all([
Api.getJson(`/api/job/${jobId}/ads`),
Api.getJson(`/api/job/${jobId}`),
]).then(([adsData, jobData]) => {
const map = {}, st = {}, customSet = new Set();
adsData.forEach((a) => {
map[a.phrase] = a;
st[a.phrase] = 'done';
if (a.url) customSet.add(a.phrase);
});
setAds(map); adsRef.current = map; setStatuses(st); setCustomUrls(customSet);
if (jobData.global_url) setGlobalUrl(jobData.global_url);
}).catch(() => {});
}, [jobId]);
const stopGeneration = useCallback(() => {
if (abortRef.current) abortRef.current.abort();
}, []);
const handleGlobalUrlChange = useCallback((val) => {
setGlobalUrl(val);
if (urlSaveTimer.current) clearTimeout(urlSaveTimer.current);
urlSaveTimer.current = setTimeout(() => {
Api.patchJson(`/api/job/${jobId}/global-url`, { global_url: val }).catch(() => {});
}, 1500);
}, [jobId]);
const startGeneration = useCallback(async () => {
const from = Math.max(1, Math.min(phrases.length, startFrom || 1));
const toGenerate = phrases.slice(from - 1);
if (!prompt.trim() || generating || !toGenerate.length) return;
setGenerating(true);
setProgress({ done: 0, total: toGenerate.length });
setStatuses((prev) => {
const next = { ...prev };
toGenerate.forEach((p) => { next[p] = 'generating'; });
return next;
});
const controller = new AbortController();
abortRef.current = controller;
const token = localStorage.getItem('sc_token') || '';
try {
const resp = await fetch(`/api/job/${jobId}/ads/generate`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ phrases: toGenerate, prompt }),
signal: controller.signal,
});
if (!resp.ok) { setGenerating(false); return; }
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6));
if (data.done) continue;
const { phrase, ...adData } = data;
setAds((prev) => {
const next = { ...prev, [phrase]: { ...(prev[phrase] || {}), ...adData } };
adsRef.current = next;
return next;
});
setStatuses((prev) => ({ ...prev, [phrase]: 'done' }));
setProgress((prev) => ({ ...prev, done: prev.done + 1 }));
}
}
} catch (e) {
if (e.name !== 'AbortError') console.error('Ad generation error:', e);
setStatuses((prev) => {
const next = { ...prev };
Object.keys(next).forEach((p) => { if (next[p] === 'generating') next[p] = 'pending'; });
return next;
});
} finally {
abortRef.current = null;
setGenerating(false);
}
}, [jobId, phrases, prompt, generating, startFrom]);
const handleFieldChange = useCallback((phrase, field, value) => {
setAds((prev) => {
const next = { ...prev, [phrase]: { ...(prev[phrase] || {}), [field]: value } };
adsRef.current = next;
return next;
});
const key = `${phrase}\x00${field}`;
if (saveTimers.current[key]) clearTimeout(saveTimers.current[key]);
const limit = LIMITS[field];
if (limit && (value || '').length > limit) return;
saveTimers.current[key] = setTimeout(async () => {
setSavingFields((prev) => {
const set = new Set(prev[phrase] || []);
set.add(field);
return { ...prev, [phrase]: set };
});
const current = adsRef.current[phrase] || {};
try { await Api.patchJson(`/api/job/${jobId}/ads`, { phrase, ...current }); } catch {}
setSavingFields((prev) => {
const set = new Set(prev[phrase] || []);
set.delete(field);
return { ...prev, [phrase]: set };
});
delete saveTimers.current[key];
}, 2000);
}, [jobId]);
const handleCustomUrlToggle = useCallback((phrase, checked) => {
setCustomUrls((prev) => {
const next = new Set(prev);
if (checked) next.add(phrase); else next.delete(phrase);
return next;
});
if (!checked) handleFieldChange(phrase, 'url', '');
}, [handleFieldChange]);
const total = phrases.length;
const fromIdx = Math.max(1, Math.min(total, startFrom || 1));
const genCount = total - fromIdx + 1;
const done = progress.done;
return (
{/* Top bar */}
Генератор рекламы
{generating && (
{done} / {progress.total} фраз
)}
{/* Prompt + Global URL */}
{/* Промпт */}
Промпт — опишите продукт или услугу для ИИ:
{/* Глобальный URL */}
Ссылка для всех фраз:
handleGlobalUrlChange(e.target.value)}
placeholder="https://example.com/?utm_source=yandex&utm_medium=cpc"
style={{
flex: 1, fontFamily: 'inherit', fontSize: 12,
border: `1px solid ${T.border}`, borderRadius: 7,
padding: '6px 10px', background: T.bg, color: T.ink, outline: 'none',
}}
/>
{generating && total > 0 && (
)}
{/* Table */}
{phrases.length === 0 ? (
Нет фраз для генерации. Вернитесь к результатам.
) : (
| # |
Фраза / Ссылка |
{COLS.map(({ field, label, group }) => (
{group === 'yandex' ? 'Яндекс' : 'Google'}
{label}
|
))}
{phrases.map((phrase, i) => (
))}
)}
);
}
window.AdGenScreen = AdGenScreen;
})();