// texts.jsx — «Команда» → «Тексты» (CJM 3.1: «команда меняет тексты предложений и бонусов без // разработчика»): сообщения Николая, карточки бонусов, подписи вариантов тарифа (без цен) и экран // «Почему уходишь?». json в репо — исходный текст; правка хранится в базе поверх него и сразу видна // участникам, «Вернуть исходный» удаляет правку. Внизу — журнал: кто и когда менял. // Проверки — на сервере (/api/admin/texts), здесь те же правила для подсказки до отправки. const TEXT_GROUPS = [['messages', 'Сообщения Николая'], ['bonuses', 'Подарки за баллы'], ['tariffs', 'Варианты тарифа'], ['churn', 'Экран ухода']]; const TEXT_ERR = { required: () => 'Заполни поле', too_long: (e) => `Не длиннее ${e.max} знаков`, not_int: () => 'Нужно целое число', out_of_range: (e) => `От ${fmtNum(e.min)} до ${fmtNum(e.max)}`, too_many: (e) => `Не больше ${e.max} строк`, }; // значение поля → строка для формы и обратно const toForm = (f, v) => f.type === 'paras' ? (v || []).join('\n\n') : f.type === 'lines' ? (v || []).join('\n') : v == null ? '' : String(v); const fromForm = (f, s) => f.type === 'paras' ? s.split(/\n\s*\n/).map(x => x.trim()).filter(Boolean) : f.type === 'lines' ? s.split('\n').map(x => x.trim()).filter(Boolean) : f.type === 'int' ? (s.trim() === '' ? null : s.trim()) : s; function checkField(f, s) { const v = s.trim(); if (f.type === 'int') { if (!v) return f.nullable ? null : { error: 'required' }; const n = Number(v.replace(/\s/g, '')); if (!Number.isInteger(n)) return { error: 'not_int' }; if (n < f.min || n > f.max) return { error: 'out_of_range', min: f.min, max: f.max }; return null; } if (f.required && !v) return { error: 'required' }; if (f.type === 'lines') { const l = v.split('\n').filter(x => x.trim()); if (f.maxItems && l.length > f.maxItems) return { error: 'too_many', max: f.maxItems }; if (l.some(x => x.trim().length > f.max)) return { error: 'too_long', max: f.max }; return null; } if (v.length > f.max) return { error: 'too_long', max: f.max }; return null; } function TextEntry({ e, onSaved }) { const [open, setOpen] = React.useState(false); const init = () => Object.fromEntries(e.fields.map(f => [f.name, toForm(f, e.value[f.name])])); const [form, setForm] = React.useState(init); const [errs, setErrs] = React.useState({}); const [busy, setBusy] = React.useState(false); React.useEffect(() => { setForm(init()); setErrs({}); }, [e]); const dirty = e.fields.some(f => form[f.name] !== toForm(f, e.value[f.name])); const save = async () => { const bad = {}; for (const f of e.fields) { const x = checkField(f, form[f.name]); if (x) bad[f.name] = x; } setErrs(bad); if (Object.keys(bad).length) return; setBusy(true); const value = Object.fromEntries(e.fields.map(f => [f.name, fromForm(f, form[f.name])])); const r = await putJSON('/api/admin/texts/' + encodeURIComponent(e.key), { value }); setBusy(false); if (!r.ok) { if (r.j && r.j.field) return setErrs({ [r.j.field]: r.j }); return toast('Не сохранилось: ошибка сервера'); } toast('Сохранено, участники уже видят новый текст'); onSaved(); }; const reset = async () => { if (!window.confirm('Вернуть исходный текст из файла? Твоя правка удалится, в журнале останется запись.')) return; setBusy(true); const r = await fetch('/api/admin/texts/' + encodeURIComponent(e.key), { method: 'DELETE', credentials: 'same-origin' }).then(x => x.json()).catch(() => null); setBusy(false); if (r && r.ok) { toast('Вернули исходный текст'); onSaved(); } }; const preview = e.fields.map(f => toForm(f, e.value[f.name])).filter(Boolean).join(' · ').replace(/\n+/g, ' '); return (
{open && (
{e.changed &&
Изменил {e.updated_by} · {fmtDT(e.updated_at)}
} {e.fields.map(f => { const err = errs[f.name]; const multi = f.type === 'long' || f.type === 'paras' || f.type === 'lines'; const len = f.type === 'int' ? null : (form[f.name] || '').trim().length; return (
{f.hint &&
{f.hint}
} {multi ?