// ==UserScript==
// @name Xbox PriceLens
// @namespace https://github.com/sinazadeh/userscripts
// @version 1.0.4
// @description Get a clear view of global Xbox pricing. PriceLens adds a powerful, customizable dashboard to game pages, showing you what a game costs in different countries—all in your home currency. Pin your favorite stores and let PriceLens help you focus on the best deals.
// @author TheSina
// @match *://www.xbox.com/*/games/store/*
// @connect cdn.jsdelivr.net
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// ==/UserScript==
/* jshint esversion: 11 */
(async function () {
'use strict';
// 1) Centralized Configuration
const CONFIG = {
SELECTORS: {
priceText: '.Price-module__boldText___1i2Li',
insertionPoint: '.Price-module__priceBaseContainer___j9jGE',
buyButton: 'button[data-m*="Buy"]',
banner: '.xbox-banner',
},
API_BASE_URL:
'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/',
RETRY_DELAY: 750,
CACHE: {
KEY_RATES_PREFIX: 'xboxCurrencyRates_v4.2_',
KEY_TIMESTAMP_PREFIX: 'xboxCurrencyRatesTS_v4.2_',
TTL: 12 * 60 * 60 * 1000,
},
};
// 2) Style Injection
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.xbox-banner,
.xbox-row,
.xbox-modal,
.xbox-modal-section,
.xbox-settings-btn {
font-family:
system-ui,
'Noto Sans',
'Segoe UI',
'Noto Color Emoji',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 14px;
}
.xbox-banner {
position: relative;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin: 16px 0;
padding: 12px 16px;
font-size: 0.95rem;
line-height: 1.4;
color: #333;
min-height: 50px;
}
.xbox-rows {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.xbox-row {
flex: 1 1 calc(50% - 8px);
background: #f9f9f9;
padding: 8px;
border-radius: 4px;
direction: ltr;
text-align: left;
border-left: 3px solid transparent;
transition:
background-color 0.2s,
border-color 0.2s;
}
.xbox-row.default-store-highlight {
background-color: #e6ffed;
border-left-color: #4caf50;
}
.xbox-row.error {
color: #d32f2f;
}
.xbox-row.loading {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
color: #888;
background: none;
}
.xbox-row .error-text {
color: currentColor;
}
.rtl-text {
direction: rtl;
unicode-bidi: embed;
display: inline-block;
}
.xbox-settings-btn {
position: absolute;
bottom: 8px;
right: 8px;
background: none;
border: none;
cursor: pointer;
color: currentColor;
padding: 4px;
font-size: 1.2rem;
z-index: 1;
}
.xbox-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
z-index: 9999;
}
.xbox-overlay.show {
opacity: 1;
}
.xbox-modal {
background: #fff;
color: #000;
padding: 20px;
border-radius: 8px;
width: 500px;
max-width: 95%;
font-size: 1rem;
line-height: 1.4;
}
.xbox-modal h3 {
margin: 0 0 16px;
}
.xbox-modal h4 {
margin: 16px 0 8px;
}
.stores-list {
column-count: 2;
column-gap: 20px;
}
.xbox-modal-section {
margin-bottom: 16px;
}
.xbox-modal-section select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.xbox-modal-actions {
text-align: right;
margin-top: 24px;
}
.xbox-modal-actions-links {
margin-bottom: 16px;
}
.xbox-modal-actions-links a {
margin-right: 12px;
cursor: pointer;
}
.xbox-modal-actions button:not(:last-child) {
margin-right: 8px;
}
.switch {
display: flex;
align-items: center;
margin: 6px 0;
cursor: pointer;
break-inside: avoid-column;
}
.switch input {
opacity: 0;
width: 1px;
height: 1px;
}
.switch .slider {
width: 36px;
height: 18px;
background: #ccc;
border-radius: 9px;
margin-right: 10px;
position: relative;
transition: background 0.2s;
}
.switch .slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
top: 1px;
left: 1px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.switch input:checked + .slider {
background: #4caf50;
}
.switch input:checked + .slider::after {
transform: translateX(18px);
}
.switch input:focus-visible + .slider {
box-shadow: 0 0 0 2px #0078d4;
}
@media (max-width: 420px) {
.stores-list {
column-count: 1;
}
}
@media (prefers-color-scheme: dark) {
.xbox-banner {
background: #1e1e1e;
color: #ddd;
}
.xbox-row {
background: #2a2a2a;
}
.xbox-row.default-store-highlight {
background-color: #1a3d20;
border-left-color: #66bb6a;
}
.xbox-row.loading {
color: #666;
}
.xbox-row.error {
color: #ef5350;
}
.xbox-modal {
background: #2a2a2a;
color: #ccc;
}
.xbox-modal-section select {
background: #333;
color: #ccc;
border-color: #555;
}
.xbox-settings-btn {
color: #fff;
}
}
`;
document.head.appendChild(style);
}
// 3) Currency & Tax Definitions
const CURRENCIES = [
{
code: 'ar',
api: 'ars',
region: 'es-ar',
flag: '🇦🇷',
name: 'Argentina Store',
link: 'Argentina Store',
tax: 0.7,
decimal: ',',
fmt: x =>
`ARS${new Intl.NumberFormat('es-AR', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x).replace(/\s/g, '')}`,
},
{
code: 'au',
api: 'aud',
region: 'en-au',
flag: '🇦🇺',
name: 'Australia Store',
link: 'Australia Store',
tax: 0,
decimal: '.',
fmt: x =>
`AU$${new Intl.NumberFormat('en-AU', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'br',
api: 'brl',
region: 'pt-br',
flag: '🇧🇷',
name: 'Brazil Store',
link: 'Brazil Store',
tax: 0,
decimal: ',',
fmt: x =>
new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
})
.format(x)
.replace(/\s/g, ''),
},
{
code: 'ca',
api: 'cad',
region: 'en-ca',
flag: '🇨🇦',
name: 'Canada Store',
link: 'Canada Store',
tax: 0,
decimal: '.',
fmt: x =>
`CAD ${new Intl.NumberFormat('en-CA', {style: 'currency', currency: 'CAD'}).format(x)}`,
showPlusTax: true,
},
{
code: 'ch',
api: 'chf',
region: 'de-ch',
flag: '🇨🇭',
name: 'Switzerland Store',
link: 'Switzerland Store',
tax: 0,
decimal: '.',
fmt: x =>
`CHF ${new Intl.NumberFormat('de-CH', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'cl',
api: 'clp',
region: 'es-cl',
flag: '🇨🇱',
name: 'Chile Store',
link: 'Chile Store',
tax: 0,
decimal: ',',
fmt: x => `$${new Intl.NumberFormat('es-CL').format(x)}`,
showPlusTax: true,
},
{
code: 'co',
api: 'cop',
region: 'es-co',
flag: '🇨🇴',
name: 'Colombia Store',
link: 'Colombia Store',
tax: 0,
decimal: ',',
fmt: x => `COP$${new Intl.NumberFormat('es-CO').format(x)}`,
showPlusTax: true,
},
{
code: 'cz',
api: 'czk',
region: 'cs-cz',
flag: '🇨🇿',
name: 'Czechia Store',
link: 'Czechia Store',
tax: 0,
decimal: ',',
fmt: x =>
`${new Intl.NumberFormat('cs-CZ', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} Kč`,
},
{
code: 'gb',
api: 'gbp',
region: 'en-gb',
flag: '🇬🇧',
name: 'UK Store',
link: 'UK Store',
tax: 0,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
}).format(x),
},
{
code: 'hk',
api: 'hkd',
region: 'en-hk',
flag: '🇭🇰',
name: 'Hong Kong Store',
link: 'Hong Kong Store',
tax: 0,
decimal: '.',
fmt: x =>
`HK$${new Intl.NumberFormat('en-HK', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'hu',
api: 'huf',
region: 'hu-hu',
flag: '🇭🇺',
name: 'Hungary Store',
link: 'Hungary Store',
tax: 0,
decimal: ',',
fmt: x =>
`${new Intl.NumberFormat('hu-HU', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} HUF`,
},
{
code: 'in',
api: 'inr',
region: 'en-in',
flag: '🇮🇳',
name: 'India Store',
link: 'India Store',
tax: 0.18,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.format(x)
.replace(/\s/g, ''),
},
{
code: 'jp',
api: 'jpy',
region: 'ja-jp',
flag: '🇯🇵',
name: 'Japan Store',
link: 'Japan Store',
tax: 0,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(x),
},
{
code: 'kr',
api: 'krw',
region: 'ko-kr',
flag: '🇰🇷',
name: 'South Korea Store',
link: 'South Korea Store',
tax: 0,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
})
.format(x)
.replace(/\s/g, ''),
},
{
code: 'mx',
api: 'mxn',
region: 'es-mx',
flag: '🇲🇽',
name: 'Mexico Store',
link: 'Mexico Store',
tax: 0,
decimal: '.',
fmt: x =>
`MXN$${new Intl.NumberFormat('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'no',
api: 'nok',
region: 'nb-no',
flag: '🇳🇴',
name: 'Norway Store',
link: 'Norway Store',
tax: 0,
decimal: ',',
fmt: x =>
`kr ${new Intl.NumberFormat('nb-NO', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'nz',
api: 'nzd',
region: 'en-nz',
flag: '🇳🇿',
name: 'New Zealand Store',
link: 'New Zealand Store',
tax: 0,
decimal: '.',
fmt: x =>
`NZ$${new Intl.NumberFormat('en-NZ', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'pl',
api: 'pln',
region: 'pl-pl',
flag: '🇵🇱',
name: 'Poland Store',
link: 'Poland Store',
tax: 0,
decimal: ',',
fmt: x =>
`${new Intl.NumberFormat('pl-PL', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} zł`,
},
{
code: 'sa',
api: 'sar',
region: 'ar-sa',
flag: '🇸🇦',
name: 'Saudi Arabia Store',
link: 'Saudi Arabia Store',
tax: 0,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('ar-SA', {
style: 'currency',
currency: 'SAR',
}).format(x),
preParse: str => str.replace(/ر\.س\./g, ''),
isRTL: true,
},
{
code: 'se',
api: 'sek',
region: 'sv-se',
flag: '🇸🇪',
name: 'Sweden Store',
link: 'Sweden Store',
tax: 0,
decimal: ',',
fmt: x =>
`${new Intl.NumberFormat('sv-SE', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)} kr`,
},
{
code: 'sg',
api: 'sgd',
region: 'en-sg',
flag: '🇸🇬',
name: 'Singapore Store',
link: 'Singapore Store',
tax: 0,
decimal: '.',
fmt: x =>
`S$${new Intl.NumberFormat('en-SG', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
{
code: 'tr',
api: 'try',
region: 'tr-TR',
flag: '🇹🇷',
name: 'Turkey Store',
link: 'Turkey Store',
tax: 0,
decimal: ',',
fmt: x =>
new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency: 'TRY',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.format(x)
.replace(/\s/g, ''),
},
{
code: 'tw',
api: 'twd',
region: 'zh-tw',
flag: '🇹🇼',
name: 'Taiwan Store',
link: 'Taiwan Store',
tax: 0,
decimal: '.',
fmt: x => `NT$${new Intl.NumberFormat('zh-TW').format(x)}`,
},
{
code: 'us',
api: 'usd',
region: 'en-us',
flag: '🇺🇸',
name: 'US Store',
link: 'US Store',
tax: 0,
decimal: '.',
fmt: x =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(x),
showPlusTax: true,
},
{
code: 'za',
api: 'zar',
region: 'en-za',
flag: '🇿🇦',
name: 'South Africa Store',
link: 'South Africa Store',
tax: 0,
decimal: ',',
fmt: x =>
`R ${new Intl.NumberFormat('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(x)}`,
},
];
// 4) Preference Management Module
const Prefs = {
visible: {},
sortOrder: 'lowest',
defaultStore: 'us',
defaults: ['us', 'tr', 'in', 'ar'],
async load() {
this.visible = await GM_getValue('visibleCurrencies_v4.2', {});
this.sortOrder = await GM_getValue(
'currencySortOrder_v4.2',
'lowest',
);
this.defaultStore = await GM_getValue('defaultStore_v4.2', 'us');
let needsSave = false;
CURRENCIES.forEach(c => {
if (this.visible[c.code] === undefined) {
this.visible[c.code] = this.defaults.includes(c.code);
needsSave = true;
}
});
if (needsSave) await this.save();
},
async save() {
await GM_setValue('visibleCurrencies_v4.2', this.visible);
await GM_setValue('currencySortOrder_v4.2', this.sortOrder);
await GM_setValue('defaultStore_v4.2', this.defaultStore);
},
};
// 5) Data Fetching, Caching & Parsing
async function getRates(baseCurrencyApi) {
const now = Date.now();
const cacheKeyRates = `${CONFIG.CACHE.KEY_RATES_PREFIX}${baseCurrencyApi}`;
const cacheKeyTs = `${CONFIG.CACHE.KEY_TIMESTAMP_PREFIX}${baseCurrencyApi}`;
const ts = await GM_getValue(cacheKeyTs, 0);
const cachedRates = await GM_getValue(cacheKeyRates, null);
if (cachedRates && now - ts < CONFIG.CACHE.TTL) return cachedRates;
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: `${CONFIG.API_BASE_URL}${baseCurrencyApi}.json`,
onload: async r => {
if (r.status >= 200 && r.status < 300) {
try {
const data = JSON.parse(r.responseText)[
baseCurrencyApi
];
await GM_setValue(cacheKeyRates, data);
await GM_setValue(cacheKeyTs, now);
resolve(data);
} catch (e) {
console.error('API parse failed:', e);
resolve(null);
}
} else {
console.error('API request failed:', r.statusText);
resolve(null);
}
},
onerror: e => {
console.error('API network error:', e);
resolve(null);
},
});
});
}
function fetchWithRetry(currency, url, retries = 1) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: r => {
if (r.status >= 500 && retries > 0) {
setTimeout(
() =>
resolve(
fetchWithRetry(currency, url, retries - 1),
),
CONFIG.RETRY_DELAY,
);
return;
}
let priceStr = null,
error = null;
if (r.status >= 200 && r.status < 300) {
const doc = new DOMParser().parseFromString(
r.responseText,
'text/html',
);
priceStr =
doc
.querySelector(CONFIG.SELECTORS.priceText)
?.textContent.replace(/\+\s*$/, '')
.trim() || null;
if (!priceStr) error = 'Price not found';
} else {
error = `Request failed (${r.status})`;
}
resolve({
code: currency.code,
priceStr,
error,
});
},
onerror: () => {
if (retries > 0) {
setTimeout(
() =>
resolve(
fetchWithRetry(currency, url, retries - 1),
),
CONFIG.RETRY_DELAY,
);
} else {
resolve({
code: currency.code,
priceStr: null,
error: 'Network Error',
});
}
},
});
});
}
function fetchAllPrices(urls, currenciesToFetch) {
const promises = currenciesToFetch.map(c =>
fetchWithRetry(c, urls[c.code]),
);
return Promise.all(promises);
}
function parsePrice(priceString, separator = '.') {
const cleanRegex = new RegExp(`[^\\d\\${separator}]`, 'g');
const cleaned = priceString.replace(cleanRegex, '');
const normalized =
separator === '.' ? cleaned : cleaned.replace(separator, '.');
return parseFloat(normalized);
}
// 6) UI & DOM Manipulation
function createBanner() {
const banner = document.createElement('div');
banner.className = 'xbox-banner';
const rowsContainer = document.createElement('div');
rowsContainer.className = 'xbox-rows';
const settingsBtn = document.createElement('button');
settingsBtn.className = 'xbox-settings-btn';
settingsBtn.textContent = '⚙️';
settingsBtn.title = 'Settings';
settingsBtn.setAttribute('aria-label', 'Price Settings');
banner.append(rowsContainer, settingsBtn);
return {
banner,
rowsContainer,
settingsBtn,
};
}
function updateBannerDisplay(container, lines) {
const sorted = [...lines]
.filter(item => Prefs.visible[item.code])
.sort((a, b) => {
// avoid the “line break before ‘?’” warning by using an if/else
if (Prefs.sortOrder === 'alpha') {
return a.name.localeCompare(b.name);
}
return a.convertedPrice - b.convertedPrice;
});
container.innerHTML = '';
if (sorted.length === 0) {
container.innerHTML = `<div class="xbox-row">No stores selected.</div>`;
return;
}
sorted.forEach(item => {
const row = document.createElement('div');
row.className = 'xbox-row';
if (item.error) {
row.classList.add('error');
}
if (item.code === Prefs.defaultStore) {
row.classList.add('default-store-highlight');
}
row.innerHTML = item.html;
container.appendChild(row);
});
}
function showSettingsModal(lines, rowsContainer) {
let lastFocusedElement = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'xbox-overlay';
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
const modal = document.createElement('div');
modal.className = 'xbox-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'xbox-modal-title');
const defaultStoreOptions = CURRENCIES.map(
c =>
`<option value="${c.code}" ${Prefs.defaultStore === c.code ? 'selected' : ''}>${c.flag} ${c.name}</option>`,
).join('');
modal.innerHTML = `<h3 id="xbox-modal-title">Settings</h3>
<div class="xbox-modal-section">
<h4>Default Store (for Conversion)</h4>
<select id="default-store-select">${defaultStoreOptions}</select>
</div>
<div class="xbox-modal-section">
<h4>Visible Stores</h4>
<div class="stores-list"></div>
<div class="xbox-modal-actions-links">
<a href="#" data-action="default">Select Default</a>
<a href="#" data-action="all">Select All</a>
<a href="#" data-action="none">Select None</a>
</div>
</div>
<div class="xbox-modal-section">
<h4>Sort Order</h4>
<select id="sort-order-select">
<option value="lowest" ${Prefs.sortOrder === 'lowest' ? 'selected' : ''}>Lowest Price</option>
<option value="alpha" ${Prefs.sortOrder === 'alpha' ? 'selected' : ''}>Alphabetical</option>
</select>
</div>
<div class="xbox-modal-actions">
<button class="cancel">Cancel</button>
<button class="save">Save</button>
</div>`;
const storesList = modal.querySelector('.stores-list');
CURRENCIES.forEach(c => {
storesList.insertAdjacentHTML(
'beforeend',
`<label class="switch">
<input type="checkbox" value="${c.code}" ${Prefs.visible[c.code] ? 'checked' : ''}>
<span class="slider" role="presentation"></span><span>${c.flag} ${c.link}</span></label>`,
);
});
overlay.appendChild(modal);
const focusableElements = modal.querySelectorAll(
'button, [href], input, select',
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
firstFocusable.focus();
const handleKeyDown = e => {
if (e.key === 'Escape') {
close();
return;
}
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
};
const close = () => {
overlay.classList.remove('show');
overlay.addEventListener(
'transitionend',
() => {
overlay.remove();
document.removeEventListener('keydown', handleKeyDown);
lastFocusedElement?.focus();
},
{
once: true,
},
);
};
document.addEventListener('keydown', handleKeyDown);
modal.querySelector('.cancel').addEventListener('click', close);
overlay.addEventListener('click', e => {
if (e.target === overlay) close();
});
modal.querySelector('.save').addEventListener('click', async () => {
modal.querySelectorAll('.stores-list input').forEach(cb => {
Prefs.visible[cb.value] = cb.checked;
});
Prefs.sortOrder = modal.querySelector('#sort-order-select').value;
Prefs.defaultStore = modal.querySelector(
'#default-store-select',
).value;
await Prefs.save();
runScript(true);
close();
});
modal
.querySelector('.xbox-modal-actions-links')
.addEventListener('click', e => {
e.preventDefault();
const action = e.target.dataset.action;
if (!action) return;
modal.querySelectorAll('.stores-list input').forEach(cb => {
if (action === 'all') cb.checked = true;
else if (action === 'none') cb.checked = false;
else if (action === 'default')
cb.checked = Prefs.defaults.includes(cb.value);
});
});
}
// 7) Main Execution Logic
async function main(sku) {
const anchor = document.querySelector(CONFIG.SELECTORS.insertionPoint);
if (!anchor) return;
const {banner, rowsContainer, settingsBtn} = createBanner();
banner.dataset.xboxSku = sku;
rowsContainer.innerHTML = `<div class="xbox-row loading">Loading prices...</div>`;
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
const parts = location.pathname.split('/').filter(p => p);
const storeIdx = parts.indexOf('store');
const [, slug, prod] = parts.slice(storeIdx);
const currenciesToFetch = CURRENCIES.filter(c => Prefs.visible[c.code]);
if (currenciesToFetch.length === 0) {
updateBannerDisplay(rowsContainer, []);
settingsBtn.addEventListener('click', () =>
showSettingsModal([], rowsContainer),
);
return;
}
const urls = Object.fromEntries(
CURRENCIES.map(c => [
c.code,
`https://www.xbox.com/${c.region}/games/store/${slug}/${prod}/${sku}`,
]),
);
const priceResults = await fetchAllPrices(urls, currenciesToFetch);
const rawPrices = Object.fromEntries(
priceResults.map(r => [
r.code,
{
priceStr: r.priceStr,
error: r.error,
},
]),
);
const parsedValues = {};
currenciesToFetch.forEach(c => {
const raw = rawPrices[c.code];
if (raw && raw.priceStr) {
try {
let strToParse = raw.priceStr;
if (c.preParse) {
strToParse = c.preParse(strToParse);
}
parsedValues[c.code] = parsePrice(strToParse, c.decimal);
} catch (e) {
raw.error = 'Parse failed';
}
}
});
const defaultCurrency = CURRENCIES.find(
c => c.code === Prefs.defaultStore,
);
const rates = await getRates(defaultCurrency.api);
if (!rates || !defaultCurrency) {
rowsContainer.innerHTML = `<div class="xbox-row error">Could not load exchange rates for ${defaultCurrency?.name || 'default store'}.</div>`;
return;
}
const displayLines = CURRENCIES.map(c => {
if (!Prefs.visible[c.code]) {
return {
code: c.code,
html: '',
};
}
const value = parsedValues[c.code];
const raw = rawPrices[c.code];
let html,
convertedPrice = Infinity;
const linkHtml = `<a href="${urls[c.code]}" target="_blank" rel="noopener noreferrer">${c.name}</a>`;
const nameWithFlag = `${c.flag} ${linkHtml}`;
if (raw.error) {
console.warn(`Could not fetch price for ${c.name}:`, raw.error);
html = `${nameWithFlag}: <span class="error-text" title="${raw.error}">⚠️ Couldn’t load</span>`;
} else if (value != null) {
convertedPrice = value / rates[c.api];
html = `${nameWithFlag}: ${defaultCurrency.fmt(convertedPrice)}`;
if (c.code !== defaultCurrency.code) {
let formattedLocal = c.fmt(value);
if (c.isRTL) {
formattedLocal = `<span class="rtl-text">${formattedLocal}</span>`;
}
html += ` (${formattedLocal})`;
}
if (c.tax > 0) {
const totalConverted = convertedPrice * (1 + c.tax);
const totalLocalValue = value * (1 + c.tax);
let formattedTaxLocal = c.fmt(totalLocalValue);
if (c.isRTL) {
formattedTaxLocal = `<span class="rtl-text">${formattedTaxLocal}</span>`;
}
html += ` + Tax = ${defaultCurrency.fmt(totalConverted)}`;
if (c.code !== defaultCurrency.code) {
html += ` (${formattedTaxLocal})`;
}
convertedPrice = totalConverted;
} else if (c.showPlusTax) {
html += ` + Tax`;
}
} else {
html = `${nameWithFlag}: Not available`;
}
return {
code: c.code,
name: c.name,
html,
convertedPrice,
error: raw.error,
};
});
settingsBtn.addEventListener('click', () =>
showSettingsModal(displayLines, rowsContainer),
);
updateBannerDisplay(rowsContainer, displayLines);
}
function getCurrentSku() {
try {
const buyButton = document.querySelector(
CONFIG.SELECTORS.buyButton,
);
if (!buyButton) return null;
const mData = JSON.parse(buyButton.dataset.m || '{}');
return mData.sku || null;
} catch (e) {
return null;
}
}
// --- Script Initialization and SPA Navigation Handling ---
let debounceTimer;
async function runScript(forceRefresh = false) {
observer.disconnect();
try {
const currentSku = getCurrentSku();
const existingBanner = document.querySelector(
CONFIG.SELECTORS.banner,
);
if (!currentSku) {
existingBanner?.remove();
return;
}
if (
!forceRefresh &&
existingBanner &&
existingBanner.dataset.xboxSku === currentSku
) {
// re-observe even if we don't run
} else {
existingBanner?.remove();
const insertionPoint = document.querySelector(
CONFIG.SELECTORS.insertionPoint,
);
if (insertionPoint) {
await main(currentSku);
}
}
} catch (error) {
console.error('Error during script execution:', error);
} finally {
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
}
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => runScript(false), 300);
});
injectStyles();
await Prefs.load();
await runScript(true);
})();