// pages-comm.jsx — Chats + Checks (NEW)
const { useState: useStateC, useMemo: useMemoC, useRef: useRefC, useEffect: useEffectC } = React;
/* ============================
CHATS
============================ */
// PHASE D — визуальное выделение «тяжёлых» клиентов по кол-ву сообщений.
// Чем больше сожжено токенов/времени — тем заметнее. Без сложного дизайна.
function _heavyMessagesClass(n) {
const m = Number(n) || 0;
if (m >= 1000) return "bg-rose-500/20 text-rose-600 font-bold ring-1 ring-rose-500/40";
if (m >= 500) return "bg-orange-500/20 text-orange-700 font-semibold";
if (m >= 250) return "bg-amber-500/20 text-amber-600";
if (m >= 100) return "bg-yellow-500/15 text-yellow-700";
return "text-zinc-500";
}
function ChatsPage() {
// ВНИМАНИЕ ЗА РЕФАКТОРИНГОМ:
// Это первая страница мигрированная на runtime.js (Фаза 2).
// Раньше тут был useEffect+setInterval+stopped с ручным polling 20с.
// Теперь useQuery делает всё: dedupe, AbortController, polling через
// глобальный scheduler, visibility-pause. UI state (sort/filter/search/
// selected) остаётся локальным — это правильно, это user intent а не data.
const [sort, setSort] = useStateC("recent"); // recent (last_message) | messages
const [direction, setDirection] = useStateC("desc"); // desc (больше/новее сверху) | asc
const [filter, setFilter] = useStateC("all"); // all | blocked
const [search, setSearch] = useStateC("");
const [selected, setSelected] = useStateC(null);
const [busyBlock, setBusyBlock] = useStateC(null); // convId в процессе block/unblock
const scrollMemoryRef = useRefC(new Map());
// Фильтрация/сортировка/поиск на СЕРВЕРЕ (полный dataset). Сортировка — по
// времени или по кол-ву сообщений, направление desc/asc (toggle).
const serverSort = sort === "messages" ? "messages" : "last_message";
const blockedOnly = filter === "blocked";
const fetchChatsList = React.useCallback(
(signal) => fetchChats(500, {
signal, sort: serverSort, direction, blockedOnly, q: search,
}),
[serverSort, direction, blockedOnly, search]
);
const { data: chatsData, loading, error, refetch } = useQuery(
["chats", "list", serverSort, direction, filter, search],
fetchChatsList,
{ pollInterval: 3_000 } // лайв-список диалогов (было 20с)
);
const chats = chatsData || [];
// PHASE C/D — Block / Unblock без перезагрузки страницы.
const handleBlockToggle = React.useCallback(async (chat) => {
if (!chat || busyBlock) return;
setBusyBlock(chat.id);
try {
if (chat.blocked) await unblockChat(chat.id);
else await blockChat(chat.id);
await refetch(); // обновить актуальный blocked-статус
} catch (e) {
console.error("block toggle failed", e);
} finally {
setBusyBlock(null);
}
}, [busyBlock, refetch]);
// Sprint α — counts по всему dataset (не зависит от limit). Раньше
// «Всего чатов: 100» залипало потому что считалось как chats.length.
// Polling реже (30 сек) — totals меняются медленно.
const fetchCounts = React.useCallback(
(signal) => fetchChatsCounts({ signal }),
[]
);
const { data: countsData } = useQuery(
["chats", "counts"],
fetchCounts,
{ pollInterval: 30_000 }
);
// Auto-select первого чата при первой загрузке — раньше это было
// внутри refresh(). Теперь — отдельный эффект на смену списка.
React.useEffect(() => {
if (!selected && chats.length > 0) {
setSelected(chats[0].id);
}
}, [chats, selected]);
// Фильтрация/сортировка/поиск делаются на сервере (см. fetchChatsList).
const filtered = chats;
const stats = useMemoC(() => {
// Sprint α — для totalChats/withCheck/paid используем counts (по всему
// dataset, не по странице). totalMessages пока остаётся sum по странице
// потому что серверный endpoint counts его не считает (Sprint α scope —
// только counts чатов, не суммарные сообщения).
return {
totalMessages: chats.reduce((s, c) => s + c.messages, 0),
withCheck: countsData?.hasCheck ?? chats.filter((c) => c.hasCheck).length,
paid: countsData?.paid ?? chats.filter((c) => c.paid).length,
totalChats: countsData?.total ?? chats.length,
};
}, [chats, countsData]);
const current = chats.find((c) => c.id === selected);
return (
{/* Второй ряд — активность чатов по времени */}
setSearch(e.target.value)}
placeholder="Поиск: имя / username / id…"
className="w-full bg-white/50 border border-black/10 rounded-lg pl-9 pr-3 py-2 text-sm placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500/50"
/>
{/* Фильтр: все / заблокированные */}
{[
{ id: "all", label: "Все" },
{ id: "blocked", label: "Заблокированные" },
].map((f) => (
setFilter(f.id)}
className={"flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors " + (filter === f.id ? "bg-white/70 text-zinc-100 shadow-sm" : "text-zinc-500 hover:text-zinc-200")}
>
{f.label}
))}
{/* Сортировка: критерий + направление (toggle ↓/↑) */}
{[
{ id: "recent", label: "По времени" },
{ id: "messages", label: "По сообщениям" },
].map((o) => (
setSort(o.id)}
className={"flex-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors " + (sort === o.id ? "bg-indigo-500/80 text-white" : "text-zinc-500 hover:text-zinc-200")}
>
{o.label}
))}
setDirection(direction === "desc" ? "asc" : "desc")}
title={direction === "desc" ? "Сначала больше / новее" : "Сначала меньше / старее"}
className="px-3 py-1.5 rounded-lg bg-white/50 border border-black/10 hover:bg-white/70 text-zinc-300 text-xs font-medium flex items-center gap-1 shrink-0 whitespace-nowrap"
>
{direction === "desc" ? "↓ больше" : "↑ меньше"}
{loading && Array.from({ length: 8 }).map((_, i) => (
))}
{!loading && filtered.length === 0 && (
{chats.length === 0 ? "Диалогов пока нет" : "Ничего не найдено"}
)}
{!loading && filtered.map((c) => {
const isActive = selected === c.id;
return (
setSelected(c.id)}
title={(c.username ? "@" + String(c.username).replace(/^@/, "") + " · " : "") + "id " + c.clientId}
className={"relative w-full px-3.5 py-2.5 border-b border-black/[0.05] flex items-start gap-2.5 text-left cursor-pointer transition-colors " + (isActive ? "bg-[rgba(231,220,196,0.55)]" : "hover:bg-black/[0.03]") + (c.blocked ? " opacity-80" : "")}
>
{isActive &&
}
{c.name}
{c.blocked && (
Blocked
)}
{c.lastAt}
{c.preview}
{(c.account.name || "?").slice(0, 1).toUpperCase()}
{c.account.name}
{c.paid &&
оплачен }
{c.messages}
{ e.stopPropagation(); handleBlockToggle(c); }}
disabled={busyBlock === c.id}
className={"px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors disabled:opacity-50 border " + (c.blocked ? "bg-white/50 border-black/10 text-zinc-300 hover:bg-white/70" : "bg-rose-500/10 border-rose-500/30 text-rose-500 hover:bg-rose-500/20")}
>
{busyBlock === c.id ? "…" : (c.blocked ? "Unblock" : "Block")}
);
})}
{current ? (
<>
{current.name}
{current.username ? @{String(current.username).replace(/^@/, "")} : null}
через {current.account.name} · {current.messages} сообщений · id {current.clientId}
{current.hasCheck &&
чек}
{current.paid &&
оплачен }
>
) : (
Выберите чат слева
)}
);
}
function DialogBubble({ side, text, time, status, kind, mediaUrl, mediaCount, fileName }) {
const isRight = side === "right";
const isPhoto = kind === "photo";
const isVideo = kind === "video";
const isCircle = kind === "video_note";
const isVoice = kind === "voice";
const isDocument = kind === "document"; // Шаг 4: PDF / любой файл
const hasMedia = mediaUrl && (isPhoto || isVideo || isCircle || isDocument);
return (
{hasMedia && isPhoto && (
)}
{hasMedia && isVideo && (
)}
{hasMedia && isCircle && (
)}
{/* Шаг 4: document-bubble. Иконка + имя файла + клик-скачать. */}
{hasMedia && isDocument && (
{fileName || "файл"}
)}
{!hasMedia && (isPhoto || isVideo || isCircle) && (
{kind === "video_note" ? "🎥 кружок" : kind === "video" ? "🎬 видео" : "🖼 фото"}{mediaCount > 1 ? ` ×${mediaCount}` : ""} (недоступно)
)}
{!hasMedia && isDocument && (
{fileName || "файл"} (недоступно)
)}
{isVoice && (
{text || "голосовое"}
)}
{/* Шаг 3: text под медиа — caption клиента или сопровождение бота.
Для voice/document текст показывается внутри их собственных
блоков выше, тут пропускаем чтобы не дублировать. */}
{!isVoice && !isDocument && text &&
{text}
}
{time}
{isRight && status && (
{status === "read" ? (
) : (
)}
)}
);
}
/* ============================
Chat history generator + scroller
============================ */
// Tiny seeded RNG so each chat has stable history
function mulberry32(seed) {
return function () {
let t = (seed += 0x6D2B79F5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const CLIENT_LINES = [
"Привет 👋",
"Скинь прайс пожалуйста",
"А что есть из видео?",
"А виртик у тебя есть?",
"Сколько за 3 фото?",
"Можно набор 5+3?",
"А кружки делаешь?",
"Скинь реквизиты",
"Privat подойдёт?",
"Сбер можно?",
"Карту тинькоффа дашь?",
"USDT принимаешь?",
"Через Stars можно?",
"Сейчас оплачу",
"Оплатил, держи чек",
"Чек скинул, проверь пожалуйста",
"Получила?",
"Ну как там?",
"Долго ждать?",
"Спасибо ❤️",
"Огонь, ещё что-то есть?",
"А приват сколько стоит?",
"Можно фото без лица?",
"А с игрушкой видео есть?",
"Звонок 30 мин сколько?",
"Завтра напишу ещё",
"Ок, договорились",
"Спасибо, всё ок 🌸",
"Слушай, а ещё контент будет?",
"А скидку дашь если возьму набор?",
];
const SELLER_LINES = [
"Привет 💋",
"Скидываю прайс 🌸",
"Хорошо, сейчас отправлю реквизиты",
"Карта Privat: 4441 1110 6974 7248 — как оплатишь, кидай чек сюда 🌷",
"Сбер: 2202 2018 4421 9043, держатель Анна К.",
"Принимаю и USDT (TRC-20)",
"Принято, проверяю ⏳",
"Чек ок, отправляю 💧",
"Готово, держи 🌸",
"Спасибо солнышко ❤️",
"Конечно, заходи в любое время 💋",
"Сейчас сделаю и пришлю",
"Записала тебя",
"Ну ты огонь 🔥",
"Прайс актуален, скидывать?",
"Минимум 3 фото — 100₴",
"Набор 5+3 = 400₴, выгоднее",
"Кружки делаю, 150₴ за один",
"Скинула, посмотри",
"Спасибо, рада что понравилось 🌷",
"Виртик 30 мин — 400₴, переписка с фоточками",
"Приват навсегда 1200₴",
"Скидку могу дать на набор — 350 вместо 400",
];
function generateChatHistory(chat) {
const seed = chat.id.split("").reduce((s, c) => s + c.charCodeAt(0), 0);
const rnd = mulberry32(seed);
const totalMessages = Math.max(20, Math.min(80, chat.messages + 12 + Math.floor(rnd() * 20)));
const messages = [];
// Build messages spread across the last 5 days (most clustered today/yesterday)
let dayOffset = Math.min(4, Math.floor(rnd() * 4) + 1); // start N days ago
let hour = 9 + Math.floor(rnd() * 4);
let minute = Math.floor(rnd() * 60);
// Distribute msgs: 60% today, 25% yesterday, 15% earlier
for (let i = 0; i < totalMessages; i++) {
const remaining = totalMessages - i;
if (dayOffset > 0 && i / totalMessages > 1 - (dayOffset === 1 ? 0.35 : dayOffset === 2 ? 0.5 : 0.85)) {
// jump to next day, reset hour
dayOffset--;
hour = 9 + Math.floor(rnd() * 3);
minute = Math.floor(rnd() * 30);
}
// Advance time 1–25 minutes
minute += 1 + Math.floor(rnd() * 24);
while (minute >= 60) { minute -= 60; hour++; }
if (hour >= 24) { hour = 9; }
const isClient = i === 0 ? true : rnd() < 0.5;
const pool = isClient ? CLIENT_LINES : SELLER_LINES;
const text = pool[Math.floor(rnd() * pool.length)];
const time = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
// Inject a check image somewhere mid-conversation (~75% of the way)
const injectCheck = chat.hasCheck && i === Math.floor(totalMessages * 0.75) && isClient;
messages.push({
id: chat.id + "_m" + i,
side: isClient ? "left" : "right",
text,
time,
dayOffset,
kind: injectCheck ? "check" : "text",
status: !isClient ? (i < totalMessages - 2 ? "read" : "sent") : null,
});
}
return messages;
}
function formatDayLabel(offset) {
if (offset === 0) return "Сегодня";
if (offset === 1) return "Вчера";
const days = ["дн", "дн", "дн", "дн"];
const labels = ["2 дня назад", "3 дня назад", "4 дня назад", "5 дней назад"];
return labels[offset - 2] || `${offset} дней назад`;
}
/* ============================================================
* ChatScroll — Sprint β
* ============================================================
* Invariants (см. contract обсуждения β):
* 1. Anchor from bottom: distance до низа const при polling/prepend.
* 2. Merge by id everywhere — единый dedupe primitive.
* 3. Stable ordering: (ts ISO, idx) tiebreak.
* 4. Referential identity: одинаковые messages re-use existing object.
* 5. hasMore=false — final до chatId change (no polling storm).
* 6. loadingMore — hard lock от parallel prepend.
* 7. chatIdRef snapshot — stale responses ignored.
* 8. → re-anchor (capture phase listener).
* 9. scrollMemory map persists scroll position per chat for A→B→A.
* 10. UX continuity > freshness — все merges батчатся через
* useLayoutEffect ДО paint, юзер не видит "промежуточных" frame.
*/
// Sprint β — единая функция merge для всех путей (polling/prepend/initial).
// preserveExistingReferences: для каждого сообщения проверяем — если в
// existing уже есть с тем же id И поля shallow-equal → keep existing
// reference. Иначе берём incoming. Это критично для будущих reactions/edits.
function _shallowMessageEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
// Поля которые могут стабильно меняться: text, status, mediaUrl, kind, fileName
return (
a.id === b.id &&
a.side === b.side &&
a.text === b.text &&
a.time === b.time &&
a.kind === b.kind &&
a.status === b.status &&
a.mediaUrl === b.mediaUrl &&
a.mediaCount === b.mediaCount &&
a.fileName === b.fileName &&
a.ts === b.ts &&
a.idx === b.idx &&
a.dayOffset === b.dayOffset
);
}
function _mergeMessages(existing, incoming) {
// Map by id, primary order = incoming wins, identity preserved on equality.
const byId = new Map();
for (const m of existing) byId.set(m.id, m);
let changed = false;
for (const m of incoming) {
const prev = byId.get(m.id);
if (prev && _shallowMessageEqual(prev, m)) {
// re-use existing reference — referential identity preserved.
continue;
}
// Sprint β-fix #1: collision warning. Если id тот же но содержимое
// разное — это потенциальный data-loss bug (overwrite). После фикса
// backend id-генератора такого быть не должно. Если warning появится —
// значит мы поймали реальный edge case.
if (prev && !_shallowMessageEqual(prev, m)) {
console.warn(
"[merge collision] same id, different content — possible data loss",
{ id: m.id, prev, incoming: m }
);
if (typeof window !== "undefined" && window.__chatScrollStats) {
window.__chatScrollStats.collisions = (window.__chatScrollStats.collisions || 0) + 1;
}
}
byId.set(m.id, m);
changed = true;
}
if (!changed && byId.size === existing.length) return existing;
// Stable sort: ts primary (ISO string comparable), idx secondary.
const list = Array.from(byId.values());
list.sort((a, b) => {
const tA = a.ts || "";
const tB = b.ts || "";
if (tA !== tB) return tA < tB ? -1 : 1;
const iA = typeof a.idx === "number" ? a.idx : 0;
const iB = typeof b.idx === "number" ? b.idx : 0;
return iA - iB;
});
return list;
}
function ChatScroll({ chatId, scrollMemory }) {
const scrollRef = useRefC(null);
const [atBottom, setAtBottom] = useStateC(true);
// Накопленные сообщения (включая prepend через infinite scroll).
// useQuery даёт только initial 200; prepend добавляет через loadMore.
const [allMessages, setAllMessages] = useStateC([]);
const [hasMore, setHasMore] = useStateC(true);
const [loadingMore, setLoadingMore] = useStateC(false);
// Refs которые не триггерят re-render
const chatIdRef = useRefC(chatId);
const savedDistanceRef = useRefC(0); // distance from bottom — invariant для anchor
const atBottomRef = useRefC(true);
const loadingMoreRef = useRefC(false);
const hasMoreRef = useRefC(true);
const abortRef = useRefC(null);
const prevMessagesLenRef = useRefC(0); // для скана delta height
// HUD stats — глобальный bucket, читается debug-hud.jsx через polling.
// Никаких subscriptions, никаких React-renders по этим инкрементам.
if (typeof window !== "undefined" && !window.__chatScrollStats) {
window.__chatScrollStats = {
prepends: 0,
merges: 0,
scrollRestores: 0,
staleIgnored: 0,
mediaLoadRestores: 0,
currentChatId: null,
messagesCount: 0,
hasMore: true,
loadingMore: false,
atBottom: true,
oldestTs: null,
};
}
const statsBump = (key) => {
if (typeof window === "undefined" || !window.__chatScrollStats) return;
window.__chatScrollStats[key] = (window.__chatScrollStats[key] || 0) + 1;
};
const statsSet = (patch) => {
if (typeof window === "undefined" || !window.__chatScrollStats) return;
Object.assign(window.__chatScrollStats, patch);
};
// Initial load via useQuery (Sprint α infrastructure). При chatId change
// useQuery key меняется → новый fetch с AbortController, старый прерывается.
const fetcher = React.useCallback(
(signal) => fetchChatMessages(chatId, 200, { signal }),
[chatId]
);
const { data: initialData, loading: loadingMsgs } = useQuery(
["chat-messages", chatId],
fetcher,
{ pollInterval: 2_000, enabled: !!chatId } // лайв открытого чата (было 12с)
);
// При смене chatId — reset infinite-scroll state. Cache в useQuery для
// {chatId} остаётся (instant return при возврате на этот чат).
React.useEffect(() => {
chatIdRef.current = chatId;
setHasMore(true);
hasMoreRef.current = true;
setLoadingMore(false);
loadingMoreRef.current = false;
setAllMessages([]); // сбрасываем — initialData приедет следующим эффектом
if (abortRef.current) {
try { abortRef.current.abort(); } catch {}
abortRef.current = null;
}
}, [chatId]);
// Когда initialData (polling tick или first load) приходит — merge с
// существующим allMessages (там могут быть prepend-сообщения сверху).
// Это main entry для polling → merge → useLayoutEffect → anchor restore.
React.useEffect(() => {
if (!initialData) return;
setAllMessages((prev) => {
const merged = _mergeMessages(prev, initialData);
if (merged !== prev) statsBump("merges");
return merged;
});
}, [initialData]);
// ===== ANCHOR PRESERVATION =====
// useLayoutEffect выполняется после DOM mutation ДО paint. Юзер никогда
// не видит "scroll прыгнул". Алгоритм:
// - Если atBottom (юзер был на дне): scroll вниз → "новое сообщение
// появилось" feel.
// - Иначе: восстанавливаем distance от низа → scroll не сдвинется.
//
// Этот эффект триггерится на каждое allMessages change.
React.useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (atBottomRef.current) {
el.scrollTop = el.scrollHeight;
savedDistanceRef.current = 0;
} else {
// Restore distance from bottom — главный invariant.
const targetTop = el.scrollHeight - el.clientHeight - savedDistanceRef.current;
el.scrollTop = Math.max(0, targetTop);
}
statsBump("scrollRestores");
prevMessagesLenRef.current = allMessages.length;
statsSet({ messagesCount: allMessages.length, oldestTs: allMessages[0]?.ts || null });
}, [allMessages]);
// ===== INITIAL POSITIONING при смене chatId =====
// Если в scrollMemory есть сохранённая позиция → restore. Иначе → bottom.
React.useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
const saved = scrollMemory && scrollMemory.current
? scrollMemory.current.get(chatId)
: null;
// Откладываем до полного рендера сообщений (через rAF чтобы DOM измерения
// были после layout)
const jump = () => {
if (!scrollRef.current || chatIdRef.current !== chatId) return;
if (saved && saved.atBottom === false) {
// restore exact scroll position
scrollRef.current.scrollTop = saved.scrollTop;
atBottomRef.current = false;
setAtBottom(false);
savedDistanceRef.current =
scrollRef.current.scrollHeight -
scrollRef.current.clientHeight -
scrollRef.current.scrollTop;
} else {
// default: bottom
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
atBottomRef.current = true;
setAtBottom(true);
savedDistanceRef.current = 0;
}
};
jump();
const raf1 = requestAnimationFrame(jump);
const raf2 = requestAnimationFrame(() => requestAnimationFrame(jump));
const t = setTimeout(jump, 80);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
clearTimeout(t);
// Save position для A→B→A — пишем в memory map.
const el2 = scrollRef.current;
if (el2 && scrollMemory && scrollMemory.current) {
scrollMemory.current.set(chatId, {
scrollTop: el2.scrollTop,
atBottom: atBottomRef.current,
});
}
};
}, [chatId]);
// ===== SCROLL HANDLER =====
// Дешёвый — refs только, setState только при пересечении atBottom threshold.
// Триггерит infinite-scroll загрузку при scrollTop < 200 (если hasMore).
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const dist = el.scrollHeight - el.clientHeight - el.scrollTop;
savedDistanceRef.current = dist;
const isBottom = dist < 40;
if (isBottom !== atBottomRef.current) {
atBottomRef.current = isBottom;
setAtBottom(isBottom);
statsSet({ atBottom: isBottom });
}
// Infinite scroll trigger
if (
el.scrollTop < 200
&& hasMoreRef.current
&& !loadingMoreRef.current
&& allMessages.length > 0
) {
loadMore();
}
};
el.addEventListener("scroll", onScroll);
return () => el.removeEventListener("scroll", onScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allMessages.length, chatId]);
// ===== INFINITE SCROLL PREPEND =====
const loadMore = React.useCallback(async () => {
if (loadingMoreRef.current || !hasMoreRef.current) return;
if (allMessages.length === 0) return;
const oldest = allMessages[0];
if (!oldest || !oldest.ts) return;
loadingMoreRef.current = true;
setLoadingMore(true);
statsSet({ loadingMore: true });
const snapshot = chatIdRef.current;
const controller = new AbortController();
abortRef.current = controller;
try {
const older = await fetchChatMessages(chatId, 200, {
before: oldest.ts,
signal: controller.signal,
});
// Stale response check (юзер ушёл в другой чат пока ждали)
if (chatIdRef.current !== snapshot) {
statsBump("staleIgnored");
return;
}
if (!older || older.length === 0) {
hasMoreRef.current = false;
setHasMore(false);
statsSet({ hasMore: false });
return;
}
// Если бэк вернул МЕНЬШЕ limit — это последняя страница.
if (older.length < 200) {
hasMoreRef.current = false;
setHasMore(false);
statsSet({ hasMore: false });
}
// Single merge primitive — то же что для polling.
setAllMessages((prev) => {
const merged = _mergeMessages(prev, older);
if (merged !== prev) {
statsBump("merges");
statsBump("prepends");
}
return merged;
});
} catch (e) {
if (e && e.name === "AbortError") return;
console.error("loadMore failed", e);
} finally {
loadingMoreRef.current = false;
setLoadingMore(false);
statsSet({ loadingMore: false });
if (abortRef.current === controller) abortRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allMessages, chatId]);
// ===== MEDIA onLOAD — re-anchor =====
// Когда / доgružает контент, scrollHeight меняется ПОСЛЕ
// нашего useLayoutEffect. Слушаем load в capture phase — он bubbles от
// любого / внутри scroll-контейнера. Если юзер не atBottom
// и distance был сохранён — восстанавливаем.
React.useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onLoad = (e) => {
const tag = e.target && e.target.tagName;
if (tag !== "IMG" && tag !== "VIDEO") return;
if (atBottomRef.current) {
el.scrollTop = el.scrollHeight;
} else {
const target = el.scrollHeight - el.clientHeight - savedDistanceRef.current;
el.scrollTop = Math.max(0, target);
}
statsBump("mediaLoadRestores");
};
el.addEventListener("load", onLoad, true); // capture
return () => el.removeEventListener("load", onLoad, true);
}, []);
// ===== SCROLL TO BOTTOM (FAB button) =====
const scrollToBottom = () => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
};
// Sprint β — update HUD stats for current chat
React.useEffect(() => {
statsSet({
currentChatId: chatId,
messagesCount: allMessages.length,
hasMore,
loadingMore,
atBottom,
oldestTs: allMessages[0]?.ts || null,
});
}, [chatId, allMessages.length, hasMore, loadingMore, atBottom]);
// Build rendered items with day separators
const rendered = [];
let lastDay = null;
for (const m of allMessages) {
if (m.dayOffset !== lastDay) {
rendered.push({ kind: "day", id: "day-" + m.dayOffset, label: formatDayLabel(m.dayOffset) });
lastDay = m.dayOffset;
}
rendered.push(m);
}
// ref alias для совместимости с rendering ниже
const ref = scrollRef;
const messages = allMessages;
return (
{/* Sprint β — Infinite scroll indicator. Появляется когда юзер
прокрутил наверх и фоном грузятся старые сообщения. Тонкий
ободок, не отвлекающий. */}
{loadingMore && (
Загружаю старые сообщения…
)}
{/* Sprint β — индикатор «больше нечего грузить» (final stop) */}
{!hasMore && allMessages.length >= 200 && (
)}
{loadingMsgs && Array.from({ length: 6 }).map((_, i) => (
))}
{!loadingMsgs && rendered.length === 0 && (
Сообщений пока нет
)}
{!loadingMsgs && rendered.map((it) => {
if (it.kind === "day") {
return (
);
}
if (it.kind === "check") {
return
;
}
return (
);
})}
{/* Scroll-to-bottom FAB */}
);
}
function CheckBubble({ chat, time }) {
// A receipt image preview from the client
return (
);
}
function ChatComposer() {
// ВАЖНО: ручная отправка пока не реализована. Раньше send() просто
// очищал textarea (silent fail — юзер думал что отправилось). Сейчас
// composer полностью disabled с явным объяснением — никаких ложных
// сигналов что Enter что-то сделает.
const tooltip = "Ручная отправка пока не реализована. Бот сам отвечает клиентам через LLM. Чтобы вмешаться — пиши клиенту со своего Telegram напрямую.";
return (
);
}
/* ============================
CHECKS — NEW
============================ */
function CurrencySymbol({ c }) {
return <>{c === "RUB" ? "₽" : c === "UAH" ? "₴" : c === "USDT" ? "$" : c === "STARS" ? "⭐" : c}>;
}
// Дата → "YYYY-MM-DD" в локальном времени (для input[type=date] и пресетов).
function _isoDate(d) {
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
// Русская плюрализация: _plural(2, "чек", "чека", "чеков") → "чека".
function _plural(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
function ChecksPage() {
const [checks, setChecks] = useStateC([]);
const [loading, setLoading] = useStateC(true);
const [status, setStatus] = useStateC("all");
const [lightbox, setLightbox] = useStateC(null);
// Период по умолчанию — последние 30 дней (скользящее окно).
const [from, setFrom] = useStateC(() => { const d = new Date(); d.setDate(d.getDate() - 29); return _isoDate(d); });
const [to, setTo] = useStateC(() => _isoDate(new Date()));
// Тянем ВСЕ чеки, фильтруем по статусу+периоду на клиенте. Polling 20с.
useEffectC(() => {
let stopped = false;
const refresh = async () => {
const data = await fetchReceipts("all", 3000);
if (stopped) return;
setChecks(data);
setLoading(false);
};
refresh();
const id = setInterval(refresh, 45_000);
return () => { stopped = true; clearInterval(id); };
}, []);
const filtered = useMemoC(() => {
const fromT = from ? new Date(from + "T00:00:00").getTime() : -Infinity;
const toT = to ? new Date(to + "T23:59:59").getTime() : Infinity;
return checks.filter((c) => {
if (status !== "all" && c.status !== status) return false;
const t = c.receivedAt ? new Date(c.receivedAt).getTime() : NaN;
if (!isNaN(t) && (t < fromT || t > toT)) return false;
return true;
}).sort((a, b) => ((new Date(b.receivedAt).getTime() || 0) - (new Date(a.receivedAt).getTime() || 0)));
}, [checks, status, from, to]);
const summary = useMemoC(() => {
// Суммируем только распознанные суммы; чеки без суммы/валюты (UNKNOWN) в
// итог не идут, но в счётчик count входят.
const byCcy = {};
for (const c of filtered) {
const ccy = (c.currency || "").toUpperCase();
if (!ccy || ccy === "UNKNOWN" || !c.amount) continue;
byCcy[ccy] = (byCcy[ccy] || 0) + c.amount;
}
return { count: filtered.length, byCcy };
}, [filtered]);
const setPreset = (kind) => {
const now = new Date();
if (kind === "today") { setFrom(_isoDate(now)); setTo(_isoDate(now)); }
else if (kind === "week") { const s = new Date(now); s.setDate(s.getDate() - 6); setFrom(_isoDate(s)); setTo(_isoDate(now)); }
else if (kind === "month") { const s = new Date(now); s.setDate(s.getDate() - 29); setFrom(_isoDate(s)); setTo(_isoDate(now)); }
};
const statusWord = (s) => (s === "confirmed" ? "подтверждён" : s === "pending" ? "на проверке" : "отклонён");
const dotClass = (s) => (s === "confirmed" ? "bg-emerald-400" : s === "pending" ? "bg-amber-400" : "bg-rose-400");
return (
{summary.count} {_plural(summary.count, "чек", "чека", "чеков")} за период
{Object.entries(summary.byCcy).map(([ccy, sum]) => (
· {sum.toLocaleString("ru")}
))}
{loading && Загрузка…
}
{!loading && filtered.length === 0 && (
)}
{!loading && filtered.map((c, idx) => (
setLightbox(c)}
className={"flex items-center gap-4 px-4 py-3.5 cursor-pointer transition-colors hover:bg-black/[0.03]" + (idx < filtered.length - 1 ? " border-b border-black/[0.055]" : "")}>
{c.account.name}
владелец: {c.ownerName || "—"}
{c.at || "—"}
{c.method} · {c.isFake ? "фейк" : statusWord(c.status)}{c.status === "confirmed" && c.delivered ? " · выдан" : ""}
{c.amount && c.currency && c.currency !== "UNKNOWN"
?
{c.amount.toLocaleString("ru")}
:
сумма не распознана
}
{c.hasPhoto ? (
) : (
нет фото
)}
{c.hasPhoto &&
}
))}
{lightbox &&
setLightbox(null)} />}
);
}
function CheckLightbox({ check, onClose }) {
useEffectC(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const sWord = check.isFake ? "фейк" : check.status === "confirmed" ? "подтверждён" : check.status === "pending" ? "на проверке" : "отклонён";
const sBadge = check.status === "confirmed" ? "bg-emerald-400/15 text-emerald-600" : check.status === "pending" ? "bg-amber-400/15 text-amber-600" : "bg-rose-400/15 text-rose-600";
const sDot = check.status === "confirmed" ? "bg-emerald-500" : check.status === "pending" ? "bg-amber-500" : "bg-rose-500";
const R = ({ label, children }) => (
{label}
{children}
);
return (
e.stopPropagation()} className="glass rounded-3xl w-full max-w-3xl max-h-[88vh] overflow-hidden flex flex-col md:flex-row">
{check.hasPhoto ? (
) : (
)}
{check.amount && check.currency && check.currency !== "UNKNOWN" ? <>{check.amount.toLocaleString("ru")} > : сумма не распознана }
{check.method}
{sWord}
{check.account.name}
{check.ownerName || "—"}
{check.client}{check.clientUsername ? " · @" + check.clientUsername : ""}
{check.at || "—"}
{check.summary && (
AI-распознавание
{check.summary}
)}
);
}
function ReceiptActions({ checkId, onReviewed }) {
// Кнопки «Подтвердить» / «Отклонить» с inline-индикатором.
// После успеха — СРАЗУ дёргаем onReviewed(checkId, newStatus) чтобы
// родительский ChecksPage обновил локальный state БЕЗ ожидания 20-сек
// polling-а. Раньше тут был silent-fail ощущение: «нажал — ничего не
// изменилось» 20 секунд.
const [busy, setBusy] = useStateC(""); // "approve" | "reject" | ""
const [err, setErr] = useStateC("");
const action = async (kind) => {
setBusy(kind);
setErr("");
try {
if (kind === "approve") await approveReceipt(checkId);
else await rejectReceipt(checkId);
// Optimistic + refetch — статус в UI обновится мгновенно
if (onReviewed) {
try {
await onReviewed(checkId, kind === "approve" ? "confirmed" : "rejected");
} catch {}
}
} catch (e) {
setErr(String(e.message || e).slice(0, 100));
} finally {
setBusy("");
}
};
return (
action("reject")}
disabled={!!busy}
className="flex-1 flex items-center justify-center gap-2 px-3.5 py-2.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-400 hover:bg-rose-500/20 disabled:opacity-50 text-sm font-medium"
>
{busy === "reject" ? "Отклоняю…" : "Отклонить"}
action("approve")}
disabled={!!busy}
className="flex-1 flex items-center justify-center gap-2 px-3.5 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium"
>
{busy === "approve" ? "Подтверждаю…" : "Подтвердить"}
{err && (
{err}
)}
);
}
Object.assign(window, { ChatsPage, ChecksPage });