// pages-manage.jsx — Accounts + Prices const { useState: useStateM, useMemo: useMemoM, useRef: useRefM, useEffect: useEffectM } = React; /* ============================ ACCOUNTS ============================ */ // Русская плюрализация слова «аккаунт» для подзаголовка. function accountsWord(n) { const m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return "аккаунт"; if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return "аккаунта"; return "аккаунтов"; } // Статус «реально работает» — по флагам с бэка (isActive / connected / synced) // и активности. Порядок проверок = приоритет (сверху важнее). Возвращает // {label (для пилюли), tone (для CSS-класса пилюли), title (hover)}. function accountStatus(a) { if (a.isActive === false) return { tone: "off", label: "выкл.", title: "выключен" }; if (!a.connected) return { tone: "fail", label: "не подкл.", title: "не подключён к Telegram" }; if (!a.synced) return { tone: "warn", label: "не синхр.", title: "не синхронизирован — нажми «Подтянуть с Telegram»" }; return { tone: "ok", label: "активен", title: "работает — подключён и синхронизирован" }; } // Стили пилюли статуса — приглушённая бежевая гамма (emerald/amber/rose/grey). const ACCOUNT_PILL_CLS = { ok: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", warn: "bg-amber-500/10 text-amber-400 border-amber-500/20", fail: "bg-rose-500/10 text-rose-400 border-rose-500/20", off: "bg-black/[0.04] text-zinc-500 border-black/[0.06]", }; // 3px tone-бар слева у строки — по статусу аккаунта (тёплая гамма друга). function accountTone(a) { if (a.isActive === false) return "#b4b2a9"; // выкл — серо-беж if (!a.connected) return "#b07373"; // не подключён — muted rose if (!a.synced) return "#b5c95c"; // не синхр. — muted amber return "#5da27a"; // активен — muted emerald } // Относительное «было N назад» из ISO-строки lastInteractionAt (или «—»). function accountLastSeen(iso) { if (!iso) return "—"; const t = Date.parse(iso); if (!Number.isFinite(t)) return "—"; const sec = Math.max(0, (Date.now() - t) / 1000); if (sec < 60) return "только что"; const min = Math.floor(sec / 60); if (min < 60) return min + " мин"; const hr = Math.floor(min / 60); if (hr < 24) return hr + " ч"; const d = Math.floor(hr / 24); if (d < 30) return d + " дн"; const mo = Math.floor(d / 30); if (mo < 12) return mo + " мес"; return Math.floor(mo / 12) + " г"; } // Крошечный спарклайн-тренд из [вчера, сегодня]: если данных мало — стрелка // направления, иначе мини-SVG 60×20. Тон подкрашивается под статус. function AccountTrend({ a, color }) { const y = Number(a.yesterday) || 0; const t = Number(a.today) || 0; if (y === 0 && t === 0) { return ; } const max = Math.max(y, t, 1); const pts = [y, t]; const w = 64, h = 26, pad = 3; const step = pts.length > 1 ? (w - pad * 2) / (pts.length - 1) : 0; const xy = pts.map((v, i) => [pad + i * step, h - pad - (v / max) * (h - pad * 2)]); const line = xy.map((p) => p[0].toFixed(1) + "," + p[1].toFixed(1)).join(" "); const area = pad + "," + (h - pad) + " " + line + " " + (w - pad) + "," + (h - pad); const end = xy[xy.length - 1]; return ( ); } // Кастомный дропдаун владельца — вместо нативного e.stopPropagation()} className="w-3.5 h-3.5 accent-[#9ccc52] cursor-pointer" /> {/* Модель */}
{a.name}
{a.username || "—"}
{/* Владелец — кастомный дропдаун (не нативный select) */}
e.stopPropagation()}>
{/* Статус */}
{st.label}
{/* Сегодня */}
{a.today || 0}
{/* 7 дней */}
{a.week || 0}
{/* Тренд */}
{/* Онлайн */}
{accountLastSeen(a.lastInteractionAt)}
); } // Заголовок колонок плотной таблицы (.acc-row.is-head): UPPERCASE 10px. function AccountTableHead({ allChecked, onToggleAll }) { return (
Модель Владелец Статус Сегодня Тренд Онлайн
); } // Owner-filter TAB (вкладка): «Все {N}» / «{имя} {count}» с цветной точкой. function OwnerTab({ active, dot, label, count, onClick }) { return ( ); } function AccountsPage() { // Live data из /api/accounts. Загружается в useEffect один раз. // ВАЖНО: НЕТ polling. На этой странице справа editable-поля (имя/username/ // возраст). Polling раз в 30 сек перезатирал бы введённые пользователем // значения обратно к серверным значениям. Для обновления статистики // (today/yesterday/week) — кнопка «Обновить» в заголовке. const [accounts, setAccounts] = useStateM([]); const [loading, setLoading] = useStateM(true); const [selected, setSelected] = useStateM(null); const [search, setSearch] = useStateM(""); // showAll=false → только реально работающие (активность < 2 дней). Остальные // скрыты, чтобы видеть, сколько аккаунтов сейчас реально подключено к боту. const [showAll, setShowAll] = useStateM(false); const [syncing, setSyncing] = useStateM(false); const [syncMsg, setSyncMsg] = useStateM(""); const [tab, setTab] = useStateM("accounts"); // "accounts" | "owners" const [owners, setOwners] = useStateM([]); const [ownerFilter, setOwnerFilter] = useStateM("all"); // "all" | "mine" | const [showUnsynced, setShowUnsynced] = useStateM(false); // Presentational-only: свёрнутые группы владельцев в списке (UI-состояние // сворачивания групп в стиле друга). На данные/поведение не влияет. const [collapsedGroups, setCollapsedGroups] = useStateM({}); // Плотная таблица: bulk-выбор (Set id), сортировка, drawer с деталью. const [checkedIds, setCheckedIds] = useStateM(() => new Set()); const [sortKey, setSortKey] = useStateM("today"); // today | week | name | online const [sortDir, setSortDir] = useStateM("desc"); // desc | asc const [drawerOpen, setDrawerOpen] = useStateM(false); const reload = React.useCallback(async (all) => { setLoading(true); const data = await fetchAccounts(all); setAccounts(data); setLoading(false); setSelected((cur) => cur || (data[0] ? data[0].id : null)); }, []); useEffectM(() => { let stopped = false; (async () => { const data = await fetchAccounts(showAll); if (stopped) return; setAccounts(data); setLoading(false); setSelected((cur) => cur || (data[0] ? data[0].id : null)); })(); return () => { stopped = true; }; }, [showAll]); useEffectM(() => { fetchOwners().then(setOwners); }, []); // «Подтянуть с Telegram»: обновляет имя/username/avatar/активность по всем // подключённым бизнес-аккаунтам, затем перезагружает список. const onSync = React.useCallback(async () => { setSyncing(true); setSyncMsg(""); try { const res = await syncAccounts(); const items = (res && res.items) || []; const ok = items.filter((i) => i && i.ok).length; const fail = items.length - ok; setSyncMsg(`Подтянуто: ${ok}${fail ? `, с ошибкой: ${fail}` : ""}`); await reload(showAll); } catch (e) { setSyncMsg("Ошибка синхронизации: " + (e && e.message ? e.message : e)); } finally { setSyncing(false); } }, [reload, showAll]); const current = accounts.find((a) => a.id === selected); const matched = useMemoM(() => { let arr = accounts; if (ownerFilter === "mine") arr = arr.filter((a) => !a.ownerId); else if (ownerFilter !== "all") arr = arr.filter((a) => a.ownerId === ownerFilter); if (search) { const q = search.toLowerCase(); arr = arr.filter((a) => a.name.toLowerCase().includes(q) || (a.username || "").toLowerCase().includes(q)); } return arr; }, [search, accounts, ownerFilter]); const syncedRows = matched.filter((a) => a.synced); const unsyncedRows = matched.filter((a) => !a.synced); // Presentational-only: группировка видимых (synced) строк по владельцу для // плотной таблицы в стиле друга. Порядок групп: «Мои» (админ) первым, затем // владельцы по алфавиту. НЕ меняет данные — только раскладку. const syncedGroups = useMemoM(() => { const map = new Map(); for (const a of syncedRows) { const key = a.ownerId || "__mine__"; const label = a.ownerId ? (a.ownerName || "Владелец") : "Мои"; if (!map.has(key)) map.set(key, { key, label, rows: [] }); map.get(key).rows.push(a); } const groups = Array.from(map.values()); groups.sort((g1, g2) => { if (g1.key === "__mine__") return -1; if (g2.key === "__mine__") return 1; return g1.label.localeCompare(g2.label); }); return groups; }, [syncedRows]); const toggleGroup = (key) => setCollapsedGroups((m) => ({ ...m, [key]: !m[key] })); // Смена владельца аккаунта — ТА ЖЕ операция, что в редакторе профиля и во // вкладке «Владельцы»: saveAccountProfile(id, {ownerId}) → перезагрузка. const changeOwner = React.useCallback(async (accId, ownerId) => { // Оптимистично: сразу меняем владельца в UI (контролируемый select не // откатывается и не ждём медленный reload account-stats). Сохраняем в // фоне; при ошибке — откат перезагрузкой. const ownerName = ownerId ? ((owners.find((o) => o.id === ownerId) || {}).name || "") : ""; setAccounts((prev) => prev.map((a) => (a.id === accId ? { ...a, ownerId, ownerName } : a))); try { await saveAccountProfile(accId, { ownerId }); } catch (e) { console.error("changeOwner failed", e); await reload(showAll); } }, [owners, reload, showAll]); // Bulk: сменить владельца у всех выделенных (цикл той же операции). const bulkChangeOwner = React.useCallback(async (ownerId) => { const ids = Array.from(checkedIds); const ownerName = ownerId ? ((owners.find((o) => o.id === ownerId) || {}).name || "") : ""; // Оптимистично меняем всех выделенных сразу, сохраняем в фоне. setAccounts((prev) => prev.map((a) => (ids.includes(a.id) ? { ...a, ownerId, ownerName } : a))); setCheckedIds(new Set()); for (const id of ids) { try { await saveAccountProfile(id, { ownerId }); } catch (e) { console.error("bulkChangeOwner failed", id, e); } } }, [checkedIds, owners]); // Клиентская сортировка видимых строк (презентационная). const SORT_FNS = { today: (a, b) => (a.today || 0) - (b.today || 0), week: (a, b) => (a.week || 0) - (b.week || 0), name: (a, b) => String(a.name || "").localeCompare(String(b.name || ""), "ru"), online: (a, b) => Date.parse(a.lastInteractionAt || 0) - Date.parse(b.lastInteractionAt || 0), }; const visibleRows = useMemoM(() => { const cmp = SORT_FNS[sortKey] || SORT_FNS.today; return [...matched].sort((a, b) => (sortDir === "desc" ? cmp(b, a) : cmp(a, b))); }, [matched, sortKey, sortDir]); const toggleCheck = (id) => setCheckedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const allInViewChecked = visibleRows.length > 0 && visibleRows.every((a) => checkedIds.has(a.id)); const toggleAll = () => setCheckedIds((prev) => { const next = new Set(prev); const ids = visibleRows.map((a) => a.id); const all = ids.every((id) => next.has(id)); ids.forEach((id) => (all ? next.delete(id) : next.add(id))); return next; }); const clearChecked = () => setCheckedIds(new Set()); const openAccount = (id) => { setSelected(id); setDrawerOpen(true); }; const closeDrawer = () => setDrawerOpen(false); // Закрытие drawer по Esc. useEffectM(() => { if (!drawerOpen) return; const onKey = (e) => { if (e.key === "Escape") setDrawerOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [drawerOpen]); const ownerCount = (oid) => accounts.filter((a) => a.ownerId === oid).length; const ownerColor = (oid) => (oid ? "#9ccc52" : "#b4b2a9"); return (
{tab === "accounts" ? ( <> {syncMsg ? {syncMsg} : null} ) : null}
{tab === "owners" ? : ( <> {/* Owner-filter TABS справа от заголовка секции */}
{loading ? "…" : matched.length} {accountsWord(matched.length)} моделей
setOwnerFilter("all")} /> {/* «Мои» = не привязаны ни к какому владельцу (чеки идут админу). Новые/неопределённые аккаунты автоматически попадают сюда. */} !a.ownerId).length} onClick={() => setOwnerFilter("mine")} /> {owners.map((o) => ( setOwnerFilter(o.id)} /> ))}
{/* Панель фильтра: поиск + сортировка */}
setSearch(e.target.value)} placeholder="Поиск аккаунта…" className="w-full bg-black/[0.03] border border-black/[0.06] rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-500 focus:outline-none focus:border-black/[0.14]" />
{loading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : visibleRows.length === 0 ? (
{accounts.length === 0 ? "Аккаунтов нет" : "Нет аккаунтов под текущие фильтры"}
) : ( <> {visibleRows.map((a) => ( toggleCheck(a.id)} onOpen={() => openAccount(a.id)} onOwnerChange={changeOwner} /> ))} )} )} {/* Bulk-бар: смена владельца у выделенных */} {tab === "accounts" && checkedIds.size > 0 && (
{checkedIds.size} выделено Сменить владельца:
)} {/* Drawer с деталью аккаунта (редактор профиля) */} {tab === "accounts" && drawerOpen && current && (
{current.name}
{current.username || "—"}
)}
); } // Вкладка «Владельцы» (master-detail): слева список владельцев со «Я (админ)» // сверху как основной; клик → справа его аккаунты + смена владельца у каждого. function OwnersPanel() { const [owners, setOwners] = useStateM([]); const [accounts, setAccounts] = useStateM([]); const [loading, setLoading] = useStateM(true); const [sel, setSel] = useStateM("admin"); // "admin" | const [adding, setAdding] = useStateM(false); const [name, setName] = useStateM(""); const [tgId, setTgId] = useStateM(""); const [busy, setBusy] = useStateM(false); const [msg, setMsg] = useStateM(""); const reload = React.useCallback(async () => { setLoading(true); const [os, accs] = await Promise.all([fetchOwners(), fetchAccounts(true)]); setOwners(os); setAccounts(accs); setLoading(false); }, []); useEffectM(() => { reload(); }, [reload]); const accFor = (ownerId) => accounts.filter((a) => a.ownerId === ownerId); const adminAccounts = accounts.filter((a) => !a.ownerId); const onAdd = async () => { const nm = name.trim(); const id = tgId.trim(); if (!nm || !id) { setMsg("Укажи имя и Telegram ID"); return; } setBusy(true); setMsg(""); try { await createOwner(nm, id); setName(""); setTgId(""); setAdding(false); await reload(); } catch (e) { setMsg("Ошибка: " + (e && e.message ? e.message : e)); } finally { setBusy(false); setTimeout(() => setMsg(""), 3000); } }; const onDelete = async (o) => { if (!window.confirm("Удалить владельца «" + o.name + "»? Его аккаунты вернутся к админу.")) return; setBusy(true); setMsg(""); try { await deleteOwner(o.id); if (sel === o.id) setSel("admin"); await reload(); } catch (e) { setMsg("Ошибка: " + (e && e.message ? e.message : e)); } finally { setBusy(false); } }; const onReassign = async (accId, ownerId) => { setBusy(true); setMsg(""); try { await saveAccountProfile(accId, { ownerId }); await reload(); } catch (e) { setMsg("Ошибка: " + (e && e.message ? e.message : e)); } finally { setBusy(false); } }; const inputCls = "w-full bg-zinc-800/60 border border-zinc-700/60 rounded-lg px-3 py-2 text-sm placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50"; const selOwner = sel === "admin" ? null : owners.find((o) => o.id === sel); const detailAccounts = sel === "admin" ? adminAccounts : accFor(sel); return (
Владельцы
{owners.map((o) => ( ))}
{!adding ? ( ) : (
setName(e.target.value)} placeholder="Имя (напр. Рома)" className={inputCls} /> setTgId(e.target.value.replace(/[^0-9]/g, ""))} placeholder="Telegram ID" inputMode="numeric" className={inputCls + " font-mono"} />
Друг должен раз нажать Start у бота, иначе бот не сможет слать ему чеки.
{msg &&
{msg}
}
)}
{loading ? (
Загрузка…
) : ( <>
{sel === "admin" ?
: }
{sel === "admin" ? "Я (админ)" : (selOwner || {}).name}
{sel === "admin" ? "основной — все аккаунты без отдельного владельца" : ("друг · ID " + ((selOwner || {}).telegramId || ""))}
{sel !== "admin" && selOwner && ( )}
{sel !== "admin" && (
Друг должен один раз нажать Start у бота — иначе копии чеков ему не дойдут (правило Telegram).
)}
{sel === "admin" ? "Чеки со всех этих аккаунтов приходят тебе. Чтобы отдать аккаунт другу — поменяй владельца в строке справа." : ("Копии чеков с этих аккаунтов уходят " + ((selOwner || {}).name || "") + "; тебе приходят все.")}
Аккаунты ({detailAccounts.length})
{detailAccounts.length === 0 ? (
{sel === "admin" ? "Все аккаунты розданы владельцам." : "У этого владельца пока нет аккаунтов — назначь его аккаунтам тут или во вкладке «Аккаунты»."}
) : (
{detailAccounts.map((a) => (
{a.name}
{a.username || "—"}
))}
)} )}
); } // Профиль → web: редактор настроек аккаунта (заменяет desktop). // имя / возраст / username / вкл-выкл / промпт-поля / аватар. function AccountProfileEditor({ accountId, owners = [], onSaved }) { const [form, setForm] = useStateM(null); const [pairs, setPairs] = useStateM([]); // promptFields как [[k,v],...] const [saving, setSaving] = useStateM(false); const [msg, setMsg] = useStateM(""); const [loadError, setLoadError] = useStateM(false); const fileRef = useRefM(null); useEffectM(() => { let stopped = false; setForm(null); setLoadError(false); fetchAccountProfile(accountId).then((p) => { if (stopped) return; if (!p) { setLoadError(true); return; } setForm({ ...p, age: p.age || "18" }); setPairs(Object.entries(p.promptFields || {})); }); return () => { stopped = true; }; }, [accountId]); if (loadError) return (
Не удалось загрузить профиль. Если бэкенд только что обновлён — перезапусти сервер (новые эндпоинты профиля появляются после рестарта).
); if (!form) return
Загрузка профиля…
; const set = (k, v) => setForm({ ...form, [k]: v }); const setPair = (i, idx, val) => { const a = pairs.map((p) => p.slice()); a[i][idx] = val; setPairs(a); }; const addPair = () => setPairs([...pairs, ["", ""]]); const delPair = (i) => setPairs(pairs.filter((_, j) => j !== i)); const handleSave = async () => { setSaving(true); setMsg(""); try { const promptFields = {}; for (const pr of pairs) { if (String(pr[0]).trim()) promptFields[String(pr[0]).trim()] = pr[1]; } await saveAccountProfile(accountId, { name: form.name, age: form.age || "18", isActive: form.isActive, defaultCurrency: form.defaultCurrency, promptFields, ownerId: form.ownerId || "", }); setMsg("Сохранено"); if (onSaved) await onSaved(); } catch (e) { setMsg("Ошибка: " + (e && e.message ? e.message : e)); } finally { setSaving(false); setTimeout(() => setMsg(""), 3000); } }; const handleAvatar = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; setSaving(true); setMsg(""); try { await uploadAccountAvatar(accountId, file); setMsg("Аватар обновлён"); if (onSaved) await onSaved(); } catch (err) { setMsg("Ошибка аватара: " + (err && err.message ? err.message : err)); } finally { setSaving(false); setTimeout(() => setMsg(""), 3000); } }; const inputCls = "mt-1 w-full bg-zinc-800/60 border border-zinc-700/60 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500/50"; return (
Промпт (поля персоны)
{pairs.length === 0 &&
нет полей — добавь ключ/значение (видит LLM в PROFILE_JSON)
} {pairs.map((p, i) => (
setPair(i, 0, e.target.value)} placeholder="ключ" className="w-1/3 bg-zinc-800/60 border border-zinc-700/60 rounded-md px-2 py-1.5 text-sm font-mono focus:outline-none focus:border-indigo-500/50" /> setPair(i, 1, e.target.value)} placeholder="значение" className="flex-1 bg-zinc-800/60 border border-zinc-700/60 rounded-md px-2 py-1.5 text-sm focus:outline-none focus:border-indigo-500/50" />
))}
{msg && {msg}}
); } /* ============================ PRICES ============================ */ function PricesPage() { // Прайсы per-currency: у каждой валюты свои прайсы («Гривны 1», «Гривны 2»…). // Базовый каждой валюты (id="base") = срез defaults (его читает бот сейчас). // sel = выбранный прайс {id, currency} либо {id:"__emoji__"} (общие эмодзи). const [variants, setVariants] = useStateM([]); // [{id,currency,label,base}] const [sel, setSel] = useStateM(null); const [editing, setEditing] = useStateM(null); // {id,currency,label} при переименовании const [vbusy, setVbusy] = useStateM(false); const [services, setServices] = useStateM([]); // услуги выбранной валюты const [emojis, setEmojis] = useStateM([]); // общие premium-эмодзи const [loading, setLoading] = useStateM(true); const ccyTabs = PRICE_TABS.filter((t) => t.id !== "emoji"); // uah/rub/usd/stars const isEmoji = !!(sel && sel.id === "__emoji__"); const loadVariants = React.useCallback(async () => { const list = await fetchPriceVariants(); setVariants(list); setSel((prev) => prev || (list[0] ? { id: list[0].id, currency: list[0].currency } : null)); }, []); useEffectM(() => { loadVariants(); }, []); // Эмодзи общие — грузим один раз. useEffectM(() => { fetchPrices(null, null).then((data) => { setEmojis(Array.isArray(data.emojis) ? data.emojis.map((e) => ({ symbol: e.token || e.symbol || "", id: e.customEmojiId || e.id || "", on: e.enabled !== false, })) : []); }); }, []); const reload = React.useCallback(async () => { if (!sel || sel.id === "__emoji__") { setLoading(false); return; } setLoading(true); const data = await fetchPrices(sel.id === "base" ? null : sel.id, sel.currency); setServices(Array.isArray(data.services) ? data.services : []); setLoading(false); }, [sel]); useEffectM(() => { let stopped = false; if (!sel || sel.id === "__emoji__") { setLoading(false); return; } setLoading(true); fetchPrices(sel.id === "base" ? null : sel.id, sel.currency).then((data) => { if (stopped) return; setServices(Array.isArray(data.services) ? data.services : []); setLoading(false); }); return () => { stopped = true; }; }, [sel]); const byCcy = {}; for (const v of variants) (byCcy[v.currency] = byCcy[v.currency] || []).push(v); const rowKey = (v) => v.currency + ":" + v.id; const handleAdd = async (ccy) => { setVbusy(true); try { const r = await createPriceVariant(ccy, ""); await loadVariants(); if (r && r.id) setSel({ id: r.id, currency: ccy }); } finally { setVbusy(false); } }; const handleDelete = async (v) => { setVbusy(true); try { await deletePriceVariant(v.id); if (sel && sel.id === v.id && sel.currency === v.currency) setSel({ id: "base", currency: v.currency }); await loadVariants(); } finally { setVbusy(false); } }; const commitRename = async () => { const ed = editing; setEditing(null); if (!ed) return; const trimmed = (ed.label || "").trim(); if (!trimmed) return; await renamePriceVariant(ed.id, trimmed, ed.currency); await loadVariants(); }; return (
{/* Список прайсов по валютам */}
{ccyTabs.map((m) => (
{m.symbol} {m.label}
{(byCcy[m.id] || []).map((v) => { const isSel = sel && !isEmoji && sel.id === v.id && sel.currency === v.currency; const isEd = editing && editing.id === v.id && editing.currency === v.currency; return (
{ if (!isEd) setSel({ id: v.id, currency: v.currency }); }} className={"group pl-4 pr-2 py-2 flex items-center gap-2 cursor-pointer transition-colors " + (isSel ? "bg-[rgba(231,220,196,0.55)]" : "hover:bg-black/[0.03]")} > {isEd ? ( setEditing({ ...editing, label: e.target.value })} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Enter") commitRename(); if (e.key === "Escape") setEditing(null); }} onBlur={commitRename} className="flex-1 min-w-0 bg-white/70 border border-black/15 rounded-lg px-2 py-1 text-sm outline-none" /> ) : (
{v.label} {v.base && бот}
)} {!isEd && (
e.stopPropagation()}> {!v.base && ( )}
)}
); })}
))} {/* Премиум-эмодзи (общие, не зависят от валюты) */}
setSel({ id: "__emoji__" })} className={"px-4 py-3 flex items-center gap-2 cursor-pointer transition-colors " + (isEmoji ? "bg-[rgba(231,220,196,0.55)]" : "hover:bg-black/[0.03]")}> Премиум-эмодзи общие
{/* Редактор выбранного прайса */}
{!sel && } {isEmoji && (
Общие для всех прайсов — подмена символов на кастом-эмодзи в витрине
{ await saveEmojis(emojis); }} />
)} {sel && !isEmoji && ( )}
); } // Редактор услуг ОДНОЙ валюты в прайсе: правим названия + суммы + заголовки групп. // serviceKey/serviceFamily не трогаем (контракт бота — матчинг/выдача). function ItemsEditor({ currency, services, variant, loading, onSaved }) { const meta = PRICE_TABS.find((t) => t.id === currency) || { symbol: "", label: currency }; const [draft, setDraft] = useStateM({}); // serviceKey -> {title?, amount?} const [headings, setHeadings] = useStateM({}); // origHeading -> newHeading const [saving, setSaving] = useStateM(false); const [msg, setMsg] = useStateM(""); const [pasteOpen, setPasteOpen] = useStateM(false); const [pasteText, setPasteText] = useStateM(""); const [importing, setImporting] = useStateM(false); const ordered = [...services].sort((a, b) => (a.order ?? 1e9) - (b.order ?? 1e9)); const groups = []; let cur = null; for (const s of ordered) { const h = s.heading || "—"; if (!cur || cur.h !== h) { cur = { h, items: [] }; groups.push(cur); } cur.items.push(s); } const tOf = (s) => (draft[s.serviceKey] && draft[s.serviceKey].title !== undefined ? draft[s.serviceKey].title : s.title); const aOf = (s) => (draft[s.serviceKey] && draft[s.serviceKey].amount !== undefined ? draft[s.serviceKey].amount : String(s.amount)); const hOf = (h) => (headings[h] !== undefined ? headings[h] : h); const setT = (sk, v) => setDraft({ ...draft, [sk]: { ...(draft[sk] || {}), title: v } }); const setA = (sk, v) => setDraft({ ...draft, [sk]: { ...(draft[sk] || {}), amount: v.replace(/[^0-9]/g, "") } }); const handleSave = async () => { setSaving(true); setMsg(""); try { const items = []; for (const s of ordered) { const it = { serviceKey: s.serviceKey }; let changed = false; const t = tOf(s); if (t !== s.title) { it.title = t; changed = true; } const a = parseInt(aOf(s), 10); if (Number.isFinite(a) && a > 0 && a !== s.amount) { it.amount = a; changed = true; } const h = hOf(s.heading || "—"); if (h !== (s.heading || "—") && h !== "—") { it.heading = h; changed = true; } if (changed) items.push(it); } if (!items.length) { setMsg("Нет изменений"); setSaving(false); setTimeout(() => setMsg(""), 3000); return; } const res = await savePriceItems(variant === "base" ? null : variant, currency, items); setMsg("Сохранено: " + (res?.updated ?? items.length)); setDraft({}); setHeadings({}); if (onSaved) await onSaved(); } catch (e) { setMsg("Ошибка: " + (e?.message || e)); } finally { setSaving(false); setTimeout(() => setMsg(""), 4000); } }; const handleImport = async () => { if (!pasteText.trim()) { setPasteOpen(false); return; } setImporting(true); try { const res = await importPrices(variant === "base" ? null : variant, currency, pasteText); setMsg("Разобрано услуг: " + (res?.count ?? "?")); setPasteText(""); setPasteOpen(false); if (onSaved) await onSaved(); } catch (e) { setMsg("Ошибка: " + (e?.message || e)); } finally { setImporting(false); setTimeout(() => setMsg(""), 4000); } }; return (
{meta.symbol}Услуги · {meta.label}
{services.length} услуг · правь названия и цены, или вставь готовый прайс
{msg && {msg}} {!pasteOpen && ( )}
{pasteOpen ? (
Вставь готовый прайс текстом (как в телеге). Система сама разберёт на услуги, цены и заголовки.
Услуги этой валюты в этом прайсе заменятся на разобранные.