Convert Steam prices between any currencies with commission support.
// ==UserScript==
// @name Steam Currency Converter
// @name:ru Конвертер валют в Steam
// @version 0.8.9.5
// @namespace https://store.steampowered.com/
// @description Convert Steam prices between any currencies with commission support.
// @description:ru Конвертирует цены Steam между любой валютой с поддержкой комиссии.
// @author NeTan
// @license MIT
// @match https://store.steampowered.com/*
// @match https://steamcommunity.com/*
// @match https://checkout.steampowered.com/*
// @icon https://icons.duckduckgo.com/ip3/steampowered.com.ico
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @connect cdn.jsdelivr.net
// ==/UserScript==
// --- DEBUG LOGGER ---
// (чтобы включить подробный лог, измените false на true)
const DEBUG_MODE = false;
const log = (message, ...args) => {
if (DEBUG_MODE) {
console.log("SCC DEBUG:", message, ...args);
}
};
const warn = (message, ...args) => {
if (DEBUG_MODE) {
console.warn("SCC WARN:", message, ...args);
}
};
const error = (message, ...args) => {
console.error("SCC ERROR:", message, ...args);
};
// --- END LOGGER ---
// Currency symbol to abbreviation mapping
const CURRENCY_SYMBOLS = {
"₸": "KZT",
"TL": "TRY",
"€": "EUR",
"£": "GBP",
"ARS$": "ARS",
"₴": "UAH",
"$": "USD", // Примечание: $ используется для USD, CAD, AUD, MXN и др. USD является запасным вариантом.
"₽": "RUB",
"Br": "BYN",
"฿": "THB",
"zł": "PLN",
"R$": "BRL",
"¥": "JPY", // Примечание: JPY и CNY используют ¥. JPY более вероятен без префикса.
"CNY ¥": "CNY",
"₩": "KRW",
"A$": "AUD",
"CDN$": "CAD",
"CHF": "CHF",
"kr": "NOK", // Также используется DKK, SEK. NOK - базовый вариант.
"MXN$": "MXN",
"₹": "INR",
"AED": "AED",
"SAR": "SAR",
"R": "ZAR",
"CLP$": "CLP",
"S/.": "PEN",
"COL$": "COP",
"$U": "UYU",
"₡": "CRC",
"₪": "ILS",
"KWD": "KWD",
"QR": "QAR",
"HK$": "HKD",
"NT$": "TWD",
"S$": "SGD",
"Rp": "IDR",
"RM": "MYR",
"₱": "PHP",
"₫": "VND"
};
// Abbreviation to symbol mapping
const ABBREVIATION_SYMBOLS = {};
Object.entries(CURRENCY_SYMBOLS).forEach(([symbol, abbr]) => {
// Не перезаписываем $ -> USD, если уже есть
if (!ABBREVIATION_SYMBOLS[abbr]) {
ABBREVIATION_SYMBOLS[abbr] = symbol;
}
});
// Добавляем $ вручную для валют, которые могут его использовать
ABBREVIATION_SYMBOLS["USD"] = "$";
ABBREVIATION_SYMBOLS["CAD"] = "CDN$";
ABBREVIATION_SYMBOLS["AUD"] = "A$";
ABBREVIATION_SYMBOLS["MXN"] = "MXN$";
// Pre-sorted symbols for currency detection (longest first)
const SORTED_CURRENCY_SYMBOLS = Object.entries(CURRENCY_SYMBOLS)
.sort((a, b) => b[0].length - a[0].length);
// Supported Steam currencies
const SUPPORTED_CURRENCIES = [
// Основные (из старого списка)
{ id: 1, code: "USD", sign: "$", description: "United States Dollars" },
{ id: 5, code: "RUB", sign: "₽", description: "Russian Rouble" },
{ id: 17, code: "TRY", sign: "TL", description: "Turkish Lira" },
{ id: 18, code: "UAH", sign: "₴", description: "Ukrainian Hryvnia" },
{ id: 29, code: "THB", sign: "฿", description: "Thai Baht" },
{ id: 34, code: "ARS", sign: "ARS$", description: "Argentine Peso" },
{ id: 37, code: "KZT", sign: "₸", description: "Kazakhstani Tenge" },
{ id: 42, code: "BYN", sign: "Br", description: "Belarusian Ruble" },
// Добавленные
{ id: 2, code: "GBP", sign: "£", description: "British Pound" },
{ id: 3, code: "EUR", sign: "€", description: "European Union Euro" },
{ id: 6, code: "PLN", sign: "zł", description: "Polish Złoty" },
{ id: 7, code: "BRL", sign: "R$", description: "Brazilian Real" },
{ id: 8, code: "JPY", sign: "¥", description: "Japanese Yen" },
{ id: 9, code: "NOK", sign: "kr", description: "Norwegian Krone" },
{ id: 10, code: "CAD", sign: "CDN$", description: "Canadian Dollar" },
{ id: 11, code: "AUD", sign: "A$", description: "Australian Dollar" },
{ id: 12, code: "CHF", sign: "CHF", description: "Swiss Franc" },
{ id: 19, code: "MXN", sign: "MXN$", description: "Mexican Peso" },
{ id: 20, code: "INR", sign: "₹", description: "Indian Rupee" },
{ id: 21, code: "CLP", sign: "CLP$", description: "Chilean Peso" },
{ id: 22, code: "PEN", sign: "S/.", description: "Peruvian Sol" },
{ id: 23, code: "KRW", sign: "₩", description: "South Korean Won" },
{ id: 24, code: "COP", sign: "COL$", description: "Colombian Peso" },
{ id: 25, code: "CNY", sign: "CNY ¥", description: "Chinese Yuan" },
{ id: 26, code: "ZAR", sign: "R", description: "South African Rand" },
{ id: 27, code: "AED", sign: "AED", description: "United Arab Emirates Dirham" },
{ id: 28, code: "SAR", sign: "SAR", description: "Saudi Riyal" },
{ id: 30, code: "UYU", sign: "$U", description: "Uruguayan Peso" },
{ id: 31, code: "CRC", sign: "₡", description: "Costa Rican Colón" },
{ id: 32, code: "ILS", sign: "₪", description: "Israeli New Shekel" },
{ id: 33, code: "KWD", sign: "KWD", description: "Kuwaiti Dinar" },
{ id: 35, code: "QAR", sign: "QR", description: "Qatari Riyal" },
{ id: 36, code: "HKD", sign: "HK$", description: "Hong Kong Dollar" },
{ id: 38, code: "TWD", sign: "NT$", description: "New Taiwan Dollar" },
{ id: 39, code: "SGD", sign: "S$", description: "Singapore Dollar" },
{ id: 40, code: "IDR", sign: "Rp", description: "Indonesian Rupiah" },
{ id: 41, code: "MYR", sign: "RM", description: "Malaysian Ringgit" },
{ id: 43, code: "PHP", sign: "₱", description: "Philippine Peso" },
{ id: 44, code: "VND", sign: "₫", description: "Vietnamese Đồng" }
];
// Currencies using COMMA as decimal separator
const COMMA_DECIMAL_CURRENCIES = [
"KZT", "TRY", "EUR", "ARS", "UAH", "RUB", "BYN", "PLN", "BRL",
"NOK", "CHF", "CLP", "PEN", "COP", "UYU", "CRC", "IDR", "VND"
];
const COMMA_DECIMAL_SET = new Set(COMMA_DECIMAL_CURRENCIES);
// Lookup maps for O(1) currency access
const CURRENCY_BY_ID = new Map(SUPPORTED_CURRENCIES.map(c => [c.id, c]));
const CURRENCY_BY_CODE = new Map(SUPPORTED_CURRENCIES.map(c => [c.code, c]));
// Application state
let activeCurrencyCode = null;
let activeCurrencySign = null;
let exchangeData = null;
let baseRateFromRub = null;
let targetCurrencies = {}; // { code: commission }
let suppressOriginal = false;
let userLanguage = 'ru';
let languageMode = 'auto';
let panelThemeMode = 'auto';
let userAddedSelectors = [];
let finalPriceSelectorString = "";
let conversionLogicStarted = false; // Флаг, что логика конвертации уже запущена
let targetCurrencyOrder = []; // Порядок отображения валют
let panelCurrencyOrder = []; // Временный порядок при редактировании в панели
// Storage identifiers
const RATE_EXPIRY_ID = "rate_expiry_v2";
const RATE_DATA_ID = "rate_data_v2";
const TARGETS_ID = "targets";
const SUPPRESS_ID = "suppress_original";
const LANG_ID = "user_lang";
const LANG_MODE_ID = "user_lang_mode_v1";
const THEME_MODE_ID = "ui_theme_mode_v1";
const USER_SELECTORS_ID = "user_selectors_v1";
const DISABLED_BASE_SELECTORS_ID = "disabled_base_selectors_v1";
const CURRENCY_ORDER_ID = "currency_order_v1";
// Network utilities
const fetchData = url => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url,
headers: { "Content-Type": "application/json" },
onload: response => resolve(response.responseText),
onerror: reject,
});
});
const refreshRates = async () => {
const cacheBuster = Math.random();
const endpoint = `https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/rub.json?${cacheBuster}`;
log("Refreshing rates from:", endpoint);
const response = await fetchData(endpoint);
const parsedRates = JSON.parse(response);
const expiry = Date.now() + 6 * 60 * 60 * 1000; // 6 hours
GM_setValue(RATE_EXPIRY_ID, expiry);
GM_setValue(RATE_DATA_ID, parsedRates);
log("Rates refreshed and stored.");
return parsedRates;
};
const loadRates = async () => {
if (exchangeData) return;
const expiry = GM_getValue(RATE_EXPIRY_ID, null);
const storedRates = GM_getValue(RATE_DATA_ID, null);
const rateTimestamp = storedRates ? Date.parse(storedRates.date) : Date.now();
const needsRefresh = !storedRates || !expiry || expiry <= Date.now() ||
(rateTimestamp + 24 * 60 * 60 * 1000) <= Date.now();
if (needsRefresh) {
log("Rate cache expired or missing, fetching new rates...");
exchangeData = await refreshRates();
} else {
log("Loading rates from cache.");
exchangeData = storedRates;
}
};
// Value formatting utilities
const renderValue = (value, code) => {
const lowValue = value < 100;
const adjustedValue = lowValue ?
Math.ceil(value * 10) / 10 : Math.ceil(value);
const region = code === "RUB" ?
"ru-RU" : "en-US";
const formatOptions = lowValue ? { maximumFractionDigits: 1 } : {};
return adjustedValue.toLocaleString(region, formatOptions);
};
// String/number helpers
const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const normalizePriceString = (raw, useCommaDecimal) => {
let cleaned = raw.replace(/USD/gi, '');
cleaned = cleaned.replace(/[\s\u00A0]/g, '');
if (useCommaDecimal) {
cleaned = cleaned.replace(/\./g, "").replace(/,/g, ".");
} else {
cleaned = cleaned.replace(/,/g, "");
}
return cleaned;
};
const cleanupDiscountedPrice = element => {
const crossedOut = element.querySelector("span > strike");
if (!crossedOut) return;
crossedOut.remove();
element.classList.remove("discounted");
element.style.color = "#BEEE11";
};
// Regex cache for price parsing
const PRICE_TAG_REGEX_CACHE = new Map();
const getPriceTagRegex = sign => {
const cached = PRICE_TAG_REGEX_CACHE.get(sign);
if (cached) return cached;
const escapedSign = escapeRegex(sign);
const regex = new RegExp(
`(<[a-z]+[^>]*>)?` +
`(\\s*` +
`(${escapedSign}\\s*[\\d.,]+(?:\\s*USD)?|[\\d.,\\s\u00A0]+\\s*${escapedSign})` +
`\\s*)` +
`(<\\/[a-z]+>)?`,
'gi'
);
PRICE_TAG_REGEX_CACHE.set(sign, regex);
return regex;
};
// *** ИСПРАВЛЕННАЯ ФУНКЦИЯ: Price modification routine (v1.0.15) ***
const applyConversion = element => {
const classList = element.classList;
// --- ИСПРАВЛЕНИЕ v0.8.15: Предотвращение бесконечного цикла React ---
// Если React перезаписывает наш .done, проверяем, не содержит ли элемент уже нашу конвертацию.
const elementText = element.textContent || "";
if (classList.contains("done") || elementText.includes("≈")) {
return;
}
// --- КОНЕЦ ИСПРАВЛЕНИЯ v0.8.15 ---
const isWalletBalance = element.id === "header_wallet_balance";
// Ранние проверки на выход
if (!activeCurrencySign ||
// Проверяем наличие символа валюты, только если это НЕ баланс кошелька
(!isWalletBalance && !elementText.includes(activeCurrencySign)) ||
classList.contains("es-regprice") || classList.contains("es-converted") ||
classList.contains("discount_original_price")) {
classList.add("done"); // Помечаем, чтобы не проверять снова
return;
}
// Порядок валют: сначала по сохранённому порядку, затем остальные
const orderedCodes = [];
const seen = new Set();
for (const code of targetCurrencyOrder) {
if (code in targetCurrencies) { orderedCodes.push(code); seen.add(code); }
}
for (const code of Object.keys(targetCurrencies)) {
if (!seen.has(code)) orderedCodes.push(code);
}
const targetEntries = orderedCodes.map(code => [code, targetCurrencies[code]]);
if (targetEntries.length === 0 || !baseRateFromRub || !exchangeData) {
if (DEBUG_MODE) {
warn("Bailed applyConversion. Reason: missing targets, rate, or data.", {
targets: targetEntries.length,
baseRate: baseRateFromRub,
data: !!exchangeData,
element: element.cloneNode(true)
});
}
return;
}
classList.add("done");
if (DEBUG_MODE) {
log("applyConversion running on element:", element.cloneNode(true));
}
// Предварительная чистка (для зачеркнутых цен)
cleanupDiscountedPrice(element);
let originalHTML = element.innerHTML;
const escapedSign = escapeRegex(activeCurrencySign);
const useCommaDecimal = COMMA_DECIMAL_SET.has(activeCurrencyCode);
const priceTagPattern = getPriceTagRegex(activeCurrencySign);
let replaced = false;
const newHTML = originalHTML.replace(priceTagPattern, (match, openTag, priceTextWithSign, innerPrice, closeTag) => {
log(`Match found: '${match}'`);
const rawPriceString = priceTextWithSign.trim(); // e.g., "$1.79 USD" или "26 155,13₸"
let cleanedText = rawPriceString.replace(new RegExp(escapedSign, 'g'), '');
cleanedText = normalizePriceString(cleanedText, useCommaDecimal);
const originalValue = parseFloat(cleanedText);
log(`Parsed Value: ${originalValue} (from '${cleanedText}')`);
if (isNaN(originalValue)) {
warn("Parsing failed (NaN) for:", cleanedText, "from:", rawPriceString);
return match; // Возвращаем оригинальное совпадение, если парсинг не удался
}
const rubEquivalent = originalValue / baseRateFromRub;
log(`RUB Equivalent: ${rubEquivalent} (${originalValue} / ${baseRateFromRub})`);
const currentConvertedDisplays = [];
for (const [targetCode, fee] of targetEntries) {
if (targetCode === activeCurrencyCode) {
log(`Skipping conversion: Target (${targetCode}) is same as Active (${activeCurrencyCode})`);
continue;
}
let rateToTarget = exchangeData.rub[targetCode.toLowerCase()];
if (rateToTarget === undefined) {
if (targetCode.toLowerCase() === 'rub') {
rateToTarget = 1;
} else {
warn(`No rate found for target currency: ${targetCode}`);
continue;
}
}
const convertedValue = rubEquivalent * rateToTarget * (1 + fee / 100);
const displayedValue = renderValue(convertedValue, targetCode);
const sign = getCurrencySign(targetCode);
currentConvertedDisplays.push(`${displayedValue}${sign}`);
}
if (currentConvertedDisplays.length === 0) {
warn("No converted displays generated (all targets might be same as active or no rates found).");
if (!suppressOriginal) {
return match;
}
}
replaced = true;
const newPrice = currentConvertedDisplays.join(" ≈ ");
log(`Converted Strings: ${newPrice}`);
// Используем захваченные теги или пустую строку, чтобы сохранить форматирование
const tagToUse = openTag ? openTag : '';
const closingTagToUse = closeTag ? closeTag : '';
let contentToInsert;
if (suppressOriginal) {
contentToInsert = (newPrice.length > 0) ? newPrice : "";
} else {
// Используем 'innerPrice' (Group 3), который содержит только цену (напр. $1.79 USD или 26 155,13₸)
const displayPrice = innerPrice.trim();
contentToInsert = (newPrice.length > 0) ? `${displayPrice} ≈ ${newPrice}` : displayPrice;
}
return `${tagToUse}${contentToInsert}${closingTagToUse}`;
});
if (replaced) {
if (DEBUG_MODE) {
log("Replacing HTML for element:", element);
}
element.innerHTML = newHTML;
}
};
// *** ИСПРАВЛЕННАЯ ФУНКЦИЯ: Currency identification (v1.0.14 fix) ***
const identifyActiveCurrency = () => {
if (activeCurrencyCode) return; // Не запускать дважды
// 1. GStoreItemData (Надежно, если есть)
try {
const formattedSample = GStoreItemData.fnFormatCurrency(12345); // "$123.45 USD" or "123,45 ₸"
if (formattedSample.includes('USD')) {
activeCurrencyCode = "USD";
activeCurrencySign = "$"; // Используем базовый символ $
log(`Currency via GStoreItemData (USD Fix): ${activeCurrencyCode} (${activeCurrencySign})`);
return;
}
const cleanedSample = formattedSample
.replace("123,45", "").replace("123.45", "").trim(); // "₸"
activeCurrencyCode = CURRENCY_SYMBOLS[cleanedSample];
activeCurrencySign = cleanedSample;
if (activeCurrencyCode && !activeCurrencySign) {
activeCurrencySign = ABBREVIATION_SYMBOLS[activeCurrencyCode];
}
log(`Currency via GStoreItemData (Legacy): ${activeCurrencyCode} (${activeCurrencySign})`);
return;
} catch (err) {}
// 2. Wallet Info (Надежно, если залогинен)
try {
const walletCode = getCurrencyById(g_rgWalletInfo?.wallet_currency);
if (walletCode) {
activeCurrencyCode = walletCode.code;
activeCurrencySign = walletCode.sign;
log(`Currency via Wallet Info: ${activeCurrencyCode} (${activeCurrencySign})`);
return;
}
} catch (err) {}
// 3. Meta tag (Надежный, но не всегда есть)
const priceMeta = document.querySelector('meta[itemprop="priceCurrency"]');
if (priceMeta?.content) {
activeCurrencyCode = priceMeta.content;
activeCurrencySign = ABBREVIATION_SYMBOLS[activeCurrencyCode];
if (!activeCurrencySign) {
warn(`Found currency code ${activeCurrencyCode} from meta, but no matching SIGN in ABBREVIATION_SYMBOLS.`);
const curr = getCurrencyByCode(activeCurrencyCode);
if (curr) activeCurrencySign = curr.sign;
}
log(`Currency via Meta Tag: ${activeCurrencyCode} (${activeCurrencySign})`);
return;
}
// 4. Page Scrape (Резервный метод для динамических страниц)
log("Using Page Scrape fallback to find currency...");
const sortedSymbols = SORTED_CURRENCY_SYMBOLS;
// Сканируем *только* селекторы цен, а не все div
const candidateElements = document.querySelectorAll(BASE_PRICE_TARGETS.join(','));
if (candidateElements.length === 0) {
log("Page Scrape: No price elements found yet.");
return; // Не найдено, выходим (повторный запуск будет из observe)
}
for (const [sign, code] of sortedSymbols) {
for (const candidate of candidateElements) {
if (sign === "$" && candidate.innerText.includes("USD")) {
activeCurrencySign = "$";
activeCurrencyCode = "USD";
log(`Currency via Page Scrape (USD Fix): ${activeCurrencyCode} (${activeCurrencySign})`);
return;
}
if (candidate.innerText.includes(sign)) {
activeCurrencySign = sign;
activeCurrencyCode = code;
log(`Currency via Page Scrape (Legacy): ${activeCurrencyCode} (${activeCurrencySign})`);
return;
}
}
}
warn("Could not identify active currency.");
};
// *** ИСПРАВЛЕННЫЙ СПИСОК: Price element selectors (v0.8.15) ***
const BASE_PRICE_TARGETS = [
"#header_wallet_balance",
"div[class*=StoreSalePriceBox]",
"div[class*='StoreSalePriceWidget_']",
"div[class*='StoreOriginalPrice_']",
".game_purchase_price",
// --- Селекторы Списка желаемого ---
"div._3j4dI1yA7cRfCvK8h406OB",
"div.DOnsaVcV0Is-",
"div._79DIT7RUQ5g-",
// --- Селекторы Корзины (Новые) ---
"._2WLaY5TxjBGVyuWe_6KS3N", // Итого (total)
".k2r-13oII_503_1b0Bf", // Подытог (subtotal)
"._38-m1g-YVkf-nCAcHJ1NbP", // Цена элемента (item price)
// ---
".steamdb_prices_top",
".discount_final_price:not(:has(> .your_price_label))",
".discount_final_price > div:not([class])",
".search_price",
".price:not(.spotlight_body):not(.similar_grid_price)",
".match_subtitle",
".game_area_dlc_price:not(:has(> *))",
".savings.bundle_savings",
".wallet_column",
".wht_total",
".normal_price:not(.market_table_value)",
".sale_price",
".StoreSalePriceWidgetContainer:not(.Discounted) div",
".StoreSalePriceWidgetContainer.Discounted div:nth-child(2) > div:nth-child(2)",
"#marketWalletBalanceAmount",
".market_commodity_order_summary > span:nth-child(2)",
".market_commodity_orders_table tr > td:first-child",
".market_listing_price_with_fee",
".market_activity_price",
".item_market_actions > div > div:nth-child(2)",
".wishlist_row .discount_final_price",
".wishlist_row .discount_original_price",
".wishlist_row .normal_price",
".wishlist_row .price",
".ws_row_bold .price",
"[class*='wishlist'] .price",
".wishlist_row div",
];
// Price application handler
const processPrices = elements => {
if (!elements || elements.length === 0) return;
for (const elem of elements) {
applyConversion(elem);
}
};
// DOM mutation watcher
const initializeWatcher = () => {
log("Initializing MutationObserver...");
const pendingNodes = new Set();
let scheduled = false;
const scheduleProcess = () => {
if (scheduled) return;
scheduled = true;
const run = () => {
scheduled = false;
if (pendingNodes.size === 0) return;
const nodes = Array.from(pendingNodes);
pendingNodes.clear();
// Повторное определение, если валюта не найдена
if (!activeCurrencyCode) {
log("Watcher trying to find active currency...");
identifyActiveCurrency(); // Повторный вызов
if (activeCurrencyCode) {
// Валюта найдена, запускаем логику конвертации
startConversionLogic();
} else {
return;
}
}
const foundPriceNodes = new Set();
for (const added of nodes) {
if (added.nodeType !== Node.ELEMENT_NODE) continue;
if (added.matches && added.matches(finalPriceSelectorString)) {
foundPriceNodes.add(added);
}
if (added.querySelectorAll) {
const matches = added.querySelectorAll(finalPriceSelectorString);
for (const match of matches) {
foundPriceNodes.add(match);
}
}
}
if (foundPriceNodes.size > 0) {
processPrices(Array.from(foundPriceNodes));
}
};
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(run);
} else {
setTimeout(run, 0);
}
};
const watcher = new MutationObserver(mutations => {
try {
for (const change of mutations) {
if (!change.addedNodes || change.addedNodes.length === 0) continue;
for (const node of change.addedNodes) {
pendingNodes.add(node);
}
}
if (pendingNodes.size > 0) {
scheduleProcess();
}
} catch (err) {
error("Error in MutationObserver:", err);
}
});
watcher.observe(document.body, { childList: true, subtree: true });
};
// CSS selector generation
const generateSelectorForElement = (el) => {
if ((!el.classList || el.classList.length === 0) && el.parentElement) {
el = el.parentElement;
}
if (!el || el === document.body) return null;
if (el.id) {
const selector = `#${el.id}`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
}
const classes = Array.from(el.classList).filter(c => c !== 'done');
if (classes.length > 0) {
return `${el.tagName.toLowerCase()}.${classes.join('.')}`;
}
if (el.parentElement && el.parentElement !== document.body) {
const parentClasses = Array.from(el.parentElement.classList).filter(c => c !== 'done');
if (parentClasses.length > 0) {
return `${el.parentElement.tagName.toLowerCase()}.${parentClasses.join('.')} > ${el.tagName.toLowerCase()}`;
}
}
return null;
};
// Add selector by click
const findAndAddSelector = (callbacks = {}) => {
const { onStatus, onDone } = callbacks;
if (typeof onStatus === "function") {
onStatus(getSettingsText("addMissingInstructions"));
}
const clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
document.body.removeEventListener('click', clickHandler, true);
const targetElement = e.target;
const targetText = targetElement?.textContent || "";
if (!activeCurrencySign || !targetText.includes(activeCurrencySign)) {
error("Invalid click", { activeSign: activeCurrencySign, text: targetText });
if (typeof onStatus === "function") {
onStatus(getSettingsText("invalidPriceClick"));
}
if (typeof onDone === "function") onDone(false);
return;
}
const newSelector = generateSelectorForElement(targetElement);
if (newSelector) {
let currentSelectors = GM_getValue(USER_SELECTORS_ID, []);
if (currentSelectors.includes(newSelector)) {
if (typeof onStatus === "function") {
onStatus(getSettingsText("selectorExists"));
}
if (typeof onDone === "function") onDone(false);
return;
}
currentSelectors.push(newSelector);
GM_setValue(USER_SELECTORS_ID, currentSelectors);
userAddedSelectors = currentSelectors;
buildFinalSelectorString();
if (typeof onStatus === "function") {
onStatus(getSettingsText("addedSelector", newSelector));
}
if (typeof onDone === "function") onDone(true);
} else {
if (typeof onStatus === "function") {
onStatus(getSettingsText("priceNotFound"));
}
if (typeof onDone === "function") onDone(false);
}
};
document.body.addEventListener('click', clickHandler, true);
};
const SETTINGS_PANEL_ID = "scc-settings-panel";
const SETTINGS_BACKDROP_ID = "scc-settings-backdrop";
const SETTINGS_I18N = {
ru: {
menuSettings: "Настройки",
panelTitle: "Steam Currency Converter - Настройки",
actions: "Действия",
runNow: "Запустить вручную",
addByClick: "Добавить селектор кликом",
export: "Экспорт",
import: "Импорт",
configuration: "Конфигурация",
language: "Язык",
languageAuto: "Auto",
themeToggleTitle: "Тема панели",
themeToggleAuto: "Авто",
themeToggleLight: "Светлая",
themeToggleDark: "Темная",
currencyColumn: "Валюта",
feeColumn: "Комиссия",
hideOriginal: "Скрывать оригинальную цену",
targetCurrencies: "Целевые валюты",
customSelectors: "Пользовательские селекторы",
add: "Добавить",
baseSelectorToggles: "Базовые селекторы (по умолчанию ВКЛ)",
save: "Сохранить",
remove: "Удалить",
noSelectors: "Нет добавленных селекторов.",
selectorPlaceholder: ".wishlist_row .price",
removedSelector: selector => `Удален селектор: ${selector}`,
runningManual: "Запускаю конвертацию вручную...",
manualDone: "Ручной запуск завершен.",
selectorExists: "Селектор уже существует.",
addedSelector: selector => `Добавлен селектор: ${selector}`,
exported: "Настройки экспортированы.",
imported: "Настройки импортированы.",
importFailed: err => `Ошибка импорта: ${err}`,
savedReloading: "Сохранено. Перезагрузка страницы...",
tipActions: "Быстрые действия: ручной запуск, добавление селектора и перенос настроек.",
tipConfig: "Основные параметры отображения и конвертации цен.",
tipTargets: "Включите нужные валюты, задайте комиссию в процентах.",
tipSelectors: "Добавляйте селекторы, если на странице пропускаются цены.",
tipSave: "Сохраняет изменения и перезагружает страницу.",
displayOrder: "Порядок отображения",
tipOrder: "Используйте стрелки для изменения порядка конвертированных цен.",
orderEmpty: "Включите валюты выше.",
addMissingInstructions: "Нажмите на неконвертированную цену. Страница перезагрузится.",
invalidPriceClick: "Выбранный элемент не содержит цену с символом активной валюты.",
priceNotFound: "Не удалось создать полезный селектор для этого элемента."
},
en: {
menuSettings: "Settings",
panelTitle: "Steam Currency Converter - Settings",
actions: "Actions",
runNow: "Run manually",
addByClick: "Add selector by click",
export: "Export",
import: "Import",
configuration: "Configuration",
language: "Language",
languageAuto: "Auto",
themeToggleTitle: "Panel theme",
themeToggleAuto: "Auto",
themeToggleLight: "Light",
themeToggleDark: "Dark",
currencyColumn: "Currency",
feeColumn: "Fee",
hideOriginal: "Hide original price",
targetCurrencies: "Target currencies",
customSelectors: "Custom selectors",
add: "Add",
baseSelectorToggles: "Base selector toggles (default ON)",
save: "Save",
remove: "Remove",
noSelectors: "No custom selectors added.",
selectorPlaceholder: ".wishlist_row .price",
removedSelector: selector => `Removed selector: ${selector}`,
runningManual: "Running conversion manually...",
manualDone: "Manual run finished.",
selectorExists: "Selector already exists.",
addedSelector: selector => `Added selector: ${selector}`,
exported: "Settings exported.",
imported: "Settings imported.",
importFailed: err => `Import failed: ${err}`,
savedReloading: "Saved. Reloading page...",
tipActions: "Quick actions: manual run, selector capture, import/export settings.",
tipConfig: "Main parameters for displaying and converting prices.",
tipTargets: "Enable target currencies and set fee percent.",
tipSelectors: "Add selectors when some prices are not detected.",
tipSave: "Saves changes and reloads the page.",
displayOrder: "Display order",
tipOrder: "Use arrows to change display order of converted prices.",
orderEmpty: "Enable currencies above.",
addMissingInstructions: "Click on the unconverted price. The page will reload.",
invalidPriceClick: "The selected element does not contain a price with the active currency symbol.",
priceNotFound: "Could not generate a useful selector for this element."
}
};
const detectBrowserLanguage = () => {
const navLanguage = (navigator.language || navigator.userLanguage || "en").toLowerCase();
return navLanguage.startsWith("ru") ? "ru" : "en";
};
const parseCssColorToRgb = (value) => {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "transparent" || normalized === "rgba(0, 0, 0, 0)") return null;
const rgbMatch = normalized.match(/^rgba?\(([^)]+)\)$/);
if (rgbMatch) {
const parts = rgbMatch[1].split(",").map(part => parseFloat(part.trim()));
if (parts.length >= 3 && parts.slice(0, 3).every(Number.isFinite)) {
return { r: parts[0], g: parts[1], b: parts[2] };
}
return null;
}
const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1];
if (hex.length === 3) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16)
};
}
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
return null;
};
const getColorLuminance = (rgb) => {
if (!rgb) return 1;
return (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
};
const isColorDark = (colorValue) => {
const rgb = parseCssColorToRgb(colorValue);
if (!rgb) return false;
return getColorLuminance(rgb) < 0.45;
};
const detectPageDarkTheme = () => {
const html = document.documentElement;
const body = document.body;
if (!html || !body) return false;
const classMarkers = ["night-theme", "dark", "theme-dark", "dark-mode"];
for (const marker of classMarkers) {
if (html.classList.contains(marker) || body.classList.contains(marker)) {
return true;
}
}
const attrMarkers = ["data-theme", "theme", "color-theme"];
for (const attr of attrMarkers) {
const htmlTheme = (html.getAttribute(attr) || "").toLowerCase();
const bodyTheme = (body.getAttribute(attr) || "").toLowerCase();
if (htmlTheme.includes("dark") || bodyTheme.includes("dark")) {
return true;
}
}
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor && isColorDark(metaThemeColor.getAttribute("content"))) {
return true;
}
const probeSelectors = [
"body",
"html",
"#responsive_page_frame",
"#global_header",
".responsive_page_frame",
".responsive_page_content"
];
let darkVotes = 0;
let checked = 0;
for (const selector of probeSelectors) {
const node = selector === "body" ? body : (selector === "html" ? html : document.querySelector(selector));
if (!node) continue;
const bg = window.getComputedStyle(node).backgroundColor;
const rgb = parseCssColorToRgb(bg);
if (!rgb) continue;
checked += 1;
if (getColorLuminance(rgb) < 0.45) {
darkVotes += 1;
}
}
if (checked === 0) return false;
return darkVotes >= Math.ceil(checked / 2);
};
const resolveLanguage = (mode) => {
if (mode === "ru" || mode === "en") return mode;
return detectBrowserLanguage();
};
const getSettingsText = (key, ...args) => {
const dict = SETTINGS_I18N[userLanguage] || SETTINGS_I18N.en;
const value = dict[key];
if (typeof value === "function") return value(...args);
return value || key;
};
const loadLanguagePreference = () => {
const storedMode = GM_getValue(LANG_MODE_ID, null);
if (storedMode === "auto" || storedMode === "ru" || storedMode === "en") {
languageMode = storedMode;
} else {
const legacyLang = GM_getValue(LANG_ID, null);
languageMode = legacyLang === "ru" || legacyLang === "en" ? legacyLang : "auto";
GM_setValue(LANG_MODE_ID, languageMode);
}
userLanguage = resolveLanguage(languageMode);
GM_setValue(LANG_ID, userLanguage);
};
const loadThemePreference = () => {
const storedThemeMode = GM_getValue(THEME_MODE_ID, "auto");
panelThemeMode = ["auto", "light", "dark"].includes(storedThemeMode) ? storedThemeMode : "auto";
};
const resolvePanelTheme = (mode) => {
if (mode === "dark" || mode === "light") return mode;
return detectPageDarkTheme() ? "dark" : "light";
};
const applyPanelTheme = (panel, mode) => {
const resolvedTheme = resolvePanelTheme(mode);
panel.classList.remove("scc-theme-light", "scc-theme-dark");
panel.classList.add(`scc-theme-${resolvedTheme}`);
};
let settingsTooltipEl = null;
const ensureSettingsTooltip = () => {
if (settingsTooltipEl && document.body.contains(settingsTooltipEl)) {
return settingsTooltipEl;
}
settingsTooltipEl = document.createElement("div");
settingsTooltipEl.id = "scc-help-tooltip";
settingsTooltipEl.className = "scc-help-tooltip";
document.body.append(settingsTooltipEl);
return settingsTooltipEl;
};
const positionSettingsTooltip = (tooltip, anchor) => {
const anchorRect = anchor.getBoundingClientRect();
const gap = 8;
let top = anchorRect.bottom + gap;
let left = anchorRect.left;
let preferAbove = false;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const safeMaxWidth = Math.max(160, viewportWidth - (gap * 2));
tooltip.style.maxWidth = `${safeMaxWidth}px`;
tooltip.style.left = "0px";
tooltip.style.top = "0px";
const tooltipWidth = tooltip.offsetWidth;
const tooltipHeight = tooltip.offsetHeight;
if (left + tooltipWidth > viewportWidth - gap) {
left = viewportWidth - tooltipWidth - gap;
}
if (left < gap) {
left = gap;
}
if (top + tooltipHeight > viewportHeight - gap) {
top = anchorRect.top - tooltipHeight - gap;
preferAbove = true;
}
if (top < gap) {
top = gap;
}
tooltip.classList.toggle("is-above", preferAbove);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
};
const attachHelpTooltipHandlers = (panel) => {
const tooltip = ensureSettingsTooltip();
let activeHelp = null;
let showTimer = null;
const hideTooltip = () => {
if (showTimer) {
clearTimeout(showTimer);
showTimer = null;
}
activeHelp = null;
tooltip.classList.remove("is-visible");
tooltip.classList.remove("is-above");
};
panel.addEventListener("mouseover", event => {
const help = event.target.closest(".scc-help");
if (!help || !panel.contains(help)) return;
const text = help.getAttribute("data-tooltip") || "";
if (!text) return;
activeHelp = help;
tooltip.textContent = text;
if (showTimer) clearTimeout(showTimer);
showTimer = setTimeout(() => {
if (activeHelp !== help) return;
tooltip.classList.add("is-visible");
positionSettingsTooltip(tooltip, help);
}, 180);
});
panel.addEventListener("mousemove", event => {
if (!activeHelp || !tooltip.classList.contains("is-visible")) return;
const help = event.target.closest(".scc-help");
if (!help || help !== activeHelp) return;
positionSettingsTooltip(tooltip, activeHelp);
});
panel.addEventListener("mouseout", event => {
if (!activeHelp) return;
const fromHelp = event.target.closest(".scc-help");
if (!fromHelp) return;
if (event.relatedTarget && fromHelp.contains(event.relatedTarget)) return;
hideTooltip();
});
panel.addEventListener("mouseleave", hideTooltip);
window.addEventListener("scroll", hideTooltip, true);
};
const getNextThemeMode = (current) => {
if (current === "auto") return "light";
if (current === "light") return "dark";
return "auto";
};
const getThemeToggleMeta = (mode, langCode) => {
const dict = SETTINGS_I18N[langCode] || SETTINGS_I18N.en;
if (mode === "light") return { icon: "🔆", label: dict.themeToggleLight };
if (mode === "dark") return { icon: "🌙", label: dict.themeToggleDark };
return { icon: "🌓", label: dict.themeToggleAuto };
};
const updateThemeToggleButton = (panel, mode, langCode) => {
const btn = panel.querySelector("#scc-theme-toggle-btn");
if (!btn) return;
const dict = SETTINGS_I18N[langCode] || SETTINGS_I18N.en;
const meta = getThemeToggleMeta(mode, langCode);
btn.textContent = meta.icon;
btn.title = `${dict.themeToggleTitle}: ${meta.label}`;
btn.setAttribute("aria-label", `${dict.themeToggleTitle}: ${meta.label}`);
};
const applyPanelLocalization = (panel, langCode) => {
const dict = SETTINGS_I18N[langCode] || SETTINGS_I18N.en;
const setText = (selector, value) => {
const el = panel.querySelector(selector);
if (el) el.textContent = value;
};
const setTip = (selector, value) => {
const el = panel.querySelector(selector);
if (el) {
el.setAttribute("data-tooltip", value);
el.removeAttribute("title");
el.setAttribute("aria-label", value);
}
};
setText("#scc-panel-title", dict.panelTitle);
setText("#scc-actions-title", dict.actions);
if (DEBUG_MODE) setText("#scc-run-now", dict.runNow);
setText("#scc-add-selector-click", dict.addByClick);
setText("#scc-export-settings", dict.export);
setText("#scc-import-settings-btn", dict.import);
setText("#scc-config-title", dict.configuration);
setText("#scc-currency-col-label", dict.currencyColumn);
setText("#scc-fee-col-label", dict.feeColumn);
setText("#scc-hide-original-label", dict.hideOriginal);
setText("#scc-target-currencies-title", dict.targetCurrencies);
setText("#scc-currency-order-title", dict.displayOrder);
setText("#scc-custom-selectors-title", dict.customSelectors);
setText("#scc-add-selector-manual", dict.add);
setText("#scc-base-selectors-title", dict.baseSelectorToggles);
setText("#scc-save-settings", dict.save);
const selectorInput = panel.querySelector("#scc-new-selector");
if (selectorInput) selectorInput.placeholder = dict.selectorPlaceholder;
const autoOption = panel.querySelector('#scc-lang-mode option[value="auto"]');
if (autoOption) autoOption.textContent = dict.languageAuto;
setTip("#scc-actions-help", dict.tipActions);
setTip("#scc-config-help", dict.tipConfig);
setTip("#scc-targets-help", dict.tipTargets);
setTip("#scc-order-help", dict.tipOrder);
setTip("#scc-selectors-help", dict.tipSelectors);
setTip("#scc-save-help", dict.tipSave);
updateThemeToggleButton(panel, panelThemeMode, langCode);
};
const buildFinalSelectorString = () => {
const disabledBaseSelectors = new Set(GM_getValue(DISABLED_BASE_SELECTORS_ID, []));
const enabledBaseSelectors = BASE_PRICE_TARGETS.filter(sel => !disabledBaseSelectors.has(sel));
const uniqueSelectors = Array.from(new Set([...enabledBaseSelectors, ...userAddedSelectors]));
finalPriceSelectorString = uniqueSelectors.map(sel => `${sel}:not(.done)`).join(", ");
return finalPriceSelectorString;
};
const renderCurrencyList = (container, targetsSnapshot) => {
container.innerHTML = "";
for (const currency of SUPPORTED_CURRENCIES) {
const row = document.createElement("div");
row.className = "scc-list-row";
const left = document.createElement("div");
left.className = "scc-row-left";
left.textContent = `${currency.code} (${currency.sign})`;
const controls = document.createElement("div");
controls.className = "scc-row-controls";
const switchWrap = document.createElement("label");
switchWrap.className = "scc-switch";
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
enabledInput.dataset.code = currency.code;
enabledInput.className = "scc-currency-enabled";
enabledInput.checked = Object.prototype.hasOwnProperty.call(targetsSnapshot, currency.code);
const slider = document.createElement("span");
slider.className = "scc-slider";
switchWrap.append(enabledInput, slider);
const feeInput = document.createElement("input");
feeInput.type = "number";
feeInput.min = "0";
feeInput.step = "0.1";
feeInput.className = "scc-input scc-fee-input";
feeInput.dataset.code = currency.code;
feeInput.value = (targetsSnapshot[currency.code] ?? 0).toString();
feeInput.disabled = !enabledInput.checked;
enabledInput.addEventListener("change", () => {
feeInput.disabled = !enabledInput.checked;
});
controls.append(feeInput, switchWrap);
row.append(left, controls);
container.append(row);
}
};
const renderCurrencyOrder = (container, order, langCode = userLanguage) => {
const dict = SETTINGS_I18N[langCode] || SETTINGS_I18N.en;
container.innerHTML = "";
if (order.length === 0) {
const empty = document.createElement("div");
empty.className = "scc-empty";
empty.textContent = dict.orderEmpty;
container.append(empty);
return;
}
order.forEach((code, index) => {
const currency = CURRENCY_BY_CODE.get(code);
if (!currency) return;
const row = document.createElement("div");
row.className = "scc-list-row scc-order-row";
const label = document.createElement("span");
label.className = "scc-row-left";
label.textContent = `${currency.code} (${currency.sign})`;
const controls = document.createElement("div");
controls.className = "scc-row-controls";
const upBtn = document.createElement("button");
upBtn.type = "button";
upBtn.className = "scc-btn scc-btn-secondary scc-order-btn";
upBtn.textContent = "▲";
upBtn.dataset.code = code;
upBtn.dataset.dir = "up";
upBtn.disabled = index === 0;
const downBtn = document.createElement("button");
downBtn.type = "button";
downBtn.className = "scc-btn scc-btn-secondary scc-order-btn";
downBtn.textContent = "▼";
downBtn.dataset.code = code;
downBtn.dataset.dir = "down";
downBtn.disabled = index === order.length - 1;
controls.append(upBtn, downBtn);
row.append(label, controls);
container.append(row);
});
};
const renderSelectorList = (container, selectors, langCode = userLanguage) => {
const dict = SETTINGS_I18N[langCode] || SETTINGS_I18N.en;
container.innerHTML = "";
if (!selectors.length) {
const empty = document.createElement("div");
empty.className = "scc-empty";
empty.textContent = dict.noSelectors;
container.append(empty);
return;
}
selectors.forEach((selector, index) => {
const row = document.createElement("div");
row.className = "scc-list-row";
const text = document.createElement("div");
text.className = "scc-selector-text";
text.textContent = `${index + 1}. ${selector}`;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "scc-btn scc-btn-secondary";
removeBtn.textContent = dict.remove;
removeBtn.dataset.removeSelector = selector;
row.append(text, removeBtn);
container.append(row);
});
};
const renderBaseSelectorToggleList = (container, disabledSelectors) => {
container.innerHTML = "";
const disabledSet = new Set(disabledSelectors);
for (const selector of BASE_PRICE_TARGETS) {
const row = document.createElement("div");
row.className = "scc-list-row";
const text = document.createElement("div");
text.className = "scc-selector-text";
text.textContent = selector;
const switchWrap = document.createElement("label");
switchWrap.className = "scc-switch";
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.className = "scc-base-selector-toggle";
toggle.dataset.selector = selector;
toggle.checked = !disabledSet.has(selector);
const slider = document.createElement("span");
slider.className = "scc-slider";
switchWrap.append(toggle, slider);
row.append(text, switchWrap);
container.append(row);
}
};
const loadSettingsToPanel = (panel) => {
const languageInput = panel.querySelector("#scc-lang-mode");
const suppressInput = panel.querySelector("#scc-suppress-original");
const currencyList = panel.querySelector("#scc-currency-list");
const selectorsList = panel.querySelector("#scc-custom-selectors-list");
const baseSelectorsList = panel.querySelector("#scc-base-selectors-list");
const storedTargets = GM_getValue(TARGETS_ID, {});
const storedLangMode = GM_getValue(LANG_MODE_ID, languageMode);
const storedThemeMode = GM_getValue(THEME_MODE_ID, panelThemeMode);
const storedSuppress = GM_getValue(SUPPRESS_ID, false);
const storedCustomSelectors = GM_getValue(USER_SELECTORS_ID, []);
const disabledBaseSelectors = GM_getValue(DISABLED_BASE_SELECTORS_ID, []);
languageInput.value = ["auto", "ru", "en"].includes(storedLangMode) ? storedLangMode : "auto";
panelThemeMode = ["auto", "light", "dark"].includes(storedThemeMode) ? storedThemeMode : "auto";
const panelLang = resolveLanguage(languageInput.value);
applyPanelTheme(panel, panelThemeMode);
updateThemeToggleButton(panel, panelThemeMode, panelLang);
applyPanelLocalization(panel, panelLang);
suppressInput.checked = storedSuppress;
renderCurrencyList(currencyList, storedTargets);
// Загружаем порядок валют и синхронизируем с включёнными
const storedOrder = GM_getValue(CURRENCY_ORDER_ID, []);
const enabledCodes = Object.keys(storedTargets);
panelCurrencyOrder = storedOrder.filter(c => enabledCodes.includes(c));
for (const code of enabledCodes) {
if (!panelCurrencyOrder.includes(code)) panelCurrencyOrder.push(code);
}
const orderList = panel.querySelector("#scc-currency-order-list");
if (orderList) renderCurrencyOrder(orderList, panelCurrencyOrder, panelLang);
renderSelectorList(selectorsList, storedCustomSelectors, panelLang);
if (baseSelectorsList) {
renderBaseSelectorToggleList(baseSelectorsList, disabledBaseSelectors);
}
};
const saveSettingsFromPanel = (panel) => {
const selectedLanguageMode = panel.querySelector("#scc-lang-mode").value;
const nextLanguageMode = ["auto", "ru", "en"].includes(selectedLanguageMode) ? selectedLanguageMode : "auto";
const language = resolveLanguage(nextLanguageMode);
const suppress = panel.querySelector("#scc-suppress-original").checked;
const nextTargets = {};
panel.querySelectorAll(".scc-currency-enabled").forEach(toggle => {
const code = toggle.dataset.code;
if (!toggle.checked) return;
const feeInput = panel.querySelector(`.scc-fee-input[data-code="${code}"]`);
const fee = parseFloat(feeInput?.value ?? "0");
nextTargets[code] = Number.isFinite(fee) && fee >= 0 ? fee : 0;
});
let disabledBaseSelectors = GM_getValue(DISABLED_BASE_SELECTORS_ID, []);
if (panel.querySelector("#scc-base-selectors-list")) {
disabledBaseSelectors = [];
panel.querySelectorAll(".scc-base-selector-toggle").forEach(toggle => {
if (!toggle.checked) {
disabledBaseSelectors.push(toggle.dataset.selector);
}
});
}
GM_setValue(LANG_MODE_ID, nextLanguageMode);
GM_setValue(THEME_MODE_ID, panelThemeMode);
GM_setValue(LANG_ID, language);
GM_setValue(SUPPRESS_ID, suppress);
GM_setValue(TARGETS_ID, nextTargets);
GM_setValue(CURRENCY_ORDER_ID, panelCurrencyOrder);
GM_setValue(DISABLED_BASE_SELECTORS_ID, disabledBaseSelectors);
languageMode = nextLanguageMode;
userLanguage = language;
suppressOriginal = suppress;
targetCurrencies = nextTargets;
targetCurrencyOrder = [...panelCurrencyOrder];
userAddedSelectors = GM_getValue(USER_SELECTORS_ID, []);
buildFinalSelectorString();
};
const openSettingsPanel = () => {
let backdrop = document.getElementById(SETTINGS_BACKDROP_ID);
let panel = document.getElementById(SETTINGS_PANEL_ID);
if (!backdrop || !panel) {
backdrop = document.createElement("div");
backdrop.id = SETTINGS_BACKDROP_ID;
backdrop.className = "scc-settings-backdrop";
const baseSelectorsSection = DEBUG_MODE ? `
<h4>
<span id="scc-base-selectors-title">Base selector toggles (default ON)</span>
</h4>
<div id="scc-base-selectors-list" class="scc-scroll-list"></div>
` : "";
panel = document.createElement("aside");
panel.id = SETTINGS_PANEL_ID;
panel.className = "scc-settings-panel";
panel.innerHTML = `
<div class="scc-panel-header">
<h2 class="scc-panel-title" id="scc-panel-title">Steam Currency Converter - Settings</h2>
<div class="scc-header-actions">
<select id="scc-lang-mode" class="scc-input scc-lang-compact" aria-label="Language">
<option value="auto">Auto</option>
<option value="ru">RU</option>
<option value="en">EN</option>
</select>
<button type="button" class="scc-theme-toggle-btn" id="scc-theme-toggle-btn" title="Panel theme">A</button>
<button type="button" class="scc-close-btn" id="scc-close-panel">✖</button>
</div>
</div>
<div class="scc-panel-content">
<section class="scc-section">
<h3 class="scc-title-row">
<span id="scc-actions-title">Actions</span>
<span class="scc-help" id="scc-actions-help" data-tooltip="Actions help">?</span>
</h3>
<div class="scc-actions-grid">
${DEBUG_MODE ? '<button type="button" class="scc-btn scc-btn-secondary" id="scc-run-now">Run manually</button>' : ''}
<button type="button" class="scc-btn scc-btn-secondary" id="scc-add-selector-click">Add selector by click</button>
<button type="button" class="scc-btn scc-btn-secondary" id="scc-export-settings">Export</button>
<button type="button" class="scc-btn scc-btn-secondary" id="scc-import-settings-btn">Import</button>
<input type="file" id="scc-import-file" accept="application/json" style="display:none">
</div>
<div id="scc-action-status" class="scc-status"></div>
</section>
<section class="scc-section">
<h3 class="scc-title-row">
<span id="scc-config-title">Configuration</span>
<span class="scc-help" id="scc-config-help" data-tooltip="Configuration help">?</span>
</h3>
<label class="scc-field scc-inline">
<span id="scc-hide-original-label">Hide original price</span>
<label class="scc-switch">
<input type="checkbox" id="scc-suppress-original" checked>
<span class="scc-slider"></span>
</label>
</label>
<h4 class="scc-title-row">
<span id="scc-target-currencies-title">Target currencies</span>
<span class="scc-help" id="scc-targets-help" data-tooltip="Target currencies help">?</span>
</h4>
<div class="scc-currency-head">
<span id="scc-currency-col-label">Currency</span>
<span id="scc-fee-col-label">Fee</span>
<span></span>
</div>
<div id="scc-currency-list" class="scc-scroll-list"></div>
<h4 class="scc-title-row">
<span id="scc-currency-order-title">Display order</span>
<span class="scc-help" id="scc-order-help" data-tooltip="Order help">?</span>
</h4>
<div id="scc-currency-order-list" class="scc-scroll-list scc-order-list"></div>
<h4 class="scc-title-row">
<span id="scc-custom-selectors-title">Custom selectors</span>
<span class="scc-help" id="scc-selectors-help" data-tooltip="Custom selectors help">?</span>
</h4>
<div class="scc-add-row">
<input type="text" id="scc-new-selector" class="scc-input" placeholder=".wishlist_row .price">
<button type="button" class="scc-btn scc-btn-secondary" id="scc-add-selector-manual">Add</button>
</div>
<div id="scc-custom-selectors-list" class="scc-scroll-list"></div>
${baseSelectorsSection}
</section>
<section class="scc-save-area">
<span class="scc-help" id="scc-save-help" data-tooltip="Save help">?</span>
<button type="button" class="scc-btn scc-btn-primary" id="scc-save-settings">Save</button>
<div id="scc-save-status" class="scc-status"></div>
</section>
</div>
`;
document.body.append(backdrop, panel);
const closePanel = () => {
backdrop.classList.remove("is-open");
panel.classList.remove("is-open");
};
backdrop.addEventListener("click", closePanel);
panel.querySelector("#scc-close-panel").addEventListener("click", closePanel);
const setActionStatus = (message) => {
panel.querySelector("#scc-action-status").textContent = message;
};
const setSaveStatus = (message) => {
panel.querySelector("#scc-save-status").textContent = message;
};
attachHelpTooltipHandlers(panel);
const exportBtn = panel.querySelector("#scc-export-settings");
const importBtn = panel.querySelector("#scc-import-settings-btn");
const importFile = panel.querySelector("#scc-import-file");
const langModeSelect = panel.querySelector("#scc-lang-mode");
const themeToggleBtn = panel.querySelector("#scc-theme-toggle-btn");
const addManualSelectorBtn = panel.querySelector("#scc-add-selector-manual");
const runNowBtn = panel.querySelector("#scc-run-now");
const addByClickBtn = panel.querySelector("#scc-add-selector-click");
const saveBtn = panel.querySelector("#scc-save-settings");
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (!panel.classList.contains("is-open")) return;
closePanel();
});
const syncThemeInAutoMode = () => {
if (!panel.classList.contains("is-open")) return;
if (panelThemeMode !== "auto") return;
const panelLang = resolveLanguage(langModeSelect.value);
applyPanelTheme(panel, panelThemeMode);
updateThemeToggleButton(panel, panelThemeMode, panelLang);
};
const themeObserver = new MutationObserver(syncThemeInAutoMode);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme", "theme", "color-theme"]
});
if (document.body) {
themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme", "theme", "color-theme"]
});
}
langModeSelect.addEventListener("change", () => {
const previewLang = resolveLanguage(langModeSelect.value);
applyPanelLocalization(panel, previewLang);
renderSelectorList(panel.querySelector("#scc-custom-selectors-list"), GM_getValue(USER_SELECTORS_ID, []), previewLang);
const orderListEl = panel.querySelector("#scc-currency-order-list");
if (orderListEl) renderCurrencyOrder(orderListEl, panelCurrencyOrder, previewLang);
});
themeToggleBtn.addEventListener("click", () => {
panelThemeMode = getNextThemeMode(panelThemeMode);
const panelLang = resolveLanguage(langModeSelect.value);
applyPanelTheme(panel, panelThemeMode);
updateThemeToggleButton(panel, panelThemeMode, panelLang);
});
// Синхронизация порядка при включении/выключении валют
panel.addEventListener("change", (e) => {
const toggle = e.target.closest(".scc-currency-enabled");
if (!toggle) return;
const code = toggle.dataset.code;
if (toggle.checked) {
if (!panelCurrencyOrder.includes(code)) panelCurrencyOrder.push(code);
} else {
panelCurrencyOrder = panelCurrencyOrder.filter(c => c !== code);
}
const orderListEl = panel.querySelector("#scc-currency-order-list");
if (orderListEl) renderCurrencyOrder(orderListEl, panelCurrencyOrder, resolveLanguage(langModeSelect.value));
});
// Кнопки ▲/▼ для изменения порядка
panel.addEventListener("click", (e) => {
const orderBtn = e.target.closest(".scc-order-btn");
if (orderBtn) {
const code = orderBtn.dataset.code;
const dir = orderBtn.dataset.dir;
const idx = panelCurrencyOrder.indexOf(code);
if (idx === -1) return;
if (dir === "up" && idx > 0) {
[panelCurrencyOrder[idx - 1], panelCurrencyOrder[idx]] =
[panelCurrencyOrder[idx], panelCurrencyOrder[idx - 1]];
} else if (dir === "down" && idx < panelCurrencyOrder.length - 1) {
[panelCurrencyOrder[idx + 1], panelCurrencyOrder[idx]] =
[panelCurrencyOrder[idx], panelCurrencyOrder[idx + 1]];
}
const orderListEl = panel.querySelector("#scc-currency-order-list");
if (orderListEl) renderCurrencyOrder(orderListEl, panelCurrencyOrder, resolveLanguage(langModeSelect.value));
return;
}
const removeSelectorBtn = e.target.closest("[data-remove-selector]");
if (!removeSelectorBtn) return;
const panelLang = resolveLanguage(langModeSelect.value);
const selectorToRemove = removeSelectorBtn.dataset.removeSelector;
const currentSelectors = GM_getValue(USER_SELECTORS_ID, []);
const nextSelectors = currentSelectors.filter(sel => sel !== selectorToRemove);
GM_setValue(USER_SELECTORS_ID, nextSelectors);
userAddedSelectors = nextSelectors;
renderSelectorList(panel.querySelector("#scc-custom-selectors-list"), nextSelectors, panelLang);
setActionStatus(getSettingsText("removedSelector", selectorToRemove));
});
if (runNowBtn) {
runNowBtn.addEventListener("click", async () => {
setActionStatus(getSettingsText("runningManual"));
conversionLogicStarted = false;
await startConversionLogic();
processPrices(document.querySelectorAll(finalPriceSelectorString));
setActionStatus(getSettingsText("manualDone"));
});
}
addByClickBtn.addEventListener("click", () => {
findAndAddSelector({
onStatus: setActionStatus,
onDone: () => {
const panelLang = resolveLanguage(langModeSelect.value);
const selectors = GM_getValue(USER_SELECTORS_ID, []);
renderSelectorList(panel.querySelector("#scc-custom-selectors-list"), selectors, panelLang);
}
});
});
addManualSelectorBtn.addEventListener("click", () => {
const input = panel.querySelector("#scc-new-selector");
const value = (input.value || "").trim();
if (!value) return;
const currentSelectors = GM_getValue(USER_SELECTORS_ID, []);
if (currentSelectors.includes(value)) {
setActionStatus(getSettingsText("selectorExists"));
return;
}
currentSelectors.push(value);
GM_setValue(USER_SELECTORS_ID, currentSelectors);
userAddedSelectors = currentSelectors;
renderSelectorList(panel.querySelector("#scc-custom-selectors-list"), currentSelectors, resolveLanguage(langModeSelect.value));
input.value = "";
setActionStatus(getSettingsText("addedSelector", value));
});
exportBtn.addEventListener("click", () => {
const data = {
targets: GM_getValue(TARGETS_ID, {}),
suppressOriginal: GM_getValue(SUPPRESS_ID, false),
language: GM_getValue(LANG_ID, "ru"),
languageMode: GM_getValue(LANG_MODE_ID, "auto"),
themeMode: GM_getValue(THEME_MODE_ID, "auto"),
userSelectors: GM_getValue(USER_SELECTORS_ID, []),
disabledBaseSelectors: GM_getValue(DISABLED_BASE_SELECTORS_ID, []),
currencyOrder: GM_getValue(CURRENCY_ORDER_ID, [])
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "steam-currency-converter-settings.json";
link.click();
URL.revokeObjectURL(link.href);
setActionStatus(getSettingsText("exported"));
});
importBtn.addEventListener("click", () => importFile.click());
importFile.addEventListener("change", () => {
const file = importFile.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result || "{}"));
if (parsed.targets && typeof parsed.targets === "object") GM_setValue(TARGETS_ID, parsed.targets);
if (typeof parsed.suppressOriginal === "boolean") GM_setValue(SUPPRESS_ID, parsed.suppressOriginal);
if (parsed.languageMode === "auto" || parsed.languageMode === "ru" || parsed.languageMode === "en") {
GM_setValue(LANG_MODE_ID, parsed.languageMode);
} else if (parsed.language === "ru" || parsed.language === "en") {
GM_setValue(LANG_MODE_ID, parsed.language);
}
if (parsed.themeMode === "auto" || parsed.themeMode === "light" || parsed.themeMode === "dark") {
GM_setValue(THEME_MODE_ID, parsed.themeMode);
}
if (parsed.language === "ru" || parsed.language === "en") GM_setValue(LANG_ID, parsed.language);
if (Array.isArray(parsed.userSelectors)) GM_setValue(USER_SELECTORS_ID, parsed.userSelectors);
if (Array.isArray(parsed.disabledBaseSelectors)) GM_setValue(DISABLED_BASE_SELECTORS_ID, parsed.disabledBaseSelectors);
if (Array.isArray(parsed.currencyOrder)) GM_setValue(CURRENCY_ORDER_ID, parsed.currencyOrder);
loadLanguagePreference();
loadThemePreference();
loadSettingsToPanel(panel);
setActionStatus(getSettingsText("imported"));
} catch (err) {
setActionStatus(getSettingsText("importFailed", String(err)));
}
};
reader.readAsText(file);
});
saveBtn.addEventListener("click", () => {
saveSettingsFromPanel(panel);
setSaveStatus(getSettingsText("savedReloading"));
setTimeout(() => location.reload(), 600);
});
}
loadSettingsToPanel(panel);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
backdrop.classList.add("is-open");
panel.classList.add("is-open");
});
});
};
const configureMenus = () => {
GM_registerMenuCommand(getSettingsText("menuSettings"), openSettingsPanel);
};
const applyStyles = () => {
const styles = `
:root {
--scc-panel-bg-light: #f8fafc;
--scc-panel-text-light: #0f172a;
--scc-panel-border-light: #cbd5e1;
--scc-input-bg-light: #ffffff;
--scc-input-text-light: #0f172a;
--scc-input-border-light: #94a3b8;
--scc-btn-primary-light: #1d4ed8;
--scc-btn-primary-hover-light: #1e40af;
--scc-btn-secondary-light: #334155;
--scc-btn-secondary-hover-light: #1e293b;
--scc-panel-bg-dark: #0f172a;
--scc-panel-text-dark: #e2e8f0;
--scc-panel-border-dark: #334155;
--scc-input-bg-dark: #111827;
--scc-input-text-dark: #f1f5f9;
--scc-input-border-dark: #475569;
--scc-btn-primary-dark: #2563eb;
--scc-btn-primary-hover-dark: #1d4ed8;
--scc-btn-secondary-dark: #475569;
--scc-btn-secondary-hover-dark: #334155;
}
.scc-settings-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
z-index: 99998;
}
.scc-settings-backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
.scc-settings-panel {
position: fixed;
top: 12px;
right: 0;
transform: translateX(calc(100% + 24px));
width: min(440px, 94vw);
max-height: 90vh;
overflow-y: auto;
overflow-x: hidden;
border-radius: 12px 0 0 12px;
border: 1px solid var(--scc-panel-border);
background: var(--scc-panel-bg);
color: var(--scc-panel-text);
box-shadow: var(--scc-panel-shadow, 0 10px 28px rgba(0,0,0,0.22));
z-index: 99999;
transition: transform .35s ease;
padding: 16px;
font-family: Arial, sans-serif;
line-height: 1.35;
}
.scc-settings-panel.is-open { transform: translateX(0); }
.scc-settings-panel,
.scc-settings-panel.scc-theme-light {
--scc-panel-bg: var(--scc-panel-bg-light);
--scc-panel-text: var(--scc-panel-text-light);
--scc-panel-border: var(--scc-panel-border-light);
--scc-input-bg: var(--scc-input-bg-light);
--scc-input-text: var(--scc-input-text-light);
--scc-input-border: var(--scc-input-border-light);
--scc-btn-primary: var(--scc-btn-primary-light);
--scc-btn-primary-hover: var(--scc-btn-primary-hover-light);
--scc-btn-secondary: var(--scc-btn-secondary-light);
--scc-btn-secondary-hover: var(--scc-btn-secondary-hover-light);
--scc-panel-shadow: 0 10px 28px rgba(0,0,0,0.22);
}
.scc-settings-panel.scc-theme-dark {
--scc-panel-bg: var(--scc-panel-bg-dark);
--scc-panel-text: var(--scc-panel-text-dark);
--scc-panel-border: var(--scc-panel-border-dark);
--scc-input-bg: var(--scc-input-bg-dark);
--scc-input-text: var(--scc-input-text-dark);
--scc-input-border: var(--scc-input-border-dark);
--scc-btn-primary: var(--scc-btn-primary-dark);
--scc-btn-primary-hover: var(--scc-btn-primary-hover-dark);
--scc-btn-secondary: var(--scc-btn-secondary-dark);
--scc-btn-secondary-hover: var(--scc-btn-secondary-hover-dark);
--scc-panel-shadow: 0 10px 28px rgba(0,0,0,0.65);
}
.scc-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.scc-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.scc-lang-compact {
width: 74px;
min-width: 74px;
height: 34px;
padding: 4px 8px;
font-size: 12px;
}
.scc-panel-title {
font-size: 18px;
margin: 0;
color: var(--scc-panel-text) !important;
text-shadow: none !important;
opacity: 1 !important;
}
.scc-theme-toggle-btn {
border: 1px solid var(--scc-input-border);
background: var(--scc-btn-secondary);
color: #fff !important;
border-radius: 10px;
width: 34px;
height: 34px;
cursor: pointer;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.scc-close-btn {
border: 1px solid var(--scc-input-border);
background: var(--scc-input-bg);
color: var(--scc-input-text);
border-radius: 10px;
width: 34px;
height: 34px;
cursor: pointer;
}
.scc-panel-content {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 72px;
}
.scc-section {
border: 1px solid var(--scc-panel-border);
border-radius: 12px;
padding: 10px;
}
.scc-section h3, .scc-section h4 {
margin: 0 0 8px 0;
color: var(--scc-panel-text) !important;
text-shadow: none !important;
opacity: 1 !important;
}
.scc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.scc-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--scc-input-border);
background: var(--scc-input-bg);
color: var(--scc-input-text) !important;
font-size: 11px;
cursor: help;
position: relative;
flex: 0 0 auto;
}
.scc-help-tooltip {
position: fixed;
display: none;
min-width: 160px;
max-width: 260px;
background: var(--scc-input-bg-light);
color: var(--scc-input-text-light);
border: 1px solid var(--scc-input-border-light);
border-radius: 8px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.3;
white-space: normal;
z-index: 100001;
box-shadow: 0 8px 20px rgba(0, 0, 0, .3);
pointer-events: none;
}
.scc-help-tooltip.is-visible {
display: block;
}
.scc-help-tooltip.is-above {
transform-origin: bottom right;
}
@media (max-width: 560px) {
.scc-help-tooltip {
min-width: 140px;
max-width: calc(100vw - 16px);
font-size: 10px;
}
}
body.night-theme .scc-help-tooltip,
html.night-theme .scc-help-tooltip,
.night-theme .scc-help-tooltip {
background: var(--scc-input-bg-dark);
color: var(--scc-input-text-dark);
border-color: var(--scc-input-border-dark);
}
.scc-settings-panel .scc-field span,
.scc-settings-panel .scc-row-left,
.scc-settings-panel .scc-selector-text,
.scc-settings-panel .scc-empty,
.scc-settings-panel .scc-status {
color: var(--scc-panel-text) !important;
text-shadow: none !important;
opacity: 1 !important;
}
.scc-actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.scc-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.scc-field.scc-inline {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.scc-input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--scc-input-border) !important;
background: var(--scc-input-bg) !important;
color: var(--scc-input-text) !important;
border-radius: 10px;
padding: 8px 10px;
font-size: 13px;
}
.scc-scroll-list {
max-height: 180px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--scc-panel-border);
border-radius: 10px;
padding: 6px;
margin-bottom: 8px;
}
.scc-currency-head {
display: grid;
grid-template-columns: 1fr 88px 44px;
gap: 8px;
padding: 0 6px 6px 6px;
margin-bottom: 4px;
border-bottom: 1px solid var(--scc-panel-border);
color: var(--scc-panel-text) !important;
font-size: 11px;
font-weight: 700;
letter-spacing: .2px;
}
.scc-currency-head span:nth-child(2) {
text-align: center;
}
.scc-list-row {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
padding: 6px 4px;
border-bottom: 1px solid var(--scc-panel-border);
}
.scc-list-row:last-child { border-bottom: none; }
.scc-row-left {
font-size: 12px;
white-space: nowrap;
}
.scc-row-controls {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.scc-fee-input {
width: 80px;
padding: 6px 8px;
}
.scc-selector-text {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 285px;
}
.scc-empty {
font-size: 12px;
opacity: .8;
padding: 6px;
}
.scc-order-list {
max-height: 120px;
}
.scc-order-btn {
padding: 2px 8px !important;
font-size: 10px !important;
min-width: 28px;
line-height: 1;
}
.scc-order-btn:disabled {
opacity: .3;
cursor: default;
}
.scc-add-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
margin-bottom: 8px;
}
.scc-btn {
border: none;
color: #fff !important;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
cursor: pointer;
transition: background .15s ease;
}
.scc-btn-primary { background: var(--scc-btn-primary); }
.scc-btn-primary:hover { background: var(--scc-btn-primary-hover); }
.scc-btn-secondary { background: var(--scc-btn-secondary); }
.scc-btn-secondary:hover { background: var(--scc-btn-secondary-hover); }
.scc-theme-toggle-btn:hover { background: var(--scc-btn-secondary-hover); }
.scc-status {
margin-top: 8px;
min-height: 18px;
font-size: 12px;
opacity: .95;
}
.scc-save-area {
position: sticky;
bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
z-index: 3;
background: transparent;
border: none;
padding: 0;
margin-top: 2px;
}
.scc-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.scc-switch input {
opacity: 0;
width: 0;
height: 0;
}
.scc-slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #64748b;
transition: .2s;
border-radius: 999px;
}
.scc-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: #fff;
transition: .2s;
border-radius: 50%;
}
.scc-switch input:checked + .scc-slider {
background: #22c55e;
}
.scc-switch input:checked + .scc-slider:before {
transform: translateX(20px);
}
.tab_item_discount { width: 160px !important; }
.tab_item_discount .discount_prices { width: 100% !important; }
.tab_item_discount .discount_final_price { padding: 0 !important; }
.home_marketing_message.small .discount_block { height: auto !important; }
.discount_block_inline { white-space: nowrap !important; }
.curator #RecommendationsRows .store_capsule.price_inline .discount_block { min-width: 200px !important; }
.market_listing_their_price { min-width: 130px !important; }
`;
GM_addStyle(styles);
};
const getCurrencyById = id => CURRENCY_BY_ID.get(id);
const getCurrencyByCode = code => CURRENCY_BY_CODE.get(code);
const getCurrencySign = code => {
const currency = CURRENCY_BY_CODE.get(code);
return currency ? (currency.sign || code) : code;
};
// Вынесено в отдельную функцию
const startConversionLogic = async () => {
if (conversionLogicStarted) {
log("Conversion logic already started, skipping.");
return;
}
conversionLogicStarted = true;
log("Starting conversion logic...");
if (Object.keys(targetCurrencies).length === 0) {
warn("No target currencies set. Exiting conversion logic.");
return;
}
await loadRates();
log("Exchange rates loaded.");
if (!activeCurrencyCode) {
error("Conversion logic started but activeCurrencyCode is missing!");
return;
}
baseRateFromRub = exchangeData.rub[activeCurrencyCode.toLowerCase()];
if (activeCurrencyCode.toLowerCase() === 'rub') {
baseRateFromRub = 1;
}
log(`Base Rate (1 RUB to ${activeCurrencyCode}): ${baseRateFromRub}`);
if (!exchangeData || !baseRateFromRub) {
error(`FAILED to find base rate for ${activeCurrencyCode}. Exiting.`);
return;
}
// Первоначальный прогон
processPrices(document.querySelectorAll(finalPriceSelectorString));
// Дополнительные проверки для динамического контента
for (const delay of [2000, 5000, 10000]) {
setTimeout(() => {
log(`Running delayed scan (${delay / 1000}s)...`);
processPrices(document.querySelectorAll(finalPriceSelectorString));
}, delay);
}
}
// Core initialization
const bootstrap = async () => {
"use strict";
log("Bootstrapping script...");
// Restore persistent state
targetCurrencies = GM_getValue(TARGETS_ID, {});
targetCurrencyOrder = GM_getValue(CURRENCY_ORDER_ID, []);
suppressOriginal = GM_getValue(SUPPRESS_ID, false);
loadLanguagePreference();
loadThemePreference();
userAddedSelectors = GM_getValue(USER_SELECTORS_ID, []);
// Собираем финальную строку селекторов
buildFinalSelectorString();
log("Final selector string:", finalPriceSelectorString);
// Определение валюты (Методы 1, 2, 3 и безопасный 4)
identifyActiveCurrency();
log(`Active Currency: ${activeCurrencyCode} (Sign: ${activeCurrencySign})`);
// Настраиваем меню и стили в любом случае
configureMenus();
applyStyles();
if (!activeCurrencyCode) {
warn("No currency found on initial load. Initializing watcher for dynamic detection...");
initializeWatcher(); // Запускаем наблюдатель, который будет *ждать* появления валюты
return;
}
// Валюта найдена сразу, запускаем основную логику
await startConversionLogic();
initializeWatcher(); // Запускаем наблюдатель для отслеживания *будущих* изменений
};
// Start on page load
window.addEventListener("load", bootstrap);