NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name ACID CW PERKS
// @namespace http://tampermonkey.net/
// @version 3.92
// @description CWP ACID perks with OOP, Settings and Ticket Tracker1
// @author Denmar
// @license MIT
// @match *://cw.echelon.su/*
// @match *://cw2.echelon.su/*
// @match *://echelon.su/api/dashboard/*
// @match *://adm2.echelon.su/*
// @updateURL https://openuserjs.org/meta/Denmar/ACID_CW_PERKS.meta.js
// @downloadURL https://openuserjs.org/install/Denmar/ACID_CW_PERKS.user.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_notification
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// Айфрейм "Рабочая панель" (adm2.echelon.su) — отдельный документ, чужой origin,
// поэтому это не метод AcidPerks, а самостоятельный скрипт, выполняющийся прямо внутри
// самого iframe. Chatwoot не пересоздаёт iframe при переключении вкладок "Сообщения" /
// "Рабочая панель", а только показывает/прячет его через CSS — значит наблюдатель,
// однажды запущенный при загрузке iframe, сам ловит все дальнейшие обновления данных
// (новые строки логов и т.п.) без необходимости отдельно слушать клик по кнопке.
function initDashboardMskConverter() {
const TIME_RE = /^([0-2]\d):([0-5]\d):([0-5]\d)$/;
const MSK_OFFSET_HOURS = 3;
function convertSpan(span) {
const m = TIME_RE.exec(span.textContent.trim());
if (!m) return;
const hh = String((parseInt(m[1], 10) + MSK_OFFSET_HOURS) % 24).padStart(2, '0');
span.dataset.acidMskDone = '1';
span.style.display = 'inline-flex';
span.style.flexDirection = 'column';
span.style.lineHeight = '1.15';
span.textContent = '';
const line1 = document.createElement('span');
line1.textContent = `${hh}:${m[2]}:${m[3]}`;
const line2 = document.createElement('span');
line2.textContent = 'мск';
line2.style.fontSize = '1em';
line2.style.opacity = '0.55';
span.appendChild(line1);
span.appendChild(line2);
}
function scan(root) {
const scope = root || document;
const candidates = scope.matches && scope.matches('span') ? [scope] : [];
candidates.push(...scope.querySelectorAll('span'));
candidates.forEach(span => {
if (span.children.length > 0) return;
if (span.closest('[data-acid-msk-done]')) return;
convertSpan(span);
});
}
const start = () => {
scan();
new MutationObserver(mutations => {
for (const mut of mutations) {
mut.addedNodes.forEach(node => {
if (node.nodeType === 1) scan(node);
});
}
}).observe(document.body, {
childList: true,
subtree: true
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
}
// Вкладка "Профиль" рабочей панели (adm2.echelon.su) рендерит карты клиента как
// унылые строки на всю ширину. Мы перехватываем ответ её собственного XHR/fetch
// на /api/dashboard/profile/cards (тот же приём, что уже даёт данные тикета) и
// поверх существующих React-строк рисуем свою карточку. Сами строки-элементы не
// трогаем и не пересоздаём (см. предупреждение про onCommitFiberUnmount/removeChild
// на скриншоте — вставка/удаление чужих узлов внутри React-поддерева может уронить
// reconciliation), только прячем их исходное содержимое через CSS (!important поверх
// инлайновых стилей) и добавляем свой оверлей отдельным дочерним узлом — клик по нему
// всплывает к самому React-обработчику строки, поэтому переход в расширённую карточку
// продолжает работать как раньше.
function initDashboardCardsPanel() {
// product_id -> цена продукта в $. API этого не возвращает, список известных id
// поддерживается вручную по мере появления новых продуктов.
const PRODUCT_PRICES = {
6: [8, "Стандартная"],
12: [15, "Эстонская"],
15: [30, "Эплпей Сингапур"],
4: [30, "Эплпей Гонконг"],
};
function productLabel(productId) {
const price = PRODUCT_PRICES[productId];
return price != null ? `п${productId} | ${price[0]}$ (${price[1]})` : `п${productId}`;
}
function networkClass(network) {
const n = (network || '').toUpperCase();
if (n === 'VISA') return 'acid-net-visa';
if (n.includes('MASTER') || n === 'MC') return 'acid-net-mc';
return 'acid-net-other';
}
function cardStatusInfo(card) {
const status = (card.status || '').toUpperCase();
if (card.closed_at || status === 'CLOSED') return { key: 'closed', label: 'Закрыта' };
if (card.close_requested_at) return { key: 'closing', label: 'В закрытии' };
if (status.includes('INACTIVE')) return { key: 'frozen', label: 'Заморожена' };
return { key: 'active', label: 'Активна' };
}
function formatCardDate(raw) {
if (!raw) return '—';
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
if (!m) return raw;
return `${m[3]}.${m[2]}.${m[1].slice(2)}`;
}
function formatPan(card) {
if (card.pan) return card.pan.replace(/(.{4})/g, '$1 ').trim();
return `•••• •••• •••• ${card.l4 || '????'}`;
}
const MASK_RE = /^[·•]{2,}\d{3,4}$/;
function findMaskSpan(el) {
return Array.from(el.querySelectorAll('span')).find(s => MASK_RE.test((s.textContent || '').trim()));
}
function extractL4(el) {
const span = findMaskSpan(el);
if (!span) return null;
const m = /(\d{3,4})$/.exec(span.textContent.trim());
return m ? m[1] : null;
}
// #profile-root содержит несколько .no-sb (сайдбар профиля, полоска фильтров,
// список карт) — позиция среди них не гарантирована (зависит от активной под-вкладки
// Карты/Stars/eSIM), поэтому ищем контейнер списка карт по содержимому: он либо
// содержит строку с замаскированным PAN, либо пустой текст "Карт не найдено".
function findCardsListContainer(pane) {
const candidates = pane.querySelectorAll('.no-sb');
for (const el of candidates) {
if (findMaskSpan(el) || /карт\s*не\s*найдено/i.test(el.textContent || '')) return el;
}
return null;
}
function findCardRows(pane) {
const listWrap = findCardsListContainer(pane);
if (!listWrap || !listWrap.firstElementChild) return [];
const grid = listWrap.firstElementChild;
const candidates = Array.from(grid.children).filter(el => el.nodeType === 1);
const rows = candidates.filter(findMaskSpan);
if (!rows.length) return [];
grid.classList.add('acid-cards-grid');
rows.forEach(row => {
row.dataset.acidL4 = extractL4(row) || '';
});
return rows;
}
function matchCardsToRows(rows, cards) {
const byL4 = new Map();
cards.forEach(c => {
const l4 = (c.l4 || (c.pan || '').slice(-4) || '').toString();
if (l4 && !byL4.has(l4)) byL4.set(l4, c);
});
const sameLength = rows.length === cards.length;
return rows.map((row, idx) => {
const l4 = row.dataset.acidL4;
if (l4 && byL4.has(l4)) return byL4.get(l4);
return sameLength ? cards[idx] : null;
});
}
function networkLabel(network) {
const n = (network || '').toUpperCase();
if (n === 'VISA') return 'VISA';
if (n.includes('MASTER') || n === 'MC') return 'MASTERCARD';
return n || '—';
}
function buildOverlay(card) {
const status = cardStatusInfo(card);
const overlay = document.createElement('div');
overlay.className = 'acid-card-overlay';
overlay.innerHTML = `
<div class="acid-card-top">
<span class="acid-card-status st-${status.key}">${status.label}</span>
<span class="acid-card-hidden" style="color:${card.hidden ? '#f87171' : 'rgba(255,255,255,.55)'}">${card.hidden ? '🙈 Скрыта' : '👁 Видна'}</span>
</div>
<div class="acid-card-mid">
<div class="acid-card-pan">${formatPan(card)}</div>
<div class="acid-card-id-row">
<span>ID ${card.id ?? '—'}</span>
<span class="acid-card-product">${productLabel(card.product_id)}</span>
</div>
</div>
<div class="acid-card-bottom">
<span class="acid-card-created">${formatCardDate(card.created_at)} · ${networkLabel(card.network)}</span>
<span class="acid-card-balance">$${Number(card.balance || 0).toFixed(2)}</span>
</div>
`;
return overlay;
}
function decorateRow(row, card) {
const fp = [card.id, card.balance, card.hidden, card.status, card.close_requested_at, card.closed_at].join('|');
if (row.dataset.acidCardFp === fp) return;
row.dataset.acidCardFp = fp;
row.querySelectorAll(':scope > .acid-card-overlay').forEach(n => n.remove());
row.classList.remove('acid-net-visa', 'acid-net-mc', 'acid-net-other');
row.classList.add('acid-card', networkClass(card.network));
row.appendChild(buildOverlay(card));
}
// Сайдбар профиля (имя, баланс, контакты, аккаунт) — тот .no-sb, что содержит h1
// с именем клиента; этим он однозначно отличается от списка карт и от полоски фильтров.
function findProfileSidebar(pane) {
const candidates = pane.querySelectorAll('.no-sb');
for (const el of candidates) {
if (el.querySelector('h1')) return el;
}
return null;
}
function formatRegistrationDate(raw) {
if (!raw) return null;
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(raw);
if (!m) return null;
const [, year, month, day, hour, minute] = m;
const date = `${String(Number(day)).padStart(2, '0')}.${String(Number(month)).padStart(2, '0')}.${year}`;
const time = `${Number(hour)}:${minute}`;
return `Регистрация: ${date} в ${time} (+0)`;
}
function ensureSettingsHint(pane, user) {
const sidebar = findProfileSidebar(pane);
if (!sidebar || !sidebar.firstElementChild) return;
const wrapper = sidebar.firstElementChild;
const text = user && user.created_at ? formatRegistrationDate(user.created_at) : null;
if (!text) return;
let hint = wrapper.querySelector('.acid-cards-hint');
if (!hint) {
hint = document.createElement('div');
hint.className = 'acid-cards-hint';
wrapper.appendChild(hint);
}
if (hint.textContent !== text) {
hint.textContent = text;
}
}
let lastCards = null;
let lastUserProfile = null;
let renderScheduled = false;
function renderCardsUI() {
const activeTab = document.querySelector('.shell-tab.active[data-tab="profile"]');
if (!activeTab) return;
const pane = document.getElementById('profile-root') || document.querySelector('.tab-pane.active');
if (!pane) return;
if (pane.dataset.acidRenderBusy === '1') return;
pane.dataset.acidRenderBusy = '1';
try {
ensureSettingsHint(pane, lastUserProfile);
if (!lastCards) return;
const rows = findCardRows(pane);
if (!rows.length) return;
const matched = matchCardsToRows(rows, lastCards);
rows.forEach((row, i) => {
if (matched[i]) decorateRow(row, matched[i]);
});
} finally {
pane.dataset.acidRenderBusy = '0';
}
}
function scheduleRender() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
renderCardsUI();
});
}
function setupNetworkHook() {
const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const handle = (url, json) => {
if (!url) return;
if (url.includes('/api/dashboard/profile/cards')) {
if (!Array.isArray(json)) return;
lastCards = json;
scheduleRender();
return;
}
if (url.includes('/api/dashboard/profile/user')) {
if (!json || typeof json !== 'object') return;
lastUserProfile = json;
scheduleRender();
}
};
const originalFetch = w.fetch;
if (originalFetch) {
w.fetch = function (input, init) {
const promise = originalFetch.apply(this, arguments);
try {
const url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.includes('/api/dashboard/profile/cards') || url.includes('/api/dashboard/profile/user')) {
promise.then(res => {
if (!res.ok) return;
res.clone().json().then(json => handle(url, json)).catch(() => {});
}).catch(() => {});
}
} catch (e) {}
return promise;
};
}
const originalSend = w.XMLHttpRequest.prototype.send;
w.XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
try {
if (this.responseURL && (this.responseURL.includes('/api/dashboard/profile/cards') || this.responseURL.includes('/api/dashboard/profile/user'))) {
handle(this.responseURL, JSON.parse(this.responseText));
}
} catch (e) {}
});
return originalSend.apply(this, args);
};
}
function injectCardStyles() {
const style = document.createElement('style');
style.textContent = `
/* Раньше размеры текста были в px, а сама карта — в % от ширины родителя
(которая зависит от ширины окна/монитора): на широком мониторе карта
растягивалась, а шрифт оставался тем же фиксированным размером, и наоборот
на узком. Контейнерные единицы (cqw) считаются от ширины САМОЙ карты, а не
от вьюпорта, поэтому при неизменном aspect-ratio пропорции текста внутри
карты одинаковы на любом мониторе — большая карта просто одинаково
масштабируется целиком, а не "большая карта + мелкий текст". */
.acid-cards-grid {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.acid-card {
display: block !important;
position: relative !important;
width: 100% !important;
aspect-ratio: 1.586 / 1;
margin: 0 !important;
padding: 0 !important;
border-radius: 1.8cqw !important;
border-left: none !important;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
container-type: inline-size;
}
/* :last-child ломается сам собой, как только мы дописываем оверлей третьим
ребёнком — он и становится "последним", а исходный span с балансом больше не
матчится и остаётся видимым. Прячем всё, что не наш оверлей, явно по классу. */
.acid-card > *:not(.acid-card-overlay) { display: none !important; }
/* Градиент — на самом оверлее, а не на .acid-card: у строки-родителя есть
инлайновый style="background: transparent" из React-разметки, а инлайн-стиль
перебивает любое правило класса без !important. */
.acid-net-visa .acid-card-overlay { background: linear-gradient(135deg, #0d1b4c 0%, #142d7a 55%, #0a1230 100%); }
.acid-net-mc .acid-card-overlay { background: linear-gradient(135deg, #eb001b 0%, #ff5f00 55%, #b8250f 100%); }
.acid-net-other .acid-card-overlay { background: linear-gradient(135deg, #3a3f47 0%, #23262b 100%); }
.acid-card-overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column; justify-content: space-between;
padding: 3cqw 4cqw; box-sizing: border-box;
font-family: "SF Mono", "JetBrains Mono", Consolas, monospace;
color: #fff;
text-shadow: 0 0.2cqw 0.5cqw rgba(0,0,0,0.25);
}
.acid-card-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 2cqw; }
.acid-card-status { font-size: 3cqw; font-weight: 700; padding: 0.5cqw 2cqw; border-radius: 1.5cqw; background: rgba(0,0,0,0.25); letter-spacing: 0.02em; white-space: nowrap; }
.acid-card-status.st-active { color: #4ade80; }
.acid-card-status.st-frozen { color: #38bdf8; }
.acid-card-status.st-closed { color: #f87171; }
.acid-card-status.st-closing { color: #facc15; }
.acid-card-hidden { font-size: 3cqw; font-weight: 600; white-space: nowrap; }
.acid-card-mid { display: flex; flex-direction: column; gap: 1.3cqw; }
.acid-card-pan { font-size: 4.5cqw; font-weight: 700; letter-spacing: 0.03em; }
.acid-card-id-row { display: flex; align-items: center; gap: 1.7cqw; font-size: 3.2cqw; opacity: 0.85; }
.acid-card-product { background: rgba(0,0,0,0.25); padding: 0.2cqw 1.7cqw; border-radius: 1.3cqw; font-weight: 600; }
.acid-card-bottom { display: flex; justify-content: space-between; align-items: flex-end; }
.acid-card-created { font-size: 2.9cqw; opacity: 0.8; }
.acid-card-balance { font-size: 6cqw; font-weight: 800; }
.acid-cards-hint {
margin-top: 12px;
padding: 7px 10px;
border-radius: 7px;
background: rgba(95, 107, 122, 0.12);
border: 1px solid rgba(154, 166, 179, 0.18);
color: rgba(217, 221, 228, 0.8);
font-size: 11px;
line-height: 1.4;
letter-spacing: 0.01em;
opacity: 0.9;
}
`;
document.head.appendChild(style);
}
function startDomWatch() {
injectCardStyles();
scheduleRender();
const observer = new MutationObserver(() => scheduleRender());
observer.observe(document.body, {
childList: true,
subtree: true
});
document.addEventListener('click', (e) => {
const btn = e.target.closest && e.target.closest('.shell-tab[data-tab="profile"]');
if (btn) setTimeout(scheduleRender, 50);
});
}
setupNetworkHook();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startDomWatch);
} else {
startDomWatch();
}
}
// Настройки хранятся через GM_setValue/GM_getValue, а не localStorage: последний
// изолирован по origin, и adm2.echelon.su (другой домен, где живёт iframe) не увидел бы
// localStorage, записанный на cw.echelon.su. GM-хранилище общее для всего скрипта.
function loadSharedSettings(defaults) {
let parsed = {};
try {
parsed = JSON.parse(GM_getValue('acidSettings', '{}'));
} catch (e) {}
return {
...defaults,
...parsed
};
}
if (location.hostname === 'adm2.echelon.su') {
const settings = loadSharedSettings({
mskConverter: true,
cardsPanel: true
});
if (settings.mskConverter) initDashboardMskConverter();
if (settings.cardsPanel) initDashboardCardsPanel();
return;
}
class AcidPerks {
constructor() {
this.defaultSettings = {
ticketTracker: true,
addressPanel: true,
customHeader: true,
rightPanelStyle: true,
mskConverter: true,
cardsPanel: true,
customValue: ''
};
this.settings = this.loadSettings();
this.intervals = {};
this.observers = {};
this.currentChatData = null;
// Гео-данные (bbox, город, размер пула) больше не нужны на клиенте — этим занимается
// сервер (server/common.php + server/update.php). Здесь остаётся только то, что нужно
// самому UI: подпись/флаг/поиск по алиасам и генерация имени по региону.
this.addressRegions = {
"SG": {
name: "Singapore",
label: "Сингапур",
flag: "🇸🇬",
favorite: true,
aliases: ["singapore", "sg", "сингапур"],
names: ["Wei", "Jian", "Sarah", "Michael", "Chloe", "David", "Xin", "Lucas", "Emma", "Jun", "Ming", "Li", "Yan", "Hong", "Feng", "Ryan", "Rachel", "Ethan", "Grace", "Noah", "Olivia", "Matthew", "Sophia", "Benjamin", "Isabella", "Ahmad", "Siti", "Priya", "Arjun", "Wen", "Kai", "Jing", "Heai", "Xuan", "Zhi", "Yong", "Hwee", "Choon", "Wan", "Hafiz", "Aisyah", "Nur", "Farah", "Zulkifli", "Rashid", "Kumar", "Raj", "Meera", "Deepa", "Karthik", "Vikram", "Anand", "James", "Daniel", "Nicole", "Amanda", "Jasmine"],
surnames: ["Tan", "Lim", "Lee", "Ng", "Ong", "Wong", "Goh", "Chua", "Chan", "Koh", "Teo", "Yeo", "Loh", "Sim", "Wee", "Foo", "Yap", "Heng", "Low", "Chew", "Pang", "Seet", "Kee", "Ho", "Liang", "Phua", "Tay", "Yip", "Lam", "Kwan", "Neo", "Toh", "Poh", "Boon", "Soh", "Ang", "Cheong", "Quek", "Aw", "Ling"]
},
"HK": {
name: "Hong Kong",
label: "Гонконг",
flag: "🇭🇰",
favorite: true,
aliases: ["hongkong", "hong kong", "hk", "гонконг"],
names: ["Chun", "Wing", "Hei", "Ka Ho", "Tsz", "Yan", "Ka Yee", "Hoi", "Man", "Lok", "Sze", "Yee", "Cheuk", "Ho Yin", "Ching", "Ka Wai", "Wai Lam", "Tsz Ching", "Yat Long", "Ming"],
surnames: ["Chan", "Wong", "Lee", "Cheung", "Ng", "Lau", "Ho", "Wu", "Chow", "Leung", "Kwok", "Tang", "Yip", "Ma", "Yeung", "Fung", "Kwan", "Lam", "Tsang", "Choi"]
},
"DE": {
name: "Germany",
label: "Германия",
flag: "🇩🇪",
favorite: true,
aliases: ["germany", "de", "берлин", "berlin", "deutschland"],
names: ["Lukas", "Finn", "Maximilian", "Paul", "Leon", "Jonas", "Felix", "Anna", "Mia", "Emma", "Hannah", "Lena", "Sophie", "Laura", "Ben", "Elias", "Marie", "Julia", "Niklas", "Sarah"],
surnames: ["Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Schulz", "Hoffmann", "Koch", "Richter", "Klein", "Wolf", "Neumann", "Schwarz", "Zimmermann", "Braun", "Krüger", "Hartmann"]
},
"US": {
name: "United States",
label: "США",
flag: "🇺🇸",
favorite: true,
needsState: true,
aliases: ["usa", "us", "america", "сша", "америка", "нью-йорк", "new york", "штаты"],
names: ["James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Emma", "Olivia", "Ava", "Sophia", "Isabella", "Mia", "Charlotte", "Amelia", "Ethan", "Noah", "Liam", "Grace"],
surnames: ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Wilson", "Anderson", "Taylor", "Thomas", "Moore", "Jackson", "Martin", "Lee", "Perez", "White"]
},
"FR": {
name: "France",
label: "Франция",
flag: "🇫🇷",
aliases: ["france", "fr", "франция", "париж", "paris"],
names: ["Léo", "Louis", "Gabriel", "Jules", "Hugo", "Arthur", "Nathan", "Emma", "Louise", "Chloé", "Camille", "Manon", "Léa", "Inès", "Lucas", "Adam", "Alice", "Jade", "Lina", "Sarah"],
surnames: ["Martin", "Bernard", "Dubois", "Thomas", "Robert", "Petit", "Durand", "Leroy", "Moreau", "Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "Roux", "Vincent", "Fournier", "Girard", "Bonnet", "Morel"]
},
"NL": {
name: "Netherlands",
label: "Нидерланды",
flag: "🇳🇱",
aliases: ["netherlands", "holland", "nl", "нидерланды", "голландия", "амстердам", "amsterdam"],
names: ["Daan", "Sem", "Milan", "Levi", "Luuk", "Finn", "Julia", "Emma", "Sophie", "Tess", "Sara", "Anna", "Lotte", "Fleur", "Bram", "Noah", "Sanne", "Eva", "Roos", "Mila"],
surnames: ["de Jong", "Jansen", "de Vries", "van den Berg", "van Dijk", "Bakker", "Visser", "Smit", "Meijer", "de Boer", "Mulder", "de Groot", "Bos", "Vos", "Peters", "Hendriks", "van Leeuwen", "Dekker", "Brouwer", "de Wit"]
},
"GB": {
name: "United Kingdom",
label: "Великобритания",
flag: "🇬🇧",
aliases: ["uk", "britain", "england", "gb", "великобритания", "англия", "лондон", "london"],
names: ["Oliver", "George", "Harry", "Jack", "Charlie", "Jacob", "Freddie", "Olivia", "Amelia", "Isla", "Ava", "Emily", "Sophie", "Grace", "Thomas", "Alfie", "Lily", "Ella", "Poppy", "Jessica"],
surnames: ["Smith", "Jones", "Taylor", "Williams", "Brown", "Davies", "Evans", "Wilson", "Thomas", "Roberts", "Johnson", "Walker", "Wright", "Robinson", "Hughes", "Green", "Hall", "Clarke", "Patel", "Baker"]
},
"CA": {
name: "Canada",
label: "Канада",
flag: "🇨🇦",
needsState: true,
aliases: ["canada", "ca", "канада", "торонто", "toronto"],
names: ["Liam", "Noah", "William", "Benjamin", "Owen", "Jack", "Emma", "Olivia", "Charlotte", "Amelia", "Ava", "Sophia", "Isabella", "Mia", "Lucas", "Ethan", "Chloe", "Zoe", "Nathan", "Ryan"],
surnames: ["Smith", "Brown", "Tremblay", "Martin", "Roy", "Wilson", "MacDonald", "Gagnon", "Taylor", "Campbell", "Anderson", "Morin", "Clark", "Lee", "Cote", "Bouchard", "Bergeron", "Fortin", "Levesque", "Gauthier"]
},
"JP": {
name: "Japan",
label: "Япония",
flag: "🇯🇵",
aliases: ["japan", "jp", "япония", "токио", "tokyo"],
names: ["Haruto", "Yuto", "Sota", "Riku", "Ren", "Yui", "Aoi", "Hina", "Sakura", "Yuna", "Rin", "Mio", "Kaito", "Sora", "Yuki", "Hana", "Sota", "Ayaka", "Kenta", "Nao"],
surnames: ["Sato", "Suzuki", "Takahashi", "Tanaka", "Watanabe", "Ito", "Yamamoto", "Nakamura", "Kobayashi", "Kato", "Yoshida", "Yamada", "Sasaki", "Matsumoto", "Inoue", "Kimura", "Hayashi", "Shimizu", "Saito", "Yamaguchi"]
},
"CH": {
name: "Switzerland",
label: "Швейцария",
flag: "🇨🇭",
aliases: ["switzerland", "ch", "швейцария", "цюрих", "zurich"],
names: ["Noah", "Liam", "Elias", "Matteo", "Luca", "Julien", "Mia", "Emma", "Lena", "Elena", "Sofia", "Lina", "Nora", "Alina", "Leon", "David", "Laura", "Nina", "Simon", "Anna"],
surnames: ["Müller", "Meier", "Schmid", "Keller", "Weber", "Huber", "Schneider", "Meyer", "Steiner", "Fischer", "Gerber", "Brunner", "Baumann", "Frei", "Widmer", "Zimmermann", "Moser", "Graf", "Roth", "Suter"]
},
"ES": {
name: "Spain",
label: "Испания",
flag: "🇪🇸",
aliases: ["spain", "es", "испания", "мадрид", "madrid"],
names: ["Hugo", "Martín", "Lucas", "Mateo", "Leo", "Daniel", "Lucía", "Sofía", "Martina", "María", "Paula", "Julia", "Valeria", "Emma", "Pablo", "Alejandro", "Carla", "Sara", "Diego", "Marcos"],
surnames: ["García", "Martínez", "López", "Sánchez", "Pérez", "González", "Rodríguez", "Fernández", "Gómez", "Díaz", "Moreno", "Álvarez", "Romero", "Navarro", "Torres", "Ramírez", "Ruiz", "Gil", "Serrano", "Blanco"]
},
"IT": {
name: "Italy",
label: "Италия",
flag: "🇮🇹",
aliases: ["italy", "it", "италия", "рим", "rome"],
names: ["Leonardo", "Francesco", "Alessandro", "Lorenzo", "Mattia", "Tommaso", "Sofia", "Giulia", "Aurora", "Alice", "Ginevra", "Emma", "Giorgia", "Beatrice", "Andrea", "Marco", "Chiara", "Elena", "Matteo", "Elisa"],
surnames: ["Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco", "Bruno", "Gallo", "Conti", "De Luca", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti"]
},
"SE": {
name: "Sweden",
label: "Швеция",
flag: "🇸🇪",
aliases: ["sweden", "se", "швеция", "стокгольм", "stockholm"],
names: ["Lucas", "William", "Liam", "Oscar", "Hugo", "Elias", "Alice", "Maja", "Ella", "Wilma", "Alma", "Ebba", "Freja", "Stella", "Axel", "Erik", "Astrid", "Ines", "Leo", "Alva"],
surnames: ["Andersson", "Johansson", "Karlsson", "Nilsson", "Eriksson", "Larsson", "Olsson", "Persson", "Svensson", "Gustafsson", "Pettersson", "Jonsson", "Jansson", "Hansson", "Bengtsson", "Lindberg", "Berg", "Lindqvist", "Lindgren", "Olofsson"]
},
"AU": {
name: "Australia",
label: "Австралия",
flag: "🇦🇺",
needsState: true,
aliases: ["australia", "au", "австралия", "сидней", "sydney"],
names: ["Oliver", "Jack", "William", "Noah", "Thomas", "James", "Charlotte", "Olivia", "Amelia", "Isla", "Mia", "Grace", "Ava", "Ruby", "Lucas", "Henry", "Chloe", "Ella", "Cooper", "Zoe"],
surnames: ["Smith", "Jones", "Williams", "Brown", "Wilson", "Taylor", "Johnson", "White", "Martin", "Anderson", "Thompson", "Nguyen", "Ryan", "Kelly", "King", "Baker", "Harris", "Young", "Walker", "Robinson"]
},
"PL": {
name: "Poland",
label: "Польша",
flag: "🇵🇱",
aliases: ["poland", "pl", "польша", "варшава", "warsaw"],
names: ["Jakub", "Antoni", "Jan", "Aleksander", "Franciszek", "Filip", "Zuzanna", "Julia", "Zofia", "Maja", "Hanna", "Amelia", "Lena", "Alicja", "Szymon", "Kacper", "Wiktoria", "Oliwia", "Marcel", "Nikola"],
surnames: ["Nowak", "Kowalski", "Wiśniewski", "Wójcik", "Kowalczyk", "Kamiński", "Lewandowski", "Zieliński", "Szymański", "Woźniak", "Dąbrowski", "Kozłowski", "Jankowski", "Mazur", "Krawczyk", "Piotrowski", "Grabowski", "Nowakowski", "Pawłowski", "Michalski"]
},
"AE": {
name: "United Arab Emirates",
label: "ОАЭ",
flag: "🇦🇪",
aliases: ["uae", "dubai", "оаэ", "дубай", "эмираты"],
names: ["Mohammed", "Ahmed", "Ali", "Omar", "Khalid", "Hassan", "Fatima", "Aisha", "Maryam", "Sara", "Noora", "Layla", "Huda", "Amal", "Yousef", "Saeed", "Mariam", "Salem", "Rashid", "Alia"],
surnames: ["Al Maktoum", "Al Falasi", "Al Suwaidi", "Al Marri", "Al Shamsi", "Al Zaabi", "Al Ketbi", "Al Hashimi", "Al Qassimi", "Al Mansoori", "Al Ali", "Al Zarooni", "Al Mazrouei", "Al Nuaimi", "Al Blooshi"]
},
"TR": {
name: "Turkey",
label: "Турция",
flag: "🇹🇷",
aliases: ["turkey", "tr", "турция", "стамбул", "istanbul"],
names: ["Ahmet", "Mehmet", "Mustafa", "Yusuf", "Emre", "Burak", "Ayşe", "Fatma", "Zeynep", "Elif", "Merve", "Esra", "Deniz", "Ece", "Kerem", "Baran", "Selin", "Buse", "Cem", "Aylin"],
surnames: ["Yılmaz", "Kaya", "Demir", "Şahin", "Çelik", "Yıldız", "Yıldırım", "Öztürk", "Aydın", "Özdemir", "Arslan", "Doğan", "Kılıç", "Aslan", "Çetin", "Kara", "Koç", "Kurt", "Özkan", "Şimşek"]
}
};
// База адресного сервера (см. server/ в репозитории). Меняй только тут, если переносишь хостинг.
this.ADDRESS_SERVER_BASE = "https://gammahub.tech/acid";
this.currentAddress = null;
this.selectedRegionCode = "SG";
this.prefetchStarted = false;
// Кеш свежих данных из API списка тикетов (fetch-перехват), используется
// ticket tracker'ом вместо грубого DOM-текста "14m", когда данные ещё не устарели.
this.conversationsCache = new Map();
this.conversationsCacheTTL = 3 * 60 * 1000;
}
// Запуск до загрузки страницы
initNetwork() {
this.setupNetworkHooks();
}
// Запуск после рендера DOM
initDOM() {
this.injectStyles();
this.applyRightPanelClass();
this.setupObservers();
this.startIntervalTasks();
this.runOnLoadTasks();
}
loadSettings() {
return loadSharedSettings(this.defaultSettings);
}
saveSettings(newSettings) {
this.settings = newSettings;
GM_setValue('acidSettings', JSON.stringify(this.settings));
this.applyRightPanelClass();
}
applyRightPanelClass() {
document.documentElement.classList.toggle('acid-rp-style', !!this.settings.rightPanelStyle);
}
injectStyles() {
const style = document.createElement('style');
style.textContent = `
.bg-prog { position: absolute; top: 0; left: 0; bottom: 0; z-index: 0; pointer-events: none; }
.bot-prog { position: absolute; bottom: 0.5vh; left: 1vw; height: 0.4vh; border-radius: 0.2vw; z-index: 1; pointer-events: none; display: none; }
.snow-icon { font-size: 1.5vw; }
@keyframes clientWait {
0% { width: 100%; background-color: hsla(120, 100%, 40%, 0.25); }
100% { width: 0%; background-color: hsla(0, 100%, 40%, 0.25); }
}
.t-client-wait .bg-prog { animation: clientWait 300s linear forwards; }
.t-client-expired .bg-prog { width: 100%; background-color: rgba(239, 68, 68, 0.25); }
.t-agent-wait .bg-prog { width: 100%; background-color: rgba(14, 165, 233, 0.1); }
@keyframes agentWait {
0% { width: calc(100% - 2vw); }
100% { width: 0%; }
}
.t-agent-wait .bot-prog { display: block; background-color: rgba(14, 165, 233, 0.6); animation: agentWait 1800s linear forwards; }
.t-agent-expired .bg-prog { width: 100%; background-color: rgba(100, 116, 139, 0.15); }
/* === ACID: правая панель — только компоновка, цвета темы не трогаем === */
/* панель уже: была 320-360px, делаем компактнее на десктопе (на мобильном drawer'е не трогаем) */
@media (min-width: 768px) {
html.acid-rp-style .md\:static:has(.list-group) {
width: 280px !important;
min-width: 280px !important;
}
}
/* компактная сетка контактных данных (email/телефон/id/компания) вместо длинного столбца */
html.acid-rp-style div.flex.flex-col.items-start.w-full.gap-2:has([title="Email"]) {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 0.7vw;
row-gap: 0.5vh;
}
/* агент + команда в одну строку, приоритет и категории на всю ширину под ними */
html.acid-rp-style .list-group [inbox-id] {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 0.6vw;
align-items: start;
}
html.acid-rp-style .list-group [inbox-id] > .multiselect-wrap--small:nth-of-type(n+3),
html.acid-rp-style .list-group [inbox-id] > .overflow-auto.py-0.px-0,
html.acid-rp-style .list-group [inbox-id] > .sidebar-labels-wrap {
grid-column: 1 / -1;
}
/* плотнее вертикальный ритм между секциями и их заголовками */
.list-group { display: flex; flex-direction: column; gap: 0.5vh; padding: 0 0.3vw; }
.list-group .flex.flex-col.gap-3 { gap: 0.5vh; }
.list-group .drag-handle { height: 2.4vh; padding-top: 0.35vh; padding-bottom: 0.35vh; }
.list-group .drag-handle h5 { font-weight: 600; }
/* лейблы-теги оборачиваются компактнее, без лишних внешних отступов */
.label-wrap { row-gap: 0.35vh; }
/* карточки прошлых диалогов — компактнее по высоте */
.list-group .contact-conversation--list .conversation { padding-top: 0.2vh; padding-bottom: 0.2vh; }
/* === ACID: панель биллинг-адресов — сжатие до иконки, когда левый рейл узкий,
и непрерывное масштабирование шрифта/отступов под фактическую ширину рейла
(через container query units) в развёрнутом состоянии === */
#acid-address-panel {
container-type: inline-size;
--acid-fs-lg: clamp(0.7rem, 9cqw, 0.95rem);
--acid-fs-md: clamp(0.62rem, 7.5cqw, 0.85rem);
--acid-fs-sm: clamp(0.56rem, 6cqw, 0.72rem);
--acid-pad-h: clamp(0.35rem, 5cqw, 0.6rem);
--acid-radius: clamp(0.3rem, 3.5cqw, 0.5rem);
--acid-gap: clamp(0.2rem, 2.5cqw, 0.4rem);
}
#acid-address-panel .acid-compact-only { display: none; }
#acid-address-panel .acid-wide-only { display: flex; }
#acid-address-panel.acid-addr-compact .acid-wide-only { display: none !important; }
#acid-address-panel.acid-addr-compact .acid-compact-only { display: flex !important; }
#acid-addr-body { width: 100%; box-sizing: border-box; z-index: 99998; box-shadow: 0 1vh 3vh rgba(0, 0, 0, 0.5); }
/* всплывающая (сжатая) панель вынесена в document.body — фиксированные размеры,
т.к. её ширина (240px) не связана с шириной узкого рейла-триггера */
#acid-addr-body.acid-floating {
--acid-fs-lg: 0.85rem;
--acid-fs-md: 0.75rem;
--acid-fs-sm: 0.65rem;
--acid-pad-h: 0.5rem;
--acid-radius: 0.4rem;
--acid-gap: 0.35rem;
width: 240px;
}
`;
document.head.appendChild(style);
}
parseSeconds(timeStr) {
if (!timeStr || timeStr.includes('now')) return 0;
const match = timeStr.match(/(\d+)([a-z]+)/);
if (!match) return 0;
const val = parseInt(match[1]);
const unit = match[2];
if (unit === 'm') return val * 60;
if (unit === 'h') return val * 3600;
if (unit === 'd') return val * 86400;
return val;
}
featureTicketTracker() {
if (!this.settings.ticketTracker) {
document.querySelectorAll('.bg-prog, .bot-prog').forEach(el => el.remove());
document.querySelectorAll('.conversation').forEach(conv => {
conv.classList.remove('t-client-wait', 't-client-expired', 't-agent-wait', 't-agent-expired');
});
document.querySelectorAll('.leading-6.h-6 span').forEach(span => {
span.style.color = '';
});
return;
}
const conversations = document.querySelectorAll('.conversation');
for (const conv of conversations) {
const timeContainer = conv.querySelector('.v-popper--has-tooltip span');
const msgContainer = conv.querySelector('.leading-6.h-6');
if (!timeContainer || !msgContainer) continue;
const timeText = timeContainer.textContent;
const isLocked = msgContainer.innerHTML.includes('M12 2a4 4 0 0 1 4 4v2h1.75');
let isAgent = msgContainer.innerHTML.includes('M9.277 16.221');
const nameEl = conv.querySelector('h4.conversation--user');
const previewSpan = msgContainer.querySelector('span');
let cached = null;
if (nameEl && previewSpan) {
const fingerprint = nameEl.textContent.trim().toLowerCase() + '|' + this.normalizeFingerprintText(previewSpan.textContent);
const entry = this.conversationsCache.get(fingerprint);
if (entry && (Date.now() - entry.fetchedAt) < this.conversationsCacheTTL) {
cached = entry;
}
}
if (previewSpan) previewSpan.style.color = isLocked ? '#eab308' : '';
const stateHash = timeText + '|' + isAgent + '|' + isLocked + '|' + (cached ? cached.lastActivityAt : '');
if (conv.dataset.stateHash === stateHash) continue;
conv.dataset.stateHash = stateHash;
let elapsedSec;
if (cached) {
isAgent = cached.isAgentMessage;
elapsedSec = Math.max(0, Math.floor(Date.now() / 1000) - cached.lastActivityAt);
} else {
const parts = timeText.split('•').map(s => s.trim());
const lastActiveStr = parts.length > 1 ? parts[1] : parts[0];
elapsedSec = this.parseSeconds(lastActiveStr);
}
let bgProg = conv.querySelector('.bg-prog');
if (!bgProg) {
bgProg = document.createElement('div');
bgProg.className = 'bg-prog';
conv.insertBefore(bgProg, conv.firstChild);
}
let botProg = conv.querySelector('.bot-prog');
if (!botProg) {
botProg = document.createElement('div');
botProg.className = 'bot-prog';
conv.appendChild(botProg);
}
Array.from(conv.children).forEach(child => {
if (!child.classList.contains('bg-prog') && !child.classList.contains('bot-prog')) {
child.style.position = 'relative';
child.style.zIndex = '2';
}
});
const avatarContainer = conv.querySelector('span[role="img"]');
let avatarImg = avatarContainer ? avatarContainer.querySelector('img, span.select-none') : null;
let snowIcon = conv.querySelector('.snow-icon');
if (avatarImg) avatarImg.style.display = '';
if (snowIcon) snowIcon.style.display = 'none';
conv.classList.remove('t-client-wait', 't-client-expired', 't-agent-wait', 't-agent-expired');
bgProg.style.animation = 'none';
botProg.style.animation = 'none';
void bgProg.offsetWidth;
bgProg.style.animation = '';
botProg.style.animation = '';
if (!isAgent) {
if (elapsedSec >= 300) {
conv.classList.add('t-client-expired');
} else {
conv.classList.add('t-client-wait');
bgProg.style.animationDelay = `-${elapsedSec}s`;
}
} else {
if (elapsedSec >= 1800) {
conv.classList.add('t-agent-expired');
if (avatarImg) avatarImg.style.display = 'none';
if (!snowIcon && avatarContainer) {
snowIcon = document.createElement('div');
snowIcon.className = 'snow-icon absolute inset-0 flex items-center justify-center z-20';
snowIcon.innerHTML = '❄️';
avatarContainer.appendChild(snowIcon);
} else if (snowIcon) {
snowIcon.style.display = 'flex';
}
} else {
conv.classList.add('t-agent-wait');
botProg.style.animationDelay = `-${elapsedSec}s`;
}
}
}
}
featureMenuInjector() {
if (document.getElementById('acid-settings-btn')) return;
const buttons = document.querySelectorAll('.n-dropdown-item button, .n-dropdown-item a');
let targetBtn = null;
for (const btn of buttons) {
if (btn.textContent.includes('Изменить внешний вид')) {
targetBtn = btn;
break;
}
}
if (targetBtn) {
const liElement = targetBtn.closest('.n-dropdown-item');
if (!liElement) return;
const containerDiv = liElement.parentElement;
const newDiv = document.createElement('div');
newDiv.innerHTML = `
<li class="n-dropdown-item">
<button id="acid-settings-btn" class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0 hover:bg-n-alpha-2 rounded-lg gap-3" style="color: #cbd5e1; font-weight: 400;">
<span style="font-size: 1vw; color: #b3e600;">👀</span> ACID SETTINGS
</button>
</li>
`;
containerDiv.parentNode.insertBefore(newDiv, containerDiv.nextSibling);
document.getElementById('acid-settings-btn').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const menuWrap = document.querySelector('.absolute.bottom-12.z-50');
if (menuWrap) menuWrap.style.display = 'none';
this.openSettingsPanel();
});
}
}
openSettingsPanel() {
if (document.getElementById('acid-modal-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'acid-modal-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 99999;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(0.5vh);
opacity: 0; transition: opacity 0.2s ease;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: rgba(28, 31, 35, 0.95);
border: 0.1vw solid rgba(255, 255, 255, 0.08);
border-radius: 1vw;
width: 22vw; min-width: 18vw; padding: 1.5vw; color: #e2e8f0;
box-shadow: 0 1vh 3vh rgba(0, 0, 0, 0.4);
transform: scale(0.95); transition: transform 0.2s ease;
font-family: inherit;
`;
modal.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2vh;">
<h2 style="margin: 0; font-size: 1vw; font-weight: 500; color: #f8fafc; letter-spacing: 0.02vw;">ACID SETTINGS</h2>
<button id="acid-close-btn" style="background: none; border: none; color: #64748b; cursor: pointer; font-size: 1.2vw; padding: 0; transition: color 0.2s;" onmouseover="this.style.color='#f8fafc'" onmouseout="this.style.color='#64748b'">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: 2vh;">
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Трекер тикетов (Таймеры)</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.ticketTracker ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-tracker-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.ticketTracker ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.ticketTracker ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-tracker-dot"></div>
</div>
<input type="checkbox" id="acid-t-tracker" style="display: none;" ${this.settings.ticketTracker ? 'checked' : ''}>
</label>
<div style="display: flex; flex-direction: column; gap: 0.5vh;">
<label style="font-size: 0.75vw; color: #64748b;">Кастомное значение</label>
<input type="text" id="acid-text-val" value="${this.settings.customValue || ''}" style="
background: rgba(0, 0, 0, 0.2); border: 0.1vw solid rgba(255, 255, 255, 0.08);
color: #f8fafc; padding: 0.8vh 0.8vw; border-radius: 0.5vw; font-size: 0.9vw; outline: none;
transition: border-color 0.2s;
" onfocus="this.style.borderColor='#b3e600'" onblur="this.style.borderColor='rgba(255, 255, 255, 0.08)'">
</div>
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Панель генерации адресов</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.addressPanel ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-addr-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.addressPanel ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.addressPanel ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-addr-dot"></div>
</div>
<input type="checkbox" id="acid-t-addr" style="display: none;" ${this.settings.addressPanel ? 'checked' : ''}>
</label>
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Кастомный хедер тикета</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.customHeader ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-head-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.customHeader ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.customHeader ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-head-dot"></div>
</div>
<input type="checkbox" id="acid-t-head" style="display: none;" ${this.settings.customHeader ? 'checked' : ''}>
</label>
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Стили правой панели</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.rightPanelStyle ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-rp-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.rightPanelStyle ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.rightPanelStyle ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-rp-dot"></div>
</div>
<input type="checkbox" id="acid-t-rp" style="display: none;" ${this.settings.rightPanelStyle ? 'checked' : ''}>
</label>
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Конвертер времени МСК</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.mskConverter ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-msk-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.mskConverter ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.mskConverter ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-msk-dot"></div>
</div>
<input type="checkbox" id="acid-t-msk" style="display: none;" ${this.settings.mskConverter ? 'checked' : ''}>
</label>
<label style="display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 0.9vw; color: #cbd5e1;">
<span>Карточки клиента (Профиль)</span>
<div style="position: relative; width: 2.5vw; height: 1.2vw; background: ${this.settings.cardsPanel ? '#b3e600' : 'rgba(255,255,255,0.1)'}; border-radius: 1vw; transition: 0.3s;" id="acid-t-cards-bg">
<div style="position: absolute; top: 0.15vw; left: ${this.settings.cardsPanel ? '1.45vw' : '0.15vw'}; width: 0.9vw; height: 0.9vw; background: ${this.settings.cardsPanel ? '#111827' : '#94a3b8'}; border-radius: 50%; transition: 0.3s;" id="acid-t-cards-dot"></div>
</div>
<input type="checkbox" id="acid-t-cards" style="display: none;" ${this.settings.cardsPanel ? 'checked' : ''}>
</label>
</div>
<button id="acid-save-btn" style="
width: 100%; margin-top: 3vh; padding: 1vh; background: #b3e600; color: #111827;
border: none; border-radius: 0.5vw; font-weight: 500; cursor: pointer; font-size: 0.9vw;
transition: opacity 0.2s;
" onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">Сохранить</button>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.style.opacity = '1';
modal.style.transform = 'scale(1)';
});
const bindToggle = (inputId, bgId, dotId) => {
const check = document.getElementById(inputId);
const bg = document.getElementById(bgId);
const dot = document.getElementById(dotId);
check.addEventListener('change', (e) => {
bg.style.background = e.target.checked ? '#b3e600' : 'rgba(255,255,255,0.1)';
dot.style.left = e.target.checked ? '1.45vw' : '0.15vw';
dot.style.background = e.target.checked ? '#111827' : '#94a3b8';
});
};
bindToggle('acid-t-tracker', 'acid-t-tracker-bg', 'acid-t-tracker-dot');
bindToggle('acid-t-addr', 'acid-t-addr-bg', 'acid-t-addr-dot');
bindToggle('acid-t-head', 'acid-t-head-bg', 'acid-t-head-dot');
bindToggle('acid-t-rp', 'acid-t-rp-bg', 'acid-t-rp-dot');
bindToggle('acid-t-msk', 'acid-t-msk-bg', 'acid-t-msk-dot');
bindToggle('acid-t-cards', 'acid-t-cards-bg', 'acid-t-cards-dot');
const closeModal = () => {
overlay.style.opacity = '0';
modal.style.transform = 'scale(0.95)';
setTimeout(() => overlay.remove(), 200);
};
document.getElementById('acid-close-btn').addEventListener('click', closeModal);
document.getElementById('acid-save-btn').addEventListener('click', () => {
this.saveSettings({
ticketTracker: document.getElementById('acid-t-tracker').checked,
addressPanel: document.getElementById('acid-t-addr').checked,
customHeader: document.getElementById('acid-t-head').checked,
rightPanelStyle: document.getElementById('acid-t-rp').checked,
mskConverter: document.getElementById('acid-t-msk').checked,
cardsPanel: document.getElementById('acid-t-cards').checked,
customValue: document.getElementById('acid-text-val').value || ''
});
const btn = document.getElementById('acid-save-btn');
btn.textContent = 'Сохранено';
btn.style.background = '#e2e8f0';
setTimeout(closeModal, 600);
});
}
featureAddressPanel() {
if (!this.settings.addressPanel) return;
if (document.getElementById('acid-address-panel')) return;
const navList = document.querySelector('nav.overflow-y-scroll > ul.flex-col');
if (!navList) return;
const panelHtml = document.createElement('li');
panelHtml.id = 'acid-address-panel';
panelHtml.className = 'grid gap-1 text-sm select-none min-w-0 mt-2';
panelHtml.innerHTML = `
<button type="button" id="acid-addr-compact-trigger" class="acid-compact-only flex items-center justify-center size-10 rounded-lg text-n-slate-11 hover:bg-n-alpha-2" title="Биллинг Адреса">
<span style="font-size: 1rem;">🌍</span>
</button>
<div class="acid-wide-only flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0 text-n-slate-11 hover:bg-n-alpha-2 cursor-pointer transition-colors" id="acid-addr-header">
<div class="relative flex items-center gap-2">
<span style="font-size: var(--acid-fs-lg); color: #b3e600; opacity: 0.8;">🌍</span>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<span class="truncate text-body-main font-medium text-sm">Биллинг Адреса</span>
</div>
<span class="i-lucide-chevron-down size-3 transition-transform" id="acid-addr-icon"></span>
</div>
<ul id="acid-addr-body" class="grid m-0 list-none min-w-0 p-2 gap-2 rounded-lg mt-1" style="display: none; background: rgba(20,22,26,0.98); border: 1px solid rgba(255, 255, 255, 0.08);">
<div id="acid-addr-region-title" style="display: flex; align-items: center; justify-content: center; gap: var(--acid-gap); font-size: var(--acid-fs-lg); font-weight: 600; color: #e2e8f0; padding: 0.2vh 0;">
<span id="acid-addr-region-flag"></span>
<span id="acid-addr-region-name"></span>
</div>
<div id="acid-addr-country-wrap" style="position: relative;">
<button type="button" id="acid-addr-change-country-btn" style="width: 100%; box-sizing: border-box; background: rgba(255,255,255,0.05); color: #cbd5e1; border: 1px solid rgba(255,255,255,0.08); border-radius: var(--acid-radius); padding: 0.6vh var(--acid-pad-h); font-size: var(--acid-fs-md); font-family: inherit; cursor: pointer; transition: 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='rgba(255,255,255,0.05)'">Изменить страну</button>
<div id="acid-addr-country-list" style="display: none; position: absolute; top: calc(100% + 0.3vh); left: 0; right: 0; z-index: 20; max-height: 22vh; overflow-y: auto; background: rgba(20,22,26,0.98); border: 1px solid rgba(255,255,255,0.1); border-radius: var(--acid-radius); padding: 0.4vh; box-shadow: 0 1vh 2vh rgba(0,0,0,0.5);"></div>
</div>
<div id="acid-addr-inner-panel" style="background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.06); border-radius: var(--acid-radius); padding: 0.6vh var(--acid-pad-h);">
<div style="font-family: inherit; font-size: var(--acid-fs-md); display: flex; flex-direction: column; gap: 0.8vh;" id="acid-addr-data">
<span style="color: #64748b;">Загрузка базы...</span>
</div>
</div>
<div class="flex gap-2 mt-1">
<button id="acid-addr-reroll" style="flex: 1; background: rgba(255,255,255,0.05); border: 1px solid transparent; color: #cbd5e1; border-radius: var(--acid-radius); padding: 0.6vh; font-size: var(--acid-fs-md); transition: 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='rgba(255,255,255,0.05)'">Реролл</button>
<button id="acid-addr-copy" style="flex: 1; background: transparent; border: 1px solid #b3e600; color: #b3e600; border-radius: var(--acid-radius); padding: 0.6vh; font-size: var(--acid-fs-md); font-weight: 500; transition: 0.2s;" onmouseover="this.style.background='#b3e600'; this.style.color='#111827';" onmouseout="this.style.background='transparent'; this.style.color='#b3e600';">Скопировать</button>
</div>
</ul>
`;
navList.appendChild(panelHtml);
const li = panelHtml;
const header = document.getElementById('acid-addr-header');
const compactTrigger = document.getElementById('acid-addr-compact-trigger');
const body = document.getElementById('acid-addr-body');
const icon = document.getElementById('acid-addr-icon');
const changeCountryBtn = document.getElementById('acid-addr-change-country-btn');
const countryList = document.getElementById('acid-addr-country-list');
const countryWrap = document.getElementById('acid-addr-country-wrap');
this.updateRegionTitle();
const isBodyOpen = () => body.style.display !== 'none' && body.style.display !== '';
const closeBody = () => {
body.style.display = 'none';
body.style.position = '';
body.style.left = '';
body.style.top = '';
body.classList.remove('acid-floating');
if (body.parentElement !== li) li.appendChild(body);
icon.style.transform = 'rotate(0deg)';
countryList.style.display = 'none';
};
const openBody = (floatingAnchor) => {
if (floatingAnchor) {
// Всплывающую панель выносим прямо в document.body: сам li — контейнер
// container-query (нужен для масштабирования шрифта в развёрнутом режиме),
// а это неявно делает li containing block для position:fixed — если оставить
// body внутри, координаты из getBoundingClientRect() окажутся смещены.
document.body.appendChild(body);
body.classList.add('acid-floating');
const rect = floatingAnchor.getBoundingClientRect();
body.style.position = 'fixed';
body.style.left = `${rect.right + 8}px`;
body.style.top = `${rect.top}px`;
} else {
if (body.parentElement !== li) li.appendChild(body);
body.classList.remove('acid-floating');
body.style.position = '';
body.style.left = '';
body.style.top = '';
}
body.style.display = 'grid';
icon.style.transform = 'rotate(180deg)';
if (!this.currentAddress) this.fetchAndRollAddress(this.selectedRegionCode);
};
header.addEventListener('click', () => isBodyOpen() ? closeBody() : openBody(null));
compactTrigger.addEventListener('click', (e) => {
e.stopPropagation();
isBodyOpen() ? closeBody() : openBody(compactTrigger);
});
document.addEventListener('click', (e) => {
if (!countryWrap.contains(e.target)) countryList.style.display = 'none';
// body может быть вынесен в document.body (плавающий режим), поэтому проверяем
// клик и по li, и отдельно по body — иначе клик по кнопкам внутри попапа сам же его закроет.
if (li.classList.contains('acid-addr-compact') && !li.contains(e.target) && !body.contains(e.target)) {
closeBody();
}
});
changeCountryBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isHidden = countryList.style.display === 'none' || !countryList.style.display;
if (isHidden) this.renderCountryList('');
countryList.style.display = isHidden ? 'block' : 'none';
});
document.getElementById('acid-addr-reroll').addEventListener('click', () => {
this.fetchAndRollAddress(this.selectedRegionCode);
});
document.getElementById('acid-addr-copy').addEventListener('click', () => this.copyAddressToClipboard());
// Встроенный левый рейл Chatwoot можно сузить до иконок (Настройки внешнего вида ->
// компактная навигация) — тогда наша панель тоже сворачивается в иконку и открывается
// всплывающим окном, чтобы не вылезать за рамки узкого рейла. В развёрнутом состоянии
// сам рейл может иметь разную ширину — растягиваем li на всю ширину рейла (родительский
// ul обычно центрирует элементы по контенту, li иначе не подхватит реальную ширину),
// чтобы cqw-переменные считались от актуальной ширины, а не от размера контента.
const railEl = navList.closest('nav') || navList;
const applyCompactState = () => {
const wasCompact = li.classList.contains('acid-addr-compact');
const isCompact = railEl.clientWidth > 0 && railEl.clientWidth < 90;
li.classList.toggle('acid-addr-compact', isCompact);
li.style.alignSelf = isCompact ? '' : 'stretch';
if (isCompact !== wasCompact) closeBody();
};
applyCompactState();
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(applyCompactState).observe(railEl);
}
}
updateRegionTitle() {
const region = this.addressRegions[this.selectedRegionCode];
const flagEl = document.getElementById('acid-addr-region-flag');
const nameEl = document.getElementById('acid-addr-region-name');
if (flagEl) flagEl.textContent = region.flag || '🏳️';
if (nameEl) nameEl.textContent = region.label;
}
renderCountryList(filter) {
const listEl = document.getElementById('acid-addr-country-list');
if (!listEl) return;
const f = (filter || '').trim().toLowerCase();
const entries = Object.entries(this.addressRegions);
const matches = entries.filter(([code, r]) => {
if (!f) return true;
const hay = [r.name, r.label, code, ...(r.aliases || [])].join(' ').toLowerCase();
return hay.includes(f);
});
const favorites = matches.filter(([, r]) => r.favorite);
const others = matches.filter(([, r]) => !r.favorite).sort((a, b) => a[1].label.localeCompare(b[1].label, 'ru'));
const renderItem = ([code, r]) => `
<div class="acid-addr-country-item" data-code="${code}" style="display: flex; align-items: center; gap: var(--acid-gap); padding: 0.7vh var(--acid-pad-h); border-radius: var(--acid-radius); cursor: pointer; font-size: var(--acid-fs-md); color: #e2e8f0; transition: background 0.15s ease;">
<span>${r.flag || '🏳️'}</span>
<span style="flex: 1;">${r.label}</span>
${r.favorite ? '<span style="color:#b3e600; font-size: var(--acid-fs-sm);">★</span>' : ''}
</div>
`;
let html = '';
if (favorites.length) {
html += `<div style="padding: 0.3vh var(--acid-pad-h); font-size: var(--acid-fs-sm); color: #64748b; text-transform: uppercase; letter-spacing: 0.03em;">Избранное</div>`;
html += favorites.map(renderItem).join('');
}
if (others.length) {
html += `<div style="padding: 0.3vh var(--acid-pad-h); font-size: var(--acid-fs-sm); color: #64748b; text-transform: uppercase; letter-spacing: 0.03em;">Другие регионы</div>`;
html += others.map(renderItem).join('');
}
listEl.innerHTML = html || `<div style="padding: 1vh; font-size: var(--acid-fs-md); color: #64748b;">Ничего не найдено</div>`;
listEl.querySelectorAll('.acid-addr-country-item').forEach(item => {
item.addEventListener('mouseenter', () => item.style.background = 'rgba(179,230,0,0.1)');
item.addEventListener('mouseleave', () => item.style.background = 'transparent');
item.addEventListener('click', () => this.selectRegion(item.dataset.code));
});
}
selectRegion(code) {
const region = this.addressRegions[code];
if (!region) return;
this.selectedRegionCode = code;
this.updateRegionTitle();
document.getElementById('acid-addr-country-list').style.display = 'none';
this.fetchAndRollAddress(code);
}
pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
// Оборачивает GM_xmlhttpRequest в промис. Используем GM_xmlhttpRequest, а не fetch(),
// потому что адресный сервер живёт на своём домене (gammahub.tech), а не на cw.echelon.su —
// GM_xmlhttpRequest у Tampermonkey игнорирует CORS, обычный fetch() тут бы просто упал.
gmGetJson(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 15000,
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
reject(new Error('Адресный сервер: HTTP ' + res.status));
return;
}
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(new Error('Адресный сервер вернул не-JSON'));
}
},
onerror: () => reject(new Error('Адресный сервер недоступен')),
ontimeout: () => reject(new Error('Адресный сервер: таймаут'))
});
});
}
regionCacheKey(code) {
return `acid_addresses_v5_${code}`;
}
getRegionCache(code) {
try {
return JSON.parse(localStorage.getItem(this.regionCacheKey(code)) || 'null');
} catch (e) {
return null;
}
}
saveRegionCache(code, cache) {
localStorage.setItem(this.regionCacheKey(code), JSON.stringify(cache));
}
// Первичная выдача пачки адресов для региона (get_addresses.php) — избранные регионы
// получают 40 адресов, базовые — 25 (решает сервер, см. server/common.php).
async fetchAddressBatch(code) {
const data = await this.gmGetJson(`${this.ADDRESS_SERVER_BASE}/get_addresses.php?region=${encodeURIComponent(code)}`);
if (!data || !Array.isArray(data.houses) || data.houses.length === 0) {
throw new Error('Сервер не вернул адреса для региона ' + code);
}
const cache = { houses: data.houses, used: [] };
this.saveRegionCache(code, cache);
return cache;
}
// Реролл пачки для одного региона (reroll.php) — вызывается только когда в
// локал сторадж уже нет неиспробованных адресов для этого региона.
async fetchRerollBatch(code) {
const data = await this.gmGetJson(`${this.ADDRESS_SERVER_BASE}/reroll.php?region=${encodeURIComponent(code)}`);
if (!data || !Array.isArray(data.houses) || data.houses.length === 0) {
throw new Error('Сервер не вернул адреса для реролла региона ' + code);
}
const cache = { houses: data.houses, used: [] };
this.saveRegionCache(code, cache);
return cache;
}
// Возвращает рабочий кеш региона: из локал сторадж, если там ещё остались
// неиспробованные адреса, иначе — новая пачка с сервера (первая загрузка или реролл).
async ensureRegionCache(code) {
const cached = this.getRegionCache(code);
if (cached && cached.houses && cached.houses.length > 0 && cached.used.length < cached.houses.length) {
return cached;
}
if (cached && cached.houses && cached.houses.length > 0) {
return this.fetchRerollBatch(code);
}
return this.fetchAddressBatch(code);
}
// Прогревает кеш избранных регионов (SG/HK/DE/US), пока пользователь смотрит выбранную
// страну, чтобы переключение на них уже не ждало сетевого запроса. Регионы, для которых
// кеш уже есть (даже исчерпанный), не трогаем — реролл для них произойдёт по требованию.
async prefetchRegions() {
if (this.prefetchStarted) return;
this.prefetchStarted = true;
const codes = Object.keys(this.addressRegions).filter(c => c !== this.selectedRegionCode && this.addressRegions[c].favorite && !this.getRegionCache(c));
for (const code of codes) {
try {
await this.fetchAddressBatch(code);
} catch (e) {}
}
}
async fetchAndRollAddress(countryCode) {
const dataBox = document.getElementById('acid-addr-data');
const rerollBtn = document.getElementById('acid-addr-reroll');
dataBox.innerHTML = '<span style="color: #64748b;">Загрузка...</span>';
rerollBtn.disabled = true;
try {
const cache = await this.ensureRegionCache(countryCode);
const usedSet = new Set(cache.used);
const freeIndices = cache.houses.map((_, i) => i).filter(i => !usedSet.has(i));
const pickPool = freeIndices.length > 0 ? freeIndices : cache.houses.map((_, i) => i);
const idx = this.pickRandom(pickPool);
const house = cache.houses[idx];
if (!usedSet.has(idx)) {
usedSet.add(idx);
cache.used = Array.from(usedSet);
this.saveRegionCache(countryCode, cache);
}
const region = this.addressRegions[countryCode];
const fullName = `${this.pickRandom(region.names)} ${this.pickRandom(region.surnames)}`;
this.currentAddress = {
fullname: fullName,
street: house.street,
city: house.city,
zip: house.zip,
state: house.state,
needsState: !!region.needsState,
country: region.name
};
this.renderAddressData();
if (countryCode === this.selectedRegionCode) this.prefetchRegions();
} catch (e) {
dataBox.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 0.4vh;">
<span style="color: #ef4444;">Ошибка API — сервис перегружен</span>
<button id="acid-addr-retry" style="background: transparent; border: 1px solid #ef4444; color: #ef4444; border-radius: var(--acid-radius); padding: 0.3vh 0.6vh; font-size: var(--acid-fs-sm); cursor: pointer;">Повторить</button>
</div>
`;
const retryBtn = document.getElementById('acid-addr-retry');
if (retryBtn) retryBtn.addEventListener('click', () => this.fetchAndRollAddress(countryCode));
} finally {
rerollBtn.disabled = false;
}
}
renderAddressData() {
const dataBox = document.getElementById('acid-addr-data');
const a = this.currentAddress;
dataBox.innerHTML = `
<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Имя:</span> <span style="color:#e2e8f0; text-align: right;">${a.fullname}</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Улица:</span> <span style="color:#e2e8f0; text-align: right;">${a.street}</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Город:</span> <span style="color:#e2e8f0; text-align: right;">${a.city}</span></div>
${a.needsState ? `<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Штат:</span> <span style="color:#e2e8f0; text-align: right;">${a.state || '-'}</span></div>` : ''}
<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Индекс:</span> <span style="color:#e2e8f0; text-align: right;">${a.zip}</span></div>
<div style="display: flex; justify-content: space-between;"><span style="color:#64748b;">Страна:</span> <span style="color:#e2e8f0; text-align: right;">${a.country}</span></div>
`;
}
copyAddressToClipboard() {
if (!this.currentAddress) return;
const a = this.currentAddress;
let text = `Имя и фамилия: ${a.fullname}\nУлица: ${a.street}\n`;
if (a.needsState) text += `Штат: ${a.state || '-'}\n`;
text += `Индекс: ${a.zip}\n`;
text += (a.city && a.city !== a.country) ?
`Город: ${a.city}\nСтрана: ${a.country}` :
`Страна: ${a.country}`;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('acid-addr-copy');
btn.textContent = 'Успешно!';
btn.style.background = '#b3e600';
btn.style.color = '#111827';
setTimeout(() => {
btn.textContent = 'Скопировать';
btn.style.background = 'transparent';
btn.style.color = '#b3e600';
}, 1500);
});
}
setupNetworkHooks() {
const targetWin = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const originalXhrSend = targetWin.XMLHttpRequest.prototype.send;
const self = this;
targetWin.XMLHttpRequest.prototype.send = function (...args) {
this.addEventListener('load', function () {
if (this.responseURL && this.responseURL.includes('update_last_seen')) {
try {
self.currentChatData = JSON.parse(this.responseText);
self.renderHeaderUI();
} catch (e) {}
}
if (this.responseURL && this.responseURL.includes('/conversations') && this.responseURL.includes('assignee_type=')) {
try {
self.cacheConversationsPayload(JSON.parse(this.responseText));
} catch (e) {}
}
});
return originalXhrSend.apply(this, args);
};
const originalFetch = targetWin.fetch;
if (originalFetch) {
targetWin.fetch = function (input, init) {
const promise = originalFetch.apply(this, arguments);
try {
const url = typeof input === 'string' ? input : (input && input.url) || '';
if (url.includes('/conversations') && url.includes('assignee_type=')) {
promise.then(res => {
if (!res.ok) return;
res.clone().json().then(data => self.cacheConversationsPayload(data)).catch(() => {});
}).catch(() => {});
}
} catch (e) {}
return promise;
};
}
}
// Список тикетов приходит без "готового" DOM id, поэтому сопоставляем карточку
// с записью из API по отпечатку "имя контакта + начало превью сообщения" —
// этого достаточно, чтобы не зависеть от внутренней вёрстки Chatwoot.
normalizeFingerprintText(s) {
return (s || '').replace(/[\\\s]+/g, ' ').trim().toLowerCase().slice(0, 30);
}
cacheConversationsPayload(data) {
const payload = data && data.data && data.data.payload;
if (!Array.isArray(payload)) return;
const now = Date.now();
for (const conv of payload) {
const senderName = (conv.meta && conv.meta.sender && conv.meta.sender.name) || '';
const lastMsg = conv.last_non_activity_message;
if (!senderName || !lastMsg) continue;
const fingerprint = senderName.trim().toLowerCase() + '|' + this.normalizeFingerprintText(lastMsg.content);
this.conversationsCache.set(fingerprint, {
fetchedAt: now,
lastActivityAt: conv.last_activity_at,
createdAt: conv.created_at,
isAgentMessage: lastMsg.sender_type === 'User'
});
}
}
formatMSK(timestamp) {
if (!timestamp || timestamp === 0) return '<span style="color: #ef4444; font-weight: 600;">0</span>';
const nowSec = Math.floor(Date.now() / 1000);
const isOld = (nowSec - timestamp) > 28800;
const date = new Date(timestamp * 1000);
if (isOld) {
return date.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
} else {
return date.toLocaleTimeString('ru-RU', {
timeZone: 'Europe/Moscow',
hour: '2-digit',
minute: '2-digit'
});
}
}
getResponseTimer(created, firstReply) {
if (!firstReply || firstReply === 0) return '<span style="color: #ef4444; font-weight: 600;">Нет ответа</span>';
const diff = firstReply - created;
if (diff < 0) return '<span style="color: #ef4444;">Ошибка</span>';
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = diff % 60;
let color = '#ef4444';
if (diff <= 300) color = '#22c55e';
else if (diff <= 900) color = '#eab308';
let timeStr = hours > 0 ? `${hours}ч ` : '';
timeStr += `${minutes}м ${seconds}с`;
return `<span style="color: ${color}; font-weight: 600;">${timeStr}</span>`;
}
renderHeaderUI() {
if (!this.settings.customHeader) {
const existingPanel = document.getElementById('custom-chatwoot-header-block');
if (existingPanel) existingPanel.remove();
return;
}
const data = this.currentChatData;
if (!data) return;
const urlMatch = window.location.pathname.match(/\/conversations\/(\d+)/);
const currentChatId = urlMatch ? parseInt(urlMatch[1], 10) : null;
if (!currentChatId || data.id !== currentChatId) return;
const targetHeader = document.querySelector('.conversation--header--actions') ?.closest('.flex.items-center.justify-start');
if (!targetHeader) return;
let panel = document.getElementById('custom-chatwoot-header-block');
if (!panel) {
panel = document.createElement('div');
panel.id = 'custom-chatwoot-header-block';
panel.style.cssText = `
display: flex; flex-direction: row; justify-content: space-between; align-items: center;
flex: 1; margin-left: 2vw; padding: 0.5vh 1vw; background-color: transparent; border: none;
font-family: inherit; font-size: 0.8vw; font-weight: 500; color: #e2e8f0;
white-space: nowrap; overflow: hidden;
`;
targetHeader.appendChild(panel);
}
// Генерируем уникальный слепок текущего состояния тикета
const stateHash = `${data.id}_${data.last_activity_at}_${data.first_reply_created_at}`;
// Защита от бесконечного цикла MutationObserver: обновляем DOM только если данные изменились
if (panel.dataset.stateHash === stateHash) return;
const customAttrs = (data.meta && data.meta.sender && data.meta.sender.custom_attributes) || {};
panel.innerHTML = `
<div style="display: flex; gap: 1.5vw;">
<span title="Создан">📩 ${this.formatMSK(data.created_at)}</span>
<span title="Первый ответ">📤 ${this.formatMSK(data.first_reply_created_at)}</span>
<span title="Время ответа">⌛ ${this.getResponseTimer(data.created_at, data.first_reply_created_at)}</span>
<span title="Активность">👁️🗨️ ${this.formatMSK(data.last_activity_at)}</span>
</div>
<div style="display: flex; gap: 1.5vw; color: #22c55e;">
<span title="Zarub ID">ID: <span style="color: #fff;">${customAttrs.zarub_id || '<span style="color: #ef4444;">0</span>'}</span></span>
<span title="Карт">💳 <span style="color: #fff;">${customAttrs.cards_count || '<span style="color: #ef4444;">0</span>'}</span></span>
<span title="Депозит за всё время">💸 <span style="color: #fff;">${customAttrs.total_deposit_usd || '<span style="color: #ef4444;">0</span>'}</span></span>
</div>
`;
panel.dataset.stateHash = stateHash;
}
setupObservers() {
this.observers.menu = new MutationObserver(() => {
this.featureMenuInjector();
this.featureAddressPanel();
this.renderHeaderUI();
});
this.observers.menu.observe(document.body, {
childList: true,
subtree: true
});
}
startIntervalTasks() {
this.intervals.ticketTracker = setInterval(() => this.featureTicketTracker(), 1000);
}
runOnLoadTasks() {
console.log("ACID CW PERKS: Модули загружены.");
}
}
const app = new AcidPerks();
app.initNetwork();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.initDOM());
} else {
app.initDOM();
}
})();