Steam Page Tools

Adds cross-page badge search, badge tools, and bulk store actions to Steam.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Steam Page Tools
// @namespace    local.steam-page-tools
// @version      1.10.5
// @description  Adds cross-page badge search, badge tools, and bulk store actions to Steam.
// @author       x0697x
// @match        https://steamcommunity.com/id/*/badges*
// @match        https://steamcommunity.com/profiles/*/badges*
// @match        https://store.steampowered.com/search*
// @grant        none
// @icon         https://i.ibb.co/wr0d9jH3/diagram.png
// @homepageURL  https://github.com/x0697x/steam-page-tools
// ==/UserScript==

(function () {
    'use strict';

    const STEAM_COMMUNITY_ORIGIN = 'https://steamcommunity.com';
    const STEAM_STORE_ORIGIN = 'https://store.steampowered.com';

    function parsePinnedSteamUrl(value, requiredOrigin, base = requiredOrigin) {
        try {
            const url = new URL(value, base);

            return url.origin === requiredOrigin ? url : null;
        } catch {
            return null;
        }
    }

    function requirePinnedSteamUrl(value, requiredOrigin, base) {
        const url = parsePinnedSteamUrl(value, requiredOrigin, base);

        if (!url) {
            const err = new Error(
                `Refusing a URL outside ${requiredOrigin}`
            );

            err.fatal = true;
            throw err;
        }

        return url;
    }

    function requirePinnedSteamResponseUrl(
        response,
        requiredOrigin,
        requestUrl
    ) {
        return requirePinnedSteamUrl(
            response.url || requestUrl,
            requiredOrigin,
            requestUrl
        );
    }

    function sleep(ms) {
        return new Promise((resolve) => setTimeout(resolve, ms));
    }

    function getSessionId() {
        if (window.g_sessionID) {
            return window.g_sessionID;
        }

        const match = document.cookie.match(/(?:^|;\s*)sessionid=([^;]+)/);

        return match ? decodeURIComponent(match[1]) : null;
    }

    function bindButtonActivation(element, handler) {
        element.addEventListener('click', handler);
        element.addEventListener('keydown', (event) => {
            if (event.key === 'Enter' || event.key === ' ') {
                event.preventDefault();
                handler();
            }
        });
    }

    if (location.origin === STEAM_COMMUNITY_ORIGIN) {
        initBadgesPageTools();
    } else if (location.origin === STEAM_STORE_ORIGIN) {
        initSearchBulkCart();
    }

    // Badge page tools.
    function initBadgesPageTools() {
        if (
            !/^\/(?:id\/[^/]+|profiles\/\d+)\/badges\/?$/i
                .test(location.pathname)
        ) {
            return;
        }

        // Prefer locale-independent markers; use English text only when
        // neither structural marker is available.
        const DROPS_REGEX_FALLBACK = /(\d+)\s*card drops?\s*remaining/i;

        function getRows(root = document) {
            return [...root.querySelectorAll('.badge_row')];
        }

        function normalizeBadgeSearchText(value) {
            return String(value || '')
                .normalize('NFKD')
                .replace(/[\u0300-\u036f]/g, '')
                .toLocaleLowerCase()
                .replace(/\s+/g, ' ')
                .trim();
        }

        function createBadgeSearchTermPattern(term) {
            const escapedTerm = term.replace(
                /[.*+?^${}()|[\]\\]/g,
                '\\$&'
            );

            return new RegExp(
                `(?:^|[^\\p{L}\\p{N}])${escapedTerm}`,
                'u'
            );
        }

        // A badge_info_title is rendered only when this profile owns the
        // badge. Index both its badge name and the parent game/event title.
        function getOwnedBadgeSearchText(row) {
            const badgeName = row.querySelector(
                '.badge_info_title'
            )?.textContent.trim();

            if (!badgeName) {
                return null;
            }

            const title = row.querySelector('.badge_title');
            const gameName = title
                ? [...title.childNodes]
                    .filter((node) => node.nodeType === 3)
                    .map((node) => node.textContent.trim())
                    .find(Boolean) || ''
                : '';

            return normalizeBadgeSearchText(
                `${gameName} ${badgeName}`
            );
        }

        // Steam renders the "Play Game" control only while a badge has card
        // drops. Its CSS class is stable across interface languages.
        function hasDropsRemaining(row) {
            if (row.querySelector('.badge_title_playgame, .badge_title_stats_playgame')) {
                return true;
            }

            if (row.querySelector('.badge_title_stats_completed')) {
                return false;
            }

            // Fall back to Steam's English status text for ambiguous rows.
            return DROPS_REGEX_FALLBACK.test(row.textContent);
        }

        function getCurrentPageNumber() {
            const page = parseInt(
                requirePinnedSteamUrl(
                    location.href,
                    STEAM_COMMUNITY_ORIGIN
                ).searchParams.get('p') || '1',
                10
            );

            return page > 0 ? page : 1;
        }

        function buildPageUrl(pageNum) {
            const url = requirePinnedSteamUrl(
                location.href,
                STEAM_COMMUNITY_ORIGIN
            );

            url.searchParams.set('p', pageNum);

            return url.toString();
        }

        async function fetchPageRows(pageNum) {
            const pageUrl = requirePinnedSteamUrl(
                buildPageUrl(pageNum),
                STEAM_COMMUNITY_ORIGIN
            );
            const res = await fetch(pageUrl, { credentials: 'include' });

            requirePinnedSteamResponseUrl(
                res,
                STEAM_COMMUNITY_ORIGIN,
                pageUrl
            );

            if (!res.ok) throw new Error(`HTTP ${res.status}`);

            const html = await res.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            return getRows(doc);
        }

        // Read Steam's pagination instead of inferring the page count from
        // fetched content. Callers include the current page, which is a span.
        function getMaxPageFromPagination() {
            const here = requirePinnedSteamUrl(
                location.href,
                STEAM_COMMUNITY_ORIGIN
            );

            const values = [...document.querySelectorAll('a[href*="p="]')]
                .map((a) => {
                    try {
                        const url = parsePinnedSteamUrl(
                            a.href,
                            STEAM_COMMUNITY_ORIGIN,
                            location.href
                        );

                        if (!url || url.pathname !== here.pathname) {
                            return NaN;
                        }

                        return parseInt(url.searchParams.get('p'), 10);
                    } catch {
                        return NaN;
                    }
                })
                .filter((n) => Number.isFinite(n) && n > 0);

            return values.length ? Math.max(...values) : null;
        }

        // Prevent runaway scans if Steam returns malformed pagination.
        const MAX_PAGES = 200;
        const MAX_CRAFT_PASSES = 10;
        const MAX_CRAFT_BATCHES = 500;
        const CRAFT_LOCK_KEY = 'spt-badge-auto-craft-lock';
        const CRAFT_LOCK_TTL_MS = 60 * 1000;
        const craftLockOwner =
            `${Date.now()}-${Math.random().toString(36).slice(2)}`;
        let craftLockUsesStorage = true;

        async function fetchPageRowsWithRetry(pageNum) {
            for (let attempt = 0; attempt < 2; attempt += 1) {
                try {
                    return await fetchPageRows(pageNum);
                } catch (err) {
                    console.error(
                        'Steam Page Tools: failed loading badge page',
                        pageNum,
                        err
                    );

                    if (attempt === 0) {
                        await sleep(500);
                    }
                }
            }

            return null;
        }

        function getProfileUrlFromUrl(url) {
            if (!url) {
                return null;
            }

            try {
                const parsed = parsePinnedSteamUrl(
                    url,
                    STEAM_COMMUNITY_ORIGIN,
                    location.href
                );

                if (!parsed) {
                    return null;
                }

                const match = parsed.pathname.match(
                    /^\/(?:id\/[^/]+|profiles\/\d+)/i
                );

                return match
                    ? `${STEAM_COMMUNITY_ORIGIN}${match[0]}`
                    : null;
            } catch {
                return null;
            }
        }

        function normalizeProfileUrl(url) {
            const profileUrl = getProfileUrlFromUrl(url);

            return profileUrl ? profileUrl.toLowerCase() : null;
        }

        const profileUrl =
            getProfileUrlFromUrl(window.g_strProfileURL) ||
            getProfileUrlFromUrl(location.href);

        // The metadata matches every profile, so compare numeric IDs or the
        // signed-in profile link before exposing account-specific controls.
        function isProbablyOwnProfilePage() {
            if (!profileUrl) {
                return false;
            }

            const numericMatch = new URL(profileUrl).pathname.match(
                /^\/profiles\/(\d+)$/i
            );

            if (
                numericMatch &&
                window.g_steamID &&
                numericMatch[1] === String(window.g_steamID)
            ) {
                return true;
            }

            const signedInProfileLink = document.querySelector(
                '#global_header a.user_avatar[href], ' +
                '#responsive_page_menu .persona a[data-miniprofile][href]'
            );

            return Boolean(
                signedInProfileLink &&
                normalizeProfileUrl(signedInProfileLink.href) ===
                    normalizeProfileUrl(profileUrl)
            );
        }

        // Revalidate ownership through Steam's /my/ redirect immediately
        // before consuming cards.
        async function verifyOwnProfilePage() {
            const numericMatch = profileUrl
                ? new URL(profileUrl).pathname.match(/^\/profiles\/(\d+)$/i)
                : null;

            if (
                numericMatch &&
                window.g_steamID &&
                numericMatch[1] === String(window.g_steamID)
            ) {
                return true;
            }

            try {
                const ownershipUrl = requirePinnedSteamUrl(
                    '/my/badges/',
                    STEAM_COMMUNITY_ORIGIN
                );
                const res = await fetch(ownershipUrl, {
                    credentials: 'include',
                });
                const redirectUrl = requirePinnedSteamResponseUrl(
                    res,
                    STEAM_COMMUNITY_ORIGIN,
                    ownershipUrl
                );

                return Boolean(
                    res.ok &&
                    redirectUrl &&
                    normalizeProfileUrl(redirectUrl) ===
                        normalizeProfileUrl(profileUrl)
                );
            } catch (err) {
                console.error(
                    'Steam Page Tools: failed verifying profile ownership',
                    err
                );
                return false;
            }
        }

        function getBadgeName(row, appid, borderColor) {
            const title = row.querySelector('.badge_title');

            if (title) {
                const textNode = [...title.childNodes]
                    .find((node) => (
                        node.nodeType === 3 && node.textContent.trim()
                    ));

                if (textNode) {
                    return textNode.textContent.trim();
                }
            }

            return `App ${appid}${borderColor === '1' ? ' (foil)' : ''}`;
        }

        // Steam's composite row ID keeps normal and foil badges distinct
        // without relying on localized text.
        function getCraftTarget(row) {
            const button = row.querySelector(
                '.badge_progress_info > a.badge_craft_button[href*="/gamecards/"]'
            );
            const idMatch = row.id.match(
                /^badge_gamebadge_(\d+)_(\d+)_(\d+)$/
            );

            if (!button || !idMatch) {
                return null;
            }

            const detailUrl = parsePinnedSteamUrl(
                button.href,
                STEAM_COMMUNITY_ORIGIN,
                location.href
            );

            if (!detailUrl) {
                return null;
            }

            const appMatch = detailUrl.pathname.match(
                /\/gamecards\/(\d+)(?:\/|$)/i
            );
            const [, appid, series, borderColor] = idMatch;

            if (!appMatch || appMatch[1] !== appid) {
                return null;
            }

            return {
                key: `${appid}:${series}:${borderColor}`,
                appid,
                series,
                borderColor,
                detailUrl: detailUrl.toString(),
                name: getBadgeName(row, appid, borderColor),
            };
        }

        function parseCraftCall(code) {
            if (!code) {
                return null;
            }

            const match = code.match(
                /Profile_CraftGameBadge\(\s*(['"])([^'"]+)\1\s*,\s*['"](\d+)['"]\s*,\s*['"](\d+)['"]\s*,\s*['"](\d+)['"]\s*,\s*(\d+)\s*\)/
            );

            if (!match) {
                return null;
            }

            return {
                profileUrl: match[2],
                appid: match[3],
                series: match[4],
                borderColor: match[5],
                levels: parseInt(match[6], 10),
            };
        }

        async function fetchCraftOptionOnce(target) {
            const detailUrl = requirePinnedSteamUrl(
                target.detailUrl,
                STEAM_COMMUNITY_ORIGIN
            );
            const res = await fetch(detailUrl, {
                credentials: 'include',
            });
            const responseUrl = requirePinnedSteamResponseUrl(
                res,
                STEAM_COMMUNITY_ORIGIN,
                detailUrl
            );

            if (!res.ok) {
                const err = new Error(`HTTP ${res.status}`);

                err.fatal = res.status === 401 || res.status === 403;
                throw err;
            }

            if (res.url) {
                if (/\/login(?:\/|$)/i.test(responseUrl.pathname)) {
                    const err = new Error('Steam asked you to sign in again');

                    err.fatal = true;
                    throw err;
                }
            }

            const html = await res.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            if (doc.querySelector('#loginForm, input[name="password"]')) {
                const err = new Error('Steam asked you to sign in again');

                err.fatal = true;
                throw err;
            }

            const options = [
                ...doc.querySelectorAll(
                    '.gamecard_badge_craftbtn_ctn ' +
                    '.badge_craft_button[onclick*="Profile_CraftGameBadge"]'
                ),
            ]
                .map((button) => parseCraftCall(button.getAttribute('onclick')))
                .filter((option) => (
                    option &&
                    option.appid === target.appid &&
                    option.series === target.series &&
                    option.borderColor === target.borderColor &&
                    normalizeProfileUrl(option.profileUrl) ===
                        normalizeProfileUrl(profileUrl) &&
                    Number.isFinite(option.levels) &&
                    option.levels >= 1 &&
                    option.levels <= 5
                ));

            if (!options.length) {
                return null;
            }

            return options.reduce((largest, option) => (
                option.levels > largest.levels ? option : largest
            ));
        }

        async function fetchCraftOption(target) {
            let lastError = null;

            for (let attempt = 0; attempt < 2; attempt += 1) {
                try {
                    return await fetchCraftOptionOnce(target);
                } catch (err) {
                    lastError = err;

                    if (err.fatal || attempt === 1) {
                        break;
                    }

                    await sleep(600);
                }
            }

            throw lastError;
        }

        function getRetryDelayMs(res) {
            const value = res.headers && res.headers.get('Retry-After');

            if (!value) {
                return 5000;
            }

            const seconds = parseInt(value, 10);

            if (Number.isFinite(seconds)) {
                return Math.min(Math.max(seconds * 1000, 1000), 30000);
            }

            const retryAt = Date.parse(value);

            return Number.isFinite(retryAt)
                ? Math.min(Math.max(retryAt - Date.now(), 1000), 30000)
                : 5000;
        }

        async function postCraft(target, levels, sessionid) {
            const craftUrl = requirePinnedSteamUrl(
                `${profileUrl}/ajaxcraftbadge/`,
                STEAM_COMMUNITY_ORIGIN
            );
            const body = new URLSearchParams({
                appid: target.appid,
                series: target.series,
                border_color: target.borderColor,
                levels: String(levels),
                sessionid,
            });

            let res;

            try {
                res = await fetch(craftUrl, {
                    method: 'POST',
                    credentials: 'include',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'X-Requested-With': 'XMLHttpRequest',
                    },
                    body: body.toString(),
                });
                requirePinnedSteamResponseUrl(
                    res,
                    STEAM_COMMUNITY_ORIGIN,
                    craftUrl
                );
            } catch (cause) {
                if (cause.fatal) {
                    throw cause;
                }

                const err = new Error('The craft response was not received');

                err.ambiguous = true;
                err.cause = cause;
                throw err;
            }

            let text;

            try {
                text = await res.text();
            } catch (cause) {
                const err = new Error('The craft response could not be read');

                err.ambiguous = true;
                err.cause = cause;
                throw err;
            }

            let data = null;

            try {
                data = JSON.parse(text);
            } catch {
                // Validation below rejects non-JSON craft responses.
            }

            if (!res.ok) {
                const message =
                    data && (data.strError || data.message);
                const err = new Error(message || `HTTP ${res.status}`);

                err.status = res.status;
                err.fatal = res.status === 401 || res.status === 403;
                err.retryAfterMs =
                    res.status === 429 ? getRetryDelayMs(res) : 0;
                err.ambiguous = res.status >= 500;
                throw err;
            }

            if (!data || !data.Badge || typeof data.Badge !== 'object') {
                const err = new Error('Steam returned an unexpected craft response');

                err.ambiguous = true;
                throw err;
            }

            return data;
        }

        function readCraftLock() {
            if (!craftLockUsesStorage) {
                return null;
            }

            let raw;

            try {
                raw = localStorage.getItem(CRAFT_LOCK_KEY);
            } catch {
                craftLockUsesStorage = false;
                return null;
            }

            if (!raw) {
                return null;
            }

            try {
                return JSON.parse(raw);
            } catch {
                // Treat malformed lock data as stale without disabling
                // storage-backed cross-tab protection.
                return null;
            }
        }

        function writeCraftLock() {
            if (!craftLockUsesStorage) {
                return true;
            }

            try {
                localStorage.setItem(CRAFT_LOCK_KEY, JSON.stringify({
                    owner: craftLockOwner,
                    expiresAt: Date.now() + CRAFT_LOCK_TTL_MS,
                }));
                return true;
            } catch {
                craftLockUsesStorage = false;
                return true;
            }
        }

        function acquireCraftLock() {
            const current = readCraftLock();

            if (
                current &&
                current.owner !== craftLockOwner &&
                current.expiresAt > Date.now()
            ) {
                return false;
            }

            writeCraftLock();

            const saved = readCraftLock();

            return !craftLockUsesStorage || (
                saved &&
                saved.owner === craftLockOwner
            );
        }

        function refreshCraftLock() {
            const current = readCraftLock();

            if (
                current &&
                current.owner !== craftLockOwner &&
                current.expiresAt > Date.now()
            ) {
                return false;
            }

            return writeCraftLock();
        }

        function releaseCraftLock() {
            const current = readCraftLock();

            if (!craftLockUsesStorage || !current) {
                return;
            }

            if (current.owner === craftLockOwner) {
                try {
                    localStorage.removeItem(CRAFT_LOCK_KEY);
                } catch {
                    craftLockUsesStorage = false;
                }
            }
        }

        // Rows parsed from fetched badge pages retain Steam's transparent
        // placeholder. Steam's page script normally promotes this attribute
        // when a row enters the viewport, but it does not observe our clones.
        function activateDelayedBadgeArtwork(row) {
            row.querySelectorAll('img[data-delayed-image]')
                .forEach((image) => {
                    const source = image.getAttribute(
                        'data-delayed-image'
                    );

                    if (!source) {
                        return;
                    }

                    image.src = source;
                    image.loading = 'lazy';
                    image.decoding = 'async';
                    image.removeAttribute('data-delayed-image');
                    image.removeAttribute('data-delayed-image-group');
                });
        }

        function insertBadgeSearch(container, currentRows) {
            const searchBar = document.createElement('div');

            searchBar.id = 'spt-owned-badge-search';
            searchBar.style.cssText =
                'display:flex; align-items:flex-start; justify-content:flex-start; gap:10px; margin:10px 0; flex-wrap:wrap;';

            const searchField = document.createElement('div');

            searchField.style.cssText =
                'display:flex; flex-direction:column; gap:3px; width:320px; max-width:100%; flex:0 1 320px; min-width:0;';

            const input = document.createElement('input');

            input.id = 'spt-owned-badge-search-input';
            input.type = 'search';
            input.placeholder = 'Search owned badges...';
            input.setAttribute(
                'aria-label',
                'Search this profile\u2019s owned badges'
            );
            input.autocomplete = 'off';
            input.spellcheck = false;
            input.maxLength = 100;
            input.style.cssText =
                'width:100%; height:22px; box-sizing:border-box; padding:0 8px; border:1px solid #000; border-radius:3px; background:#2a3f5a; color:#fff; font-family:"Motiva Sans",Arial,sans-serif; font-size:12px; box-shadow:1px 1px 0 rgba(255,255,255,.1);';

            const searchStatus = document.createElement('span');

            searchStatus.id = 'spt-owned-badge-search-status';
            searchStatus.setAttribute('role', 'status');
            searchStatus.setAttribute('aria-live', 'polite');
            searchStatus.style.cssText =
                'display:block; color:#8f98a0; font-size:12px; line-height:14px; text-align:left;';

            searchField.appendChild(input);
            searchField.appendChild(searchStatus);
            searchBar.appendChild(searchField);
            container.insertBefore(searchBar, currentRows[0]);

            // Search aggregates every matching row onto this page. Preserve
            // Steam's inline pagination styles so the server-side pager can be
            // hidden for search mode and restored exactly when it ends.
            function getPaginationElements() {
                const pagingContainers = [
                    ...document.querySelectorAll('.profile_paging'),
                ];

                return pagingContainers.length
                    ? pagingContainers
                    : [
                        ...document.querySelectorAll(
                            '.profile_paging_summary, .profile_paging_links'
                        ),
                    ];
            }

            const paginationDisplays = new Map();
            const paginationHideReasons = new Set();
            const indexProgressListeners = new Set();
            let indexedBadges = null;
            let buildingBadgeIndex = null;
            let indexPromise = null;
            let resultClones = [];
            let searchActive = false;
            let searchVersion = 0;
            let debounceTimer = null;
            let beforeSearch = () => {};

            function setPaginationHidden(isHidden, reason = 'search') {
                if (isHidden) {
                    paginationHideReasons.add(reason);
                } else {
                    paginationHideReasons.delete(reason);
                }

                if (paginationHideReasons.size) {
                    getPaginationElements().forEach((element) => {
                        if (!paginationDisplays.has(element)) {
                            paginationDisplays.set(element, {
                                value: element.style.getPropertyValue('display'),
                                priority: element.style.getPropertyPriority(
                                    'display'
                                ),
                            });
                        }

                        element.style.setProperty(
                            'display',
                            'none',
                            'important'
                        );
                    });
                    return;
                }

                paginationDisplays.forEach((display, element) => {
                    if (display.value) {
                        element.style.setProperty(
                            'display',
                            display.value,
                            display.priority
                        );
                    } else {
                        element.style.removeProperty('display');
                    }
                });
                paginationDisplays.clear();
            }

            function removeResultClones() {
                resultClones.forEach((row) => row.remove());
                resultClones = [];
            }

            function clearSearch(resetInput = true) {
                window.clearTimeout(debounceTimer);
                searchVersion += 1;
                indexProgressListeners.clear();

                if (resetInput) {
                    input.value = '';
                }

                removeResultClones();

                if (searchActive) {
                    currentRows.forEach((row) => {
                        row.style.display = '';
                    });
                }

                searchActive = false;
                setPaginationHidden(false);
                searchStatus.textContent = '';
            }

            function notifyBadgeIndexProgress(update) {
                indexProgressListeners.forEach((listener) => {
                    listener(update);
                });
            }

            async function buildBadgeIndex() {
                const currentPage = getCurrentPageNumber();
                const detectedMaxPage = Math.max(
                    currentPage,
                    getMaxPageFromPagination() || 1
                );
                const maxPage = Math.min(detectedMaxPage, MAX_PAGES);
                const index = {
                    badges: [],
                    failedPages: 0,
                    loadingPage: null,
                    maxPage,
                    processedPages: 0,
                    truncated: detectedMaxPage > MAX_PAGES,
                };

                buildingBadgeIndex = index;

                for (let page = 1; page <= maxPage; page += 1) {
                    index.loadingPage = page;
                    notifyBadgeIndexProgress({
                        badges: [],
                        index,
                        loading: true,
                        page,
                        snapshot: false,
                    });

                    let pageRows;
                    const pageBadges = [];

                    if (page === currentPage) {
                        pageRows = currentRows;
                    } else {
                        pageRows = await fetchPageRowsWithRetry(page);
                    }

                    if (pageRows === null) {
                        index.failedPages += 1;
                    } else {
                        pageRows.forEach((row) => {
                            const text = getOwnedBadgeSearchText(row);

                            if (text) {
                                const badge = { row, text };

                                index.badges.push(badge);
                                pageBadges.push(badge);
                            }
                        });
                    }

                    index.loadingPage = null;
                    index.processedPages = page;
                    notifyBadgeIndexProgress({
                        badges: pageBadges,
                        index,
                        loading: false,
                        page,
                        snapshot: false,
                    });

                    if (page < maxPage) {
                        await sleep(100);
                    }
                }

                return index;
            }

            async function getBadgeIndex(onProgress) {
                if (indexedBadges) {
                    return indexedBadges;
                }

                const buildAlreadyRunning = Boolean(indexPromise);

                if (onProgress) {
                    indexProgressListeners.add(onProgress);
                }

                try {
                    if (!indexPromise) {
                        indexPromise = buildBadgeIndex()
                            .then((index) => {
                                indexedBadges = index;
                                return index;
                            })
                            .finally(() => {
                                buildingBadgeIndex = null;
                                indexPromise = null;
                            });
                    }

                    if (
                        buildAlreadyRunning &&
                        buildingBadgeIndex &&
                        onProgress
                    ) {
                        onProgress({
                            badges: buildingBadgeIndex.badges,
                            index: buildingBadgeIndex,
                            loading: Boolean(
                                buildingBadgeIndex.loadingPage
                            ),
                            page: buildingBadgeIndex.loadingPage ||
                                buildingBadgeIndex.processedPages,
                            snapshot: true,
                        });
                    }

                    return await indexPromise;
                } finally {
                    if (onProgress) {
                        indexProgressListeners.delete(onProgress);
                    }
                }
            }

            function appendMatchingBadges(badges, termPatterns) {
                let appended = 0;

                badges.forEach(({ row, text }) => {
                    if (
                        !termPatterns.every((pattern) => pattern.test(text))
                    ) {
                        return;
                    }

                    const clone = row.cloneNode(true);

                    clone.removeAttribute('id');
                    clone.dataset.sptBadgeSearchClone = '1';
                    clone.style.display = '';
                    activateDelayedBadgeArtwork(clone);
                    container.appendChild(clone);
                    resultClones.push(clone);
                    appended += 1;
                });

                return appended;
            }

            function renderResultSummary(index, matchCount) {
                // Steam can refresh its paging controls while results render.
                // Re-query and enforce search mode after the clones are added.
                setPaginationHidden(true);

                const countText = matchCount === 1
                    ? '1 owned badge'
                    : `${matchCount} owned badges`;
                const sourcePageText = index.maxPage === 1
                    ? '1 source page'
                    : `${index.maxPage} source pages`;
                const notes = [];

                if (index.failedPages) {
                    notes.push(
                        `${index.failedPages} page(s) could not be loaded`
                    );
                }

                if (index.truncated) {
                    notes.push(`search limited to ${MAX_PAGES} pages`);
                }

                searchStatus.textContent =
                    `Found ${countText} across ${sourcePageText}. ` +
                    'All matches are shown below.' +
                    (notes.length ? ` ${notes.join('; ')}.` : '');
            }

            async function runSearch(rawQuery) {
                const version = ++searchVersion;
                const query = normalizeBadgeSearchText(rawQuery);
                const termPatterns = query
                    .split(' ')
                    .filter(Boolean)
                    .map(createBadgeSearchTermPattern);
                let matchCount = 0;
                let renderedFromProgress = false;

                beforeSearch();
                searchActive = true;
                setPaginationHidden(true);
                removeResultClones();
                currentRows.forEach((row) => {
                    row.style.display = 'none';
                });
                searchStatus.textContent = 'Loading owned badges...';

                function handleProgress(update) {
                    if (
                        version !== searchVersion ||
                        !input.value.trim()
                    ) {
                        return;
                    }

                    if (update.snapshot || !update.loading) {
                        matchCount += appendMatchingBadges(
                            update.badges,
                            termPatterns
                        );
                        renderedFromProgress = true;
                    }

                    setPaginationHidden(true);

                    const countText = matchCount === 1
                        ? '1 owned badge'
                        : `${matchCount} owned badges`;

                    searchStatus.textContent = update.loading
                        ? `Found ${countText} so far. Loading page ` +
                            `${update.page} of ${update.index.maxPage}...`
                        : `Found ${countText} so far. Searched page ` +
                            `${update.page} of ${update.index.maxPage}...`;
                }

                let index;

                try {
                    index = await getBadgeIndex(handleProgress);
                } catch (err) {
                    if (
                        version !== searchVersion ||
                        !input.value.trim()
                    ) {
                        return;
                    }

                    throw err;
                }

                if (
                    version !== searchVersion ||
                    !input.value.trim()
                ) {
                    return;
                }

                if (!renderedFromProgress) {
                    matchCount = appendMatchingBadges(
                        index.badges,
                        termPatterns
                    );
                }

                renderResultSummary(index, matchCount);
            }

            input.addEventListener('input', () => {
                window.clearTimeout(debounceTimer);
                searchVersion += 1;
                indexProgressListeners.clear();

                if (!input.value.trim()) {
                    clearSearch(false);
                    return;
                }

                setPaginationHidden(true);
                debounceTimer = setTimeout(() => {
                    runSearch(input.value).catch((err) => {
                        console.error(
                            'Steam Page Tools: badge search failed',
                            err
                        );

                        if (input.value.trim()) {
                            removeResultClones();
                            currentRows.forEach((row) => {
                                row.style.display = '';
                            });
                            searchActive = false;
                            setPaginationHidden(false);
                            searchStatus.textContent =
                                'Badge search could not be loaded. Try again.';
                        }
                    });
                }, 200);
            });

            input.addEventListener('keydown', (event) => {
                if (event.key === 'Escape' && input.value) {
                    event.preventDefault();
                    clearSearch();
                    input.focus();
                }
            });

            return {
                clear: clearSearch,
                element: searchBar,
                setBeforeSearch(handler) {
                    beforeSearch = handler;
                },
                setDisabled(isDisabled) {
                    input.disabled = isDisabled;
                    input.style.opacity = isDisabled ? '0.6' : '';
                },
                setFilterPaginationHidden(isHidden) {
                    setPaginationHidden(isHidden, 'filter');
                },
            };
        }

        function insertControl() {
            const rows = getRows();

            if (!rows.length) {
                return false;
            }

            const container = rows[0].parentElement;
            const badgeSearch = insertBadgeSearch(container, rows);

            // Search is useful on every profile. Keep destructive and
            // account-specific controls exclusive to the signed-in owner.
            if (!isProbablyOwnProfilePage()) {
                return true;
            }

            const currentPage = getCurrentPageNumber();
            const detectedMaxPage = Math.max(
                currentPage,
                getMaxPageFromPagination() || 1
            );
            const maxPage = Math.min(detectedMaxPage, MAX_PAGES);
            const pageRowsPromises = new Map([
                [currentPage, Promise.resolve(rows)],
            ]);

            // The counter and filter scan the same pages. Cache those reads so
            // activating the filter while the count is running does not send a
            // second request for each page.
            function getPageRowsForDrops(page) {
                if (!pageRowsPromises.has(page)) {
                    const pageRowsPromise = fetchPageRowsWithRetry(page)
                        .then((pageRows) => {
                            if (pageRows === null) {
                                pageRowsPromises.delete(page);
                            }

                            return pageRows;
                        });

                    pageRowsPromises.set(page, pageRowsPromise);
                }

                return pageRowsPromises.get(page);
            }

            // Share the search row so all badge tools align as one responsive
            // toolbar instead of creating a second row with its own margins.
            const bar = badgeSearch.element;

            const actionGroup = document.createElement('div');

            actionGroup.id = 'spt-badge-actions';
            actionGroup.style.cssText =
                'display:flex; align-items:center; gap:3px; flex:0 0 auto; margin-left:auto;';

            const statusGroup = document.createElement('div');

            statusGroup.style.cssText =
                'display:flex; align-items:center; justify-content:flex-end; gap:10px; flex:0 1 auto; min-width:0; flex-wrap:wrap;';

            const dropCounterGroup = document.createElement('span');

            dropCounterGroup.setAttribute('role', 'status');
            dropCounterGroup.setAttribute('aria-live', 'polite');
            dropCounterGroup.setAttribute('aria-busy', 'true');
            dropCounterGroup.style.cssText =
                'display:inline-flex; align-items:center; gap:3px; flex:0 0 auto; color:#8f98a0; font-size:12px; white-space:nowrap;';

            const dropCounterLabel = document.createElement('span');

            dropCounterLabel.textContent = 'Games with drops:';

            const dropCounter = document.createElement('span');

            dropCounter.id = 'spt-drops-remaining-count';
            dropCounter.setAttribute(
                'aria-label',
                '0 games with card drops remaining'
            );
            dropCounter.style.cssText =
                'display:inline-block; color:#a4d007; min-width:1ch; font-weight:600; text-align:left; font-variant-numeric:tabular-nums;';
            dropCounter.textContent = '0';
            dropCounter.title = 'Games with card drops remaining';

            const dropCounterLoader = document.createElement('span');

            dropCounterLoader.setAttribute('aria-hidden', 'true');
            dropCounterLoader.style.cssText =
                'display:inline-block; width:10px; height:10px; box-sizing:border-box; border:1.5px solid rgba(143,152,160,.35); border-top-color:#8f98a0; border-radius:50%;';

            const dropCounterLoadingAnimation = window.matchMedia(
                '(prefers-reduced-motion: reduce)'
            ).matches
                ? null
                : dropCounterLoader.animate(
                    [
                        { transform: 'rotate(0deg)' },
                        { transform: 'rotate(360deg)' },
                    ],
                    {
                        duration: 700,
                        iterations: Infinity,
                    }
                );

            dropCounterGroup.appendChild(dropCounterLabel);
            dropCounterGroup.appendChild(dropCounter);
            dropCounterGroup.appendChild(dropCounterLoader);

            const toggle = document.createElement('div');

            toggle.id = 'drop-filter-toggle';
            toggle.className = 'btnv6_blue_hoverfade btn_small';
            toggle.style.cssText = 'cursor:pointer; user-select:none;';
            toggle.setAttribute('role', 'button');
            toggle.setAttribute('aria-pressed', 'false');
            toggle.tabIndex = 0;

            toggle.innerHTML = '<span>Show only drops remaining</span>';

            const craftStatus = document.createElement('span');
            craftStatus.id = 'spt-auto-craft-status';
            craftStatus.style.cssText =
                'color:#8f98a0; font-size:12px; max-width:360px;';

            const autoCraft = document.createElement('div');
            autoCraft.id = 'spt-auto-craft';
            autoCraft.className = 'btnv6_blue_hoverfade btn_small';
            autoCraft.style.cssText = 'cursor:pointer; user-select:none;';
            autoCraft.setAttribute('role', 'button');
            autoCraft.tabIndex = 0;
            autoCraft.title =
                'Craft every complete card set across all badge pages';

            const autoCraftLabel = document.createElement('span');
            autoCraftLabel.textContent = 'Auto Craft All Badges';
            autoCraft.appendChild(autoCraftLabel);

            statusGroup.appendChild(craftStatus);
            actionGroup.appendChild(dropCounterGroup);
            actionGroup.appendChild(toggle);
            actionGroup.appendChild(autoCraft);
            bar.appendChild(statusGroup);
            bar.appendChild(actionGroup);

            let active = false;
            let busy = false;
            let craftBusy = false;
            let stopCrafting = false;
            let extraRows = [];

            function setBusy(isBusy) {
                busy = isBusy;

                toggle.style.pointerEvents = isBusy ? 'none' : '';
                toggle.style.opacity = isBusy ? '0.6' : '';

                if (!craftBusy) {
                    autoCraft.style.pointerEvents = isBusy ? 'none' : '';
                    autoCraft.style.opacity = isBusy ? '0.6' : '';
                }
            }

            function setCraftBusy(isBusy) {
                craftBusy = isBusy;
                badgeSearch.setDisabled(isBusy);

                toggle.style.pointerEvents = isBusy || busy ? 'none' : '';
                toggle.style.opacity = isBusy || busy ? '0.6' : '';

                autoCraft.style.pointerEvents = '';
                autoCraft.style.opacity = '';
                autoCraftLabel.textContent = isBusy
                    ? 'Stop Auto Craft'
                    : 'Auto Craft All Badges';
                autoCraft.title = isBusy
                    ? 'Stop after the current badge'
                    : 'Craft every complete card set across all badge pages';
            }

            function setCraftStatus(text, withReload = false) {
                craftStatus.textContent = text;

                if (withReload) {
                    craftStatus.appendChild(document.createTextNode(' '));

                    const link = document.createElement('a');

                    link.href = location.href;
                    link.textContent = 'Reload badges';
                    link.style.color = '#67c1f1';

                    craftStatus.appendChild(link);
                }
            }

            // Steam's button styles have no pressed state, so add an inset
            // border while the filter is active.
            function setActiveStyle(isActive) {
                toggle.style.boxShadow =
                    isActive ? 'inset 0 0 0 1px #67c1f1' : '';
                toggle.setAttribute('aria-pressed', String(isActive));
            }

            function finishDropCounterLoading(isComplete) {
                dropCounterLoadingAnimation?.cancel();
                dropCounterLoader.style.cssText =
                    `display:inline-flex; width:10px; height:10px; align-items:center; justify-content:center; color:${isComplete ? '#a4d007' : '#d9a300'}; font-size:10px; font-weight:700; line-height:1;`;
                dropCounterLoader.textContent = isComplete ? '\u2713' : '!';
                dropCounterLoader.title = isComplete
                    ? 'Scan complete'
                    : 'Scan incomplete';
                dropCounterGroup.setAttribute('aria-busy', 'false');
            }

            async function countGamesWithDropsRemaining() {
                let count = 0;
                let failedPages = 0;

                for (let page = 1; page <= maxPage; page += 1) {
                    const pageRows = await getPageRowsForDrops(page);

                    if (pageRows === null) {
                        failedPages += 1;
                    } else {
                        count += pageRows.filter(hasDropsRemaining).length;
                    }

                    const nextCount = String(count);

                    if (dropCounter.textContent !== nextCount) {
                        dropCounter.textContent = nextCount;
                    }

                    if (page < maxPage) {
                        await sleep(100);
                    }
                }

                const incomplete = (
                    failedPages > 0 || detectedMaxPage > MAX_PAGES
                );
                const gameLabel = count === 1 ? 'game' : 'games';

                dropCounter.textContent = incomplete
                    ? `${count}+`
                    : String(count);
                dropCounter.setAttribute(
                    'aria-label',
                    incomplete
                        ? `At least ${count} ${gameLabel} with card drops remaining`
                        : `${count} ${gameLabel} with card drops remaining`
                );

                const notes = [];

                if (failedPages) {
                    notes.push(`${failedPages} page(s) could not be loaded`);
                }

                if (detectedMaxPage > MAX_PAGES) {
                    notes.push(`count limited to ${MAX_PAGES} pages`);
                }

                dropCounter.title = notes.length
                    ? `${notes.join('; ')}.`
                    : 'Number of games with card drops remaining across all badge pages';
                finishDropCounterLoading(!incomplete);
            }

            function resetDropFilter() {
                active = false;
                setActiveStyle(false);
                badgeSearch.setFilterPaginationHidden(false);

                getRows().forEach((row) => {
                    row.style.display = '';
                });

                extraRows.forEach((row) => {
                    row.remove();
                });

                extraRows = [];
                setBusy(false);
            }

            badgeSearch.setBeforeSearch(() => {
                if (active || busy) {
                    resetDropFilter();
                }
            });

            async function runFilter() {
                getRows().forEach((row) => {
                    row.style.display = hasDropsRemaining(row) ? '' : 'none';
                });

                setBusy(true);

                for (let page = 1; page <= maxPage && active; page += 1) {
                    if (page === currentPage) {
                        continue;
                    }

                    const pageWasCached = pageRowsPromises.has(page);

                    const rows = await getPageRowsForDrops(page);

                    if (!active) {
                        break;
                    }

                    if (rows === null) {
                        continue;
                    }

                    // Preserve every row: normal and foil badges share an app
                    // ID and detail URL but represent separate drop states.
                    rows
                        .filter(hasDropsRemaining)
                        .forEach((row) => {
                            const clone = row.cloneNode(true);

                            clone.dataset.sptDropFilterClone = '1';
                            activateDelayedBadgeArtwork(clone);
                            container.appendChild(clone);
                            extraRows.push(clone);
                        });

                    if (!pageWasCached) {
                        await sleep(200);
                    }
                }

                setBusy(false);
            }

            countGamesWithDropsRemaining()
                .catch((err) => {
                    console.error(
                        'Steam Page Tools: failed counting games with card drops',
                        err
                    );
                    dropCounter.textContent = '?';
                    dropCounter.setAttribute(
                        'aria-label',
                        'Card-drop count unavailable'
                    );
                    dropCounter.title =
                        'Reload the page to try counting again.';
                    finishDropCounterLoading(false);
                });

            async function onToggle() {
                if (busy || craftBusy) {
                    return;
                }

                const activating = !active;

                if (activating) {
                    badgeSearch.clear();
                }

                if (!activating) {
                    resetDropFilter();
                    return;
                }

                active = true;
                setActiveStyle(true);
                badgeSearch.setFilterPaginationHidden(true);
                await runFilter();
            }

            async function collectCraftTargets(freshAllPages = false) {
                const currentPage = getCurrentPageNumber();
                const detectedMaxPage = Math.max(
                    currentPage,
                    getMaxPageFromPagination() || 1
                );

                if (detectedMaxPage > MAX_PAGES) {
                    throw new Error(
                        `Badge list has more than the ${MAX_PAGES}-page safety limit`
                    );
                }

                const targets = new Map();

                for (let page = 1; page <= detectedMaxPage; page += 1) {
                    if (stopCrafting) {
                        return {
                            targets: [...targets.values()],
                            cancelled: true,
                        };
                    }

                    setCraftStatus(
                        `Scanning badge page ${page} of ${detectedMaxPage}...`
                    );

                    let pageRows;

                    if (page === currentPage && !freshAllPages) {
                        pageRows = getRows()
                            .filter((row) => (
                                !row.dataset.sptDropFilterClone &&
                                !row.dataset.sptBadgeSearchClone
                            ));
                    } else {
                        pageRows = await fetchPageRowsWithRetry(page);

                        if (pageRows === null) {
                            throw new Error(
                                `Could not scan badge page ${page}`
                            );
                        }
                    }

                    pageRows.forEach((row) => {
                        const target = getCraftTarget(row);

                        if (target) {
                            targets.set(target.key, target);
                        }
                    });

                    if (page !== currentPage || freshAllPages) {
                        await sleep(150);
                    }
                }

                return {
                    targets: [...targets.values()],
                    cancelled: false,
                };
            }

            function requestCraftConfirmation(count) {
                const description =
                    `Found ${count} ready badge type(s). ` +
                    'Craft every complete card set available for them? ' +
                    'Steam Page Tools will use the 5x option when Steam ' +
                    'offers it and will also craft badges made ready by ' +
                    'crafting rewards. This consumes cards and cannot be undone.';

                if (typeof window.ShowConfirmDialog === 'function') {
                    try {
                        return new Promise((resolve) => {
                            window.ShowConfirmDialog(
                                'Auto Craft All Badges',
                                description,
                                'Craft All',
                                'Cancel'
                            )
                                .done(() => resolve(true))
                                .fail(() => resolve(false));
                        });
                    } catch (err) {
                        console.error(
                            'Steam Page Tools: failed opening Steam confirmation',
                            err
                        );
                    }
                }

                return Promise.resolve(window.confirm(description));
            }

            function addCraftFailure(summary, target, err) {
                if (!summary.failed.has(target.key)) {
                    summary.failed.set(target.key, {
                        name: target.name,
                        message: err.message,
                    });
                }
            }

            async function craftEveryLevel(
                target,
                sessionid,
                summary,
                position,
                total
            ) {
                let craftedForTarget = 0;
                let attemptedMutation = false;
                let consecutiveAmbiguous = 0;
                let throttleCount = 0;

                while (
                    !stopCrafting &&
                    summary.batches < MAX_CRAFT_BATCHES
                ) {
                    setCraftStatus(
                        `Checking ${position} of ${total}: ${target.name}...`
                    );

                    let option;

                    try {
                        option = await fetchCraftOption(target);
                    } catch (err) {
                        console.error(
                            'Steam Page Tools: failed checking craft state',
                            target.key,
                            err
                        );

                        if (err.fatal) {
                            throw err;
                        }

                        addCraftFailure(summary, target, err);
                        return craftedForTarget;
                    }

                    if (!option) {
                        if (
                            !craftedForTarget &&
                            !attemptedMutation &&
                            !summary.badges.has(target.key)
                        ) {
                            summary.skipped.add(target.key);
                        }

                        return craftedForTarget;
                    }

                    if (stopCrafting) {
                        return craftedForTarget;
                    }

                    if (!refreshCraftLock()) {
                        const err = new Error(
                            'Another tab took over the badge craft queue'
                        );

                        err.fatal = true;
                        throw err;
                    }

                    const levelWord =
                        option.levels === 1 ? 'level' : 'levels';

                    setCraftStatus(
                        `Crafting ${position} of ${total}: ${target.name} ` +
                        `(${option.levels} ${levelWord})...`
                    );

                    attemptedMutation = true;
                    summary.attempts += 1;

                    try {
                        await postCraft(
                            target,
                            option.levels,
                            sessionid
                        );

                        summary.batches += 1;
                        summary.levels += option.levels;
                        summary.badges.add(target.key);
                        summary.skipped.delete(target.key);
                        craftedForTarget += option.levels;
                        consecutiveAmbiguous = 0;
                        throttleCount = 0;
                    } catch (err) {
                        console.error(
                            'Steam Page Tools: failed crafting badge',
                            target.key,
                            err
                        );

                        if (err.fatal) {
                            throw err;
                        }

                        if (err.status === 429) {
                            throttleCount += 1;

                            if (throttleCount > 3) {
                                addCraftFailure(summary, target, err);
                                return craftedForTarget;
                            }

                            const waitSeconds = Math.ceil(
                                err.retryAfterMs / 1000
                            );

                            setCraftStatus(
                                `Steam is rate limiting requests. ` +
                                `Retrying ${target.name} in ` +
                                `${waitSeconds} seconds...`
                            );

                            await sleep(err.retryAfterMs);
                            continue;
                        }

                        if (err.ambiguous) {
                            summary.uncertain += 1;
                            consecutiveAmbiguous += 1;

                            if (consecutiveAmbiguous >= 3) {
                                addCraftFailure(summary, target, err);
                                return craftedForTarget;
                            }

                            // Reconcile an ambiguous mutation against fresh
                            // badge state before issuing another POST.
                            setCraftStatus(
                                `Rechecking ${target.name} after an ` +
                                'uncertain response...'
                            );
                            await sleep(1500);
                            continue;
                        }

                        addCraftFailure(summary, target, err);
                        return craftedForTarget;
                    }

                    if (!stopCrafting) {
                        await sleep(
                            800 + Math.floor(Math.random() * 401)
                        );
                    }
                }

                return craftedForTarget;
            }

            function renderCraftSummary(summary, runError) {
                const prefix = stopCrafting ? 'Stopped.' : (
                    runError ? 'Stopped after an error.' : 'Finished.'
                );
                const totals = summary.uncertain
                    ? `Confirmed ${summary.levels} level(s) across ` +
                        `${summary.badges.size} badge(s); totals may be higher.`
                    : `Crafted ${summary.levels} level(s) across ` +
                        `${summary.badges.size} badge(s).`;
                const parts = [
                    prefix,
                    totals,
                ];

                if (summary.skipped.size) {
                    parts.push(
                        `${summary.skipped.size} stale badge(s) skipped.`
                    );
                }

                if (summary.failed.size) {
                    parts.push(
                        `${summary.failed.size} badge(s) failed; ` +
                        'details are in the console.'
                    );
                }

                if (summary.uncertain) {
                    parts.push(
                        `${summary.uncertain} response(s) were uncertain; ` +
                        'fresh badge state was checked before continuing.'
                    );
                }

                if (summary.safetyLimit) {
                    parts.push('Stopped at the automatic safety limit.');
                }

                if (runError) {
                    parts.push(runError.message);
                }

                setCraftStatus(
                    parts.join(' '),
                    summary.attempts > 0
                );
            }

            async function onAutoCraft() {
                if (craftBusy) {
                    stopCrafting = true;
                    autoCraftLabel.textContent = 'Stopping...';
                    autoCraft.style.pointerEvents = 'none';
                    autoCraft.style.opacity = '0.6';
                    setCraftStatus(
                        'Stopping after the current badge request...'
                    );
                    return;
                }

                if (busy) {
                    setCraftStatus(
                        'Wait for the card-drop filter to finish first.'
                    );
                    return;
                }

                const sessionid = getSessionId();

                if (!sessionid || !profileUrl) {
                    setCraftStatus(
                        'Could not verify your Steam session. Refresh and try again.'
                    );
                    return;
                }

                stopCrafting = false;
                badgeSearch.clear();
                setCraftBusy(true);

                const summary = {
                    attempts: 0,
                    batches: 0,
                    levels: 0,
                    uncertain: 0,
                    badges: new Set(),
                    skipped: new Set(),
                    failed: new Map(),
                    safetyLimit: false,
                };
                let lockHeld = false;
                let lockHeartbeat = null;
                let runError = null;

                try {
                    setCraftStatus('Verifying this is your badges page...');

                    if (!await verifyOwnProfilePage()) {
                        setCraftStatus(
                            'Auto craft is only available on your own badges page.'
                        );
                        return;
                    }

                    const initialScan = await collectCraftTargets(false);

                    if (initialScan.cancelled || stopCrafting) {
                        setCraftStatus('Auto craft stopped before crafting.');
                        return;
                    }

                    if (!initialScan.targets.length) {
                        setCraftStatus(
                            'No badges are currently ready to craft.'
                        );
                        return;
                    }

                    const confirmed = await requestCraftConfirmation(
                        initialScan.targets.length
                    );

                    if (!confirmed) {
                        setCraftStatus('Auto craft cancelled.');
                        return;
                    }

                    if (!acquireCraftLock()) {
                        setCraftStatus(
                            'Another tab is already auto crafting badges.'
                        );
                        return;
                    }

                    lockHeld = true;
                    lockHeartbeat = setInterval(() => {
                        if (!refreshCraftLock()) {
                            stopCrafting = true;
                            setCraftStatus(
                                'Another tab took over auto craft. ' +
                                'Stopping after the current request...'
                            );
                        }
                    }, 20000);

                    let targets = initialScan.targets;

                    for (
                        let pass = 1;
                        pass <= MAX_CRAFT_PASSES && targets.length;
                        pass += 1
                    ) {
                        const attemptsBeforePass = summary.attempts;

                        for (
                            let index = 0;
                            index < targets.length;
                            index += 1
                        ) {
                            if (
                                stopCrafting ||
                                summary.batches >= MAX_CRAFT_BATCHES
                            ) {
                                break;
                            }

                            const target = targets[index];

                            if (summary.failed.has(target.key)) {
                                continue;
                            }

                            await craftEveryLevel(
                                target,
                                sessionid,
                                summary,
                                index + 1,
                                targets.length
                            );
                        }

                        if (
                            stopCrafting ||
                            summary.batches >= MAX_CRAFT_BATCHES
                        ) {
                            summary.safetyLimit =
                                summary.batches >= MAX_CRAFT_BATCHES;
                            break;
                        }

                        // A pass without mutation attempts indicates stale
                        // rows; stop instead of rescanning indefinitely.
                        if (summary.attempts === attemptsBeforePass) {
                            break;
                        }

                        setCraftStatus(
                            'Rescanning for badges made ready by rewards...'
                        );

                        const nextScan = await collectCraftTargets(true);

                        if (nextScan.cancelled || stopCrafting) {
                            break;
                        }

                        targets = nextScan.targets
                            .filter((target) => (
                                !summary.failed.has(target.key)
                            ));

                        if (
                            pass === MAX_CRAFT_PASSES &&
                            targets.length
                        ) {
                            summary.safetyLimit = true;
                        }
                    }
                } catch (err) {
                    runError = err;
                    console.error(
                        'Steam Page Tools: auto craft stopped',
                        err
                    );
                } finally {
                    if (lockHeartbeat !== null) {
                        clearInterval(lockHeartbeat);
                    }

                    if (lockHeld) {
                        releaseCraftLock();
                    }

                    if (
                        summary.attempts ||
                        summary.skipped.size ||
                        summary.failed.size ||
                        runError
                    ) {
                        renderCraftSummary(summary, runError);
                    }

                    stopCrafting = false;
                    autoCraft.style.pointerEvents = '';
                    autoCraft.style.opacity = '';
                    setCraftBusy(false);
                }
            }

            bindButtonActivation(toggle, onToggle);
            bindButtonActivation(autoCraft, onAutoCraft);

            window.addEventListener('storage', (e) => {
                if (
                    craftBusy &&
                    e.key === CRAFT_LOCK_KEY
                ) {
                    const current = readCraftLock();

                    if (
                        current &&
                        current.owner !== craftLockOwner &&
                        current.expiresAt > Date.now()
                    ) {
                        stopCrafting = true;
                        setCraftStatus(
                            'Another tab started auto craft. ' +
                            'Stopping this queue after the current request...'
                        );
                    }
                }
            });

            return true;
        }

        // Steam may render the badge list after DOMContentLoaded, so poll
        // briefly.
        let attempts = 0;

        const interval = setInterval(() => {
            attempts += 1;

            if (insertControl() || attempts > 20) {
                clearInterval(interval);
            }
        }, 250);
    }

    // Store search bulk actions.
    // Bundle results are skipped because they require bundle-package lookup.
    // Apps with multiple purchase options use Steam's first/default package,
    // matching the primary action on the app page.
    function initSearchBulkCart() {
        const STORAGE_KEY = 'spt-search-cart-selection';
        const LEGACY_STORAGE_KEY = 'dbf-search-cart-selection';

        // Map app IDs to their display names and rendered checkboxes.
        const selected = new Map();
        let busy = false;

        injectStyles();

        const bar = buildBar();
        document.body.appendChild(bar.el);

        restoreSelection();

        // Persist selections until each game succeeds or the user clears
        // them, including across navigation and browser restarts.
        function loadStoredSelection() {
            try {
                let raw = localStorage.getItem(STORAGE_KEY);

                // Migrate selections saved under the previous script name.
                if (!raw) {
                    raw = localStorage.getItem(LEGACY_STORAGE_KEY);

                    if (raw) {
                        // Restore readable legacy data even if migration is
                        // blocked by storage permissions or quota.
                        try {
                            localStorage.setItem(STORAGE_KEY, raw);
                            localStorage.removeItem(LEGACY_STORAGE_KEY);
                        } catch (err) {
                            console.warn(
                                'Steam Page Tools: could not migrate saved selection',
                                err
                            );
                        }
                    }
                }

                if (!raw) {
                    return new Map();
                }

                return new Map(Object.entries(JSON.parse(raw)));
            } catch (err) {
                console.error('Steam Page Tools: failed to read saved selection', err);
                return new Map();
            }
        }

        function saveSelection() {
            try {
                const obj = {};

                selected.forEach((info, appid) => {
                    obj[appid] = info.name;
                });

                localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
            } catch (err) {
                console.error('Steam Page Tools: failed to save selection', err);
            }
        }

        // Restored selections acquire a checkbox reference when their row is
        // rendered.
        function restoreSelection() {
            const stored = loadStoredSelection();

            if (!stored.size) {
                return;
            }

            stored.forEach((name, appid) => {
                selected.set(appid, { name, box: null });
            });

            updateBar();
            setStatus(`Restored ${stored.size} selected game(s) from before.`);
        }

        function clearSelection() {
            if (busy) {
                return;
            }

            selected.forEach((info) => {
                if (info.box) {
                    info.box.classList.remove('spt-checked');
                    info.box.setAttribute('aria-checked', 'false');
                }
            });

            selected.clear();
            saveSelection();
            updateBar();
        }

        let scanAttempts = 0;

        const scanInterval = setInterval(() => {
            scanAttempts += 1;

            const container =
                document.querySelector('#search_resultsRows') ||
                document.querySelector('.search_results_rows');

            if (container) {
                container
                    .querySelectorAll('.search_result_row')
                    .forEach(processRow);

                observeResults(container);

                clearInterval(scanInterval);
            } else if (scanAttempts > 20) {
                clearInterval(scanInterval);
            }
        }, 250);

        // Observe infinite-scroll results as well as the initial result set.
        function observeResults(container) {
            const observer = new MutationObserver((mutations) => {
                for (const mutation of mutations) {
                    mutation.addedNodes.forEach((node) => {
                        if (node.nodeType !== 1) {
                            return;
                        }

                        if (node.matches && node.matches('.search_result_row')) {
                            processRow(node);
                        } else if (node.querySelectorAll) {
                            node
                                .querySelectorAll('.search_result_row')
                                .forEach(processRow);
                        }
                    });
                }
            });

            observer.observe(container, { childList: true, subtree: true });
        }

        function extractAppId(row) {
            const raw = row.getAttribute('data-ds-appid');

            if (raw) {
                // Comma-separated app IDs identify an unsupported bundle.
                const appid = raw.trim();

                return !raw.includes(',') && /^\d+$/.test(appid)
                    ? appid
                    : null;
            }

            // Fall back to the app ID embedded in the result URL.
            const href = row.getAttribute('href') || '';
            const match = href.match(/\/app\/(\d+)\//);

            return match ? match[1] : null;
        }

        function processRow(row) {
            if (row.dataset.sptProcessed) {
                return;
            }

            row.dataset.sptProcessed = '1';

            const appid = extractAppId(row);

            if (!appid) {
                return;
            }

            const nameEl = row.querySelector('.title');
            const name = nameEl ? nameEl.textContent.trim() : `App ${appid}`;

            // Position against the capsule; the full row includes the price
            // column.
            const capsule = row.querySelector('.search_capsule') || row;

            if (getComputedStyle(capsule).position === 'static') {
                capsule.style.position = 'relative';
            }

            const box = document.createElement('div');

            box.className = 'spt-cart-checkbox';
            box.tabIndex = 0;
            box.setAttribute('role', 'checkbox');
            box.setAttribute('aria-checked', 'false');
            box.title = 'Select for bulk cart or wishlist actions';

            if (selected.has(appid)) {
                const info = selected.get(appid);

                info.box = box;
                box.classList.add('spt-checked');
                box.setAttribute('aria-checked', 'true');
            }

            // Prevent the checkbox from following the enclosing result link.
            box.addEventListener('mousedown', (e) => e.stopPropagation());
            box.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                toggleSelection(appid, name, box);
            });

            box.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    e.stopPropagation();
                    toggleSelection(appid, name, box);
                }
            });

            capsule.appendChild(box);
        }

        function toggleSelection(appid, name, box) {
            if (busy) {
                return;
            }

            if (selected.has(appid)) {
                selected.delete(appid);
                box.classList.remove('spt-checked');
                box.setAttribute('aria-checked', 'false');
            } else {
                selected.set(appid, { name, box });
                box.classList.add('spt-checked');
                box.setAttribute('aria-checked', 'true');
            }

            saveSelection();
            updateBar();
        }

        function updateBar() {
            const count = selected.size;

            bar.cartLabel.textContent = `Add ${count} to Cart`;
            bar.wishlistLabel.textContent = `Add ${count} to Wishlist`;

            if (count > 0) {
                bar.el.classList.add('spt-visible');
            } else if (!busy) {
                bar.el.classList.remove('spt-visible');
                setStatus('');
            }
        }

        function setStatus(text, destination) {
            bar.status.textContent = text;

            if (destination) {
                bar.status.appendChild(document.createTextNode(' '));

                const link = document.createElement('a');

                if (destination === 'wishlist') {
                    link.href = requirePinnedSteamUrl(
                        '/wishlist/',
                        STEAM_STORE_ORIGIN
                    ).href;
                    link.textContent = 'View wishlist';
                } else {
                    link.href = requirePinnedSteamUrl(
                        '/cart/',
                        STEAM_STORE_ORIGIN
                    ).href;
                    link.textContent = 'View cart';
                }

                link.style.color = '#67c1f1';

                bar.status.appendChild(link);
            }
        }

        function readStoreAccount() {
            const configEl = document.querySelector('#application_config');
            let userInfo = {};
            let appConfig = {};

            if (configEl) {
                try {
                    userInfo = JSON.parse(configEl.dataset.userinfo || '{}');
                } catch (err) {
                    console.warn(
                        'Steam Page Tools: could not read Steam user info',
                        err
                    );
                }

                try {
                    appConfig = JSON.parse(configEl.dataset.config || '{}');
                } catch (err) {
                    console.warn(
                        'Steam Page Tools: could not read Steam store config',
                        err
                    );
                }
            }

            const accountid = String(
                userInfo.accountid || window.g_AccountID || ''
            );
            const country = String(
                userInfo.country_code || appConfig.COUNTRY || ''
            ).toUpperCase();

            if (!accountid || accountid === '0' || !country) {
                throw new Error('not signed in or account data is unavailable');
            }

            return { accountid, country };
        }

        function bumpDynamicStoreVersion() {
            try {
                const current =
                    parseInt(
                        localStorage.getItem('unUserdataVersion') || '0',
                        10
                    ) || 0;
                const next = current + 1;

                localStorage.setItem('unUserdataVersion', String(next));

                return next;
            } catch {
                return Date.now();
            }
        }

        // Refresh the complete account wishlist before each run because DOM
        // state can be stale or incomplete.
        async function fetchCurrentWishlist() {
            const { accountid, country } = readStoreAccount();
            const version = bumpDynamicStoreVersion();
            const url = requirePinnedSteamUrl(
                '/dynamicstore/userdata/',
                STEAM_STORE_ORIGIN
            );

            url.searchParams.set('id', accountid);
            url.searchParams.set('cc', country);
            url.searchParams.set('origin', location.origin);
            url.searchParams.set('v', String(version));
            url.searchParams.set('_', String(Date.now()));

            const res = await fetch(url.toString(), {
                credentials: 'include',
                cache: 'no-store',
                headers: { Accept: 'application/json' },
            });

            requirePinnedSteamResponseUrl(
                res,
                STEAM_STORE_ORIGIN,
                url
            );

            if (!res.ok) {
                throw new Error(`HTTP ${res.status}`);
            }

            const json = await res.json();

            if (!json || !Array.isArray(json.rgWishlist)) {
                throw new Error('Steam returned invalid wishlist data');
            }

            return new Set(json.rgWishlist.map((appid) => String(appid)));
        }

        async function addAppToWishlist(appid, sessionid) {
            const mutationUrl = requirePinnedSteamUrl(
                '/api/addtowishlist',
                STEAM_STORE_ORIGIN
            );
            const body = new URLSearchParams({
                sessionid,
                appid: String(appid),
            });

            const res = await fetch(
                mutationUrl,
                {
                    method: 'POST',
                    credentials: 'include',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        Accept: 'application/json',
                    },
                    body: body.toString(),
                }
            );

            requirePinnedSteamResponseUrl(
                res,
                STEAM_STORE_ORIGIN,
                mutationUrl
            );

            if (!res.ok) {
                const err = new Error(`HTTP ${res.status}`);

                err.rateLimited = res.status === 429;
                throw err;
            }

            const json = await res.json();

            if (!json || (json.success !== true && json.success !== 1)) {
                throw new Error('Steam did not confirm the wishlist change');
            }
        }

        function removeHandledSelection(appid, info) {
            selected.delete(appid);

            if (info.box) {
                info.box.classList.remove('spt-checked');
                info.box.setAttribute('aria-checked', 'false');
            }
        }

        function syncDynamicWishlist(appids) {
            const dynamicStore = window.GDynamicStore;

            try {
                if (dynamicStore && dynamicStore.s_rgWishlist) {
                    appids.forEach((appid) => {
                        dynamicStore.s_rgWishlist[appid] = true;
                    });
                }

                if (
                    dynamicStore &&
                    typeof dynamicStore.InvalidateCache === 'function'
                ) {
                    dynamicStore.InvalidateCache();
                } else {
                    bumpDynamicStoreVersion();
                }
            } catch {
                bumpDynamicStoreVersion();
            }
        }

        // Cart mutations require a package (sub) ID, so resolve Steam's
        // default purchase option for each app.
        async function resolveSubId(appid) {
            if (!/^\d+$/.test(String(appid))) {
                throw new Error('invalid Steam app ID');
            }

            const detailsUrl = requirePinnedSteamUrl(
                '/api/appdetails',
                STEAM_STORE_ORIGIN
            );

            detailsUrl.searchParams.set('appids', String(appid));

            const res = await fetch(detailsUrl, { credentials: 'include' });

            requirePinnedSteamResponseUrl(
                res,
                STEAM_STORE_ORIGIN,
                detailsUrl
            );

            if (!res.ok) {
                throw new Error(`HTTP ${res.status}`);
            }

            const json = await res.json();
            const entry = json[appid];

            if (!entry || !entry.success || !entry.data) {
                throw new Error('no purchase data for this app');
            }

            const groups = entry.data.package_groups;

            if (groups && groups.length && groups[0].subs && groups[0].subs.length) {
                return groups[0].subs[0].packageid;
            }

            if (entry.data.packages && entry.data.packages.length) {
                return entry.data.packages[0];
            }

            throw new Error('no purchasable package found');
        }

        async function addSubToCart(subid, sessionid) {
            if (!/^\d+$/.test(String(subid))) {
                throw new Error('invalid Steam package ID');
            }

            const mutationUrl = requirePinnedSteamUrl(
                '/cart/',
                STEAM_STORE_ORIGIN
            );
            const body = new URLSearchParams({
                action: 'add_to_cart',
                sessionid,
                subid: String(subid),
            });

            const res = await fetch(mutationUrl, {
                method: 'POST',
                credentials: 'include',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: body.toString(),
            });

            requirePinnedSteamResponseUrl(
                res,
                STEAM_STORE_ORIGIN,
                mutationUrl
            );

            if (!res.ok) {
                throw new Error(`HTTP ${res.status}`);
            }
        }

        function buildBar() {
            const el = document.createElement('div');

            el.id = 'spt-cart-bar';

            const status = document.createElement('span');

            status.id = 'spt-cart-status';

            const actions = document.createElement('div');

            actions.id = 'spt-store-actions';

            const addWrap = document.createElement('div');

            addWrap.className = 'btn_addtocart';

            const addBtn = document.createElement('a');

            addBtn.id = 'spt-add-to-cart-btn';
            addBtn.className = 'btnv6_green_white_innerfade btn_medium';
            addBtn.setAttribute('role', 'button');
            addBtn.tabIndex = 0;

            const cartLabel = document.createElement('span');

            cartLabel.textContent = 'Add 0 to Cart';

            addBtn.appendChild(cartLabel);
            addWrap.appendChild(addBtn);

            const wishlistWrap = document.createElement('div');

            wishlistWrap.className = 'btn_addtocart';

            const wishlistBtn = document.createElement('a');

            wishlistBtn.id = 'spt-add-to-wishlist-btn';
            wishlistBtn.className = 'btnv6_blue_hoverfade btn_medium';
            wishlistBtn.setAttribute('role', 'button');
            wishlistBtn.tabIndex = 0;
            wishlistBtn.title =
                'Already-wishlisted games are checked and skipped automatically';

            const wishlistLabel = document.createElement('span');

            wishlistLabel.textContent = 'Add 0 to Wishlist';

            wishlistBtn.appendChild(wishlistLabel);
            wishlistWrap.appendChild(wishlistBtn);
            actions.appendChild(addWrap);
            actions.appendChild(wishlistWrap);

            const closeBtn = document.createElement('span');

            closeBtn.id = 'spt-cart-close';
            closeBtn.textContent = '\u2715';
            closeBtn.title = 'Clear selection';
            closeBtn.setAttribute('role', 'button');
            closeBtn.tabIndex = 0;

            closeBtn.addEventListener('click', () => {
                clearSelection();
            });

            closeBtn.addEventListener('keydown', (e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    closeBtn.click();
                }
            });

            el.appendChild(status);
            el.appendChild(actions);
            el.appendChild(closeBtn);

            function setActionBusy(isBusy) {
                busy = isBusy;

                [addBtn, wishlistBtn].forEach((button) => {
                    button.style.pointerEvents = isBusy ? 'none' : '';
                    button.style.opacity = isBusy ? '0.6' : '';
                    button.setAttribute(
                        'aria-disabled',
                        isBusy ? 'true' : 'false'
                    );
                });

                closeBtn.style.pointerEvents = isBusy ? 'none' : '';
                closeBtn.style.opacity = isBusy ? '0.6' : '';
            }

            async function onAddToCart() {
                if (busy || selected.size === 0) {
                    return;
                }

                const sessionid = getSessionId();

                if (!sessionid) {
                    setStatus('Could not find your session ID - try refreshing the page.');
                    return;
                }

                setActionBusy(true);

                const entries = [...selected.entries()];
                const failed = [];

                try {
                    for (let i = 0; i < entries.length; i += 1) {
                        const [appid, info] = entries[i];

                        setStatus(
                            `Adding ${i + 1} of ${entries.length} to cart: ${info.name}...`
                        );

                        try {
                            const subid = await resolveSubId(appid);

                            await addSubToCart(subid, sessionid);

                            removeHandledSelection(appid, info);
                            saveSelection();
                        } catch (err) {
                            console.error(
                                'Steam Page Tools: failed to add to cart',
                                appid,
                                err
                            );
                            failed.push(info.name);
                        }

                        updateBar();

                        if (i < entries.length - 1) {
                            await sleep(400);
                        }
                    }

                    if (failed.length) {
                        setStatus(
                            `Added ${entries.length - failed.length} of ${entries.length} to cart. ${failed.length} failed and stayed selected.`,
                            'cart'
                        );
                    } else {
                        setStatus(
                            `Added ${entries.length} game(s) to cart.`,
                            'cart'
                        );
                    }
                } finally {
                    setActionBusy(false);
                }
            }

            async function onAddToWishlist() {
                if (busy || selected.size === 0) {
                    return;
                }

                const sessionid = getSessionId();

                if (!sessionid) {
                    setStatus(
                        'Could not find your session ID - try refreshing the page.'
                    );
                    return;
                }

                setActionBusy(true);

                try {
                    setStatus('Checking your current Steam wishlist...');

                    let currentWishlist;

                    try {
                        currentWishlist = await fetchCurrentWishlist();
                    } catch (err) {
                        console.error(
                            'Steam Page Tools: failed to verify wishlist',
                            err
                        );
                        setStatus(
                            'Could not verify your current wishlist, so nothing was added. Try refreshing the page.'
                        );
                        return;
                    }

                    const entries = [...selected.entries()];
                    const pending = [];
                    const skipped = [];
                    const addedAppids = [];
                    const failed = [];
                    let rateLimited = false;

                    entries.forEach(([appid, info]) => {
                        if (currentWishlist.has(appid)) {
                            skipped.push(info.name);
                            removeHandledSelection(appid, info);
                        } else {
                            pending.push([appid, info]);
                        }
                    });

                    if (skipped.length) {
                        saveSelection();
                        updateBar();
                    }

                    for (let i = 0; i < pending.length; i += 1) {
                        const [appid, info] = pending[i];

                        setStatus(
                            `Adding ${i + 1} of ${pending.length} to wishlist: ${info.name}...`
                        );

                        try {
                            await addAppToWishlist(appid, sessionid);

                            currentWishlist.add(appid);
                            addedAppids.push(appid);
                            removeHandledSelection(appid, info);
                            saveSelection();
                        } catch (err) {
                            console.error(
                                'Steam Page Tools: failed to add to wishlist',
                                appid,
                                err
                            );
                            failed.push(info.name);

                            if (err.rateLimited) {
                                rateLimited = true;
                            }
                        }

                        updateBar();

                        if (rateLimited) {
                            break;
                        }

                        if (i < pending.length - 1) {
                            await sleep(800);
                        }
                    }

                    if (addedAppids.length) {
                        syncDynamicWishlist(addedAppids);
                    }

                    let message =
                        `Added ${addedAppids.length} game(s) to wishlist.`;

                    if (skipped.length) {
                        message +=
                            ` Skipped ${skipped.length} already wishlisted.`;
                    }

                    if (failed.length) {
                        message +=
                            ` ${failed.length} failed and stayed selected.`;
                    }

                    if (rateLimited) {
                        message +=
                            ' Steam asked to slow down; remaining games stayed selected.';
                    }

                    setStatus(message, 'wishlist');
                } finally {
                    setActionBusy(false);
                }
            }

            bindButtonActivation(addBtn, onAddToCart);
            bindButtonActivation(wishlistBtn, onAddToWishlist);

            return { el, status, cartLabel, wishlistLabel };
        }

        function injectStyles() {
            const style = document.createElement('style');

            style.textContent = `
                .spt-cart-checkbox {
                    position: absolute;
                    bottom: 4px;
                    right: 4px;
                    width: 18px;
                    height: 18px;
                    border-radius: 2px;
                    background: rgba(27, 40, 56, 0.9);
                    border: 1px solid #67c1f1;
                    cursor: pointer;
                    z-index: 5;
                }

                .spt-cart-checkbox.spt-checked {
                    background: #67c1f1;
                }

                .spt-cart-checkbox.spt-checked::after {
                    content: '';
                    position: absolute;
                    left: 5px;
                    top: 1px;
                    width: 5px;
                    height: 9px;
                    border: solid #1b2838;
                    border-width: 0 2px 2px 0;
                    transform: rotate(45deg);
                }

                #spt-cart-bar {
                    position: fixed;
                    right: 20px;
                    bottom: 20px;
                    z-index: 10000;
                    display: none;
                    align-items: center;
                    flex-wrap: wrap;
                    gap: 12px;
                    max-width: calc(100vw - 40px);
                    background: #1b2838;
                    border: 1px solid #2a475e;
                    border-radius: 3px;
                    padding: 10px 14px;
                    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
                    font-family: "Motiva Sans", Arial, sans-serif;
                }

                #spt-cart-bar.spt-visible {
                    display: flex;
                }

                #spt-cart-status {
                    color: #8f98a0;
                    font-size: 12px;
                    max-width: 280px;
                    flex: 0 1 auto;
                }

                #spt-cart-status:empty {
                    display: none;
                }

                #spt-store-actions {
                    display: flex;
                    align-items: center;
                    flex: 0 0 auto;
                    flex-wrap: nowrap;
                    gap: 4px;
                }

                #spt-store-actions .btn_addtocart {
                    float: none;
                    margin: 0;
                }

                #spt-cart-close {
                    cursor: pointer;
                    color: #8f98a0;
                    font-size: 13px;
                    padding: 2px 4px;
                }

                #spt-cart-close:hover {
                    color: #ffffff;
                }
            `;

            document.head.appendChild(style);
        }
    }
})();