// TubePress admin — the marketplace / top-up modal (~56 KB), previously inlined on
// every page that shows the marketplace bar.
const MP_BOOT = JSON.parse(document.getElementById('tp-mp-boot').textContent);

window.MP = (function() {
    const CSRF = MP_BOOT.csrf;
    const CREDIT_RATE = MP_BOOT.creditRate;
    let connected = MP_BOOT.connected;
    let balance = null;
    let cachedPacks = null;
    let stripeInstance = null;
    let embeddedCheckout = null;
    let currentPurchase = { slug: '', type: '', name: '', selectedTierId: null, selectedTierPrice: 0 };
    let topUpResolve = null; // For top-up-then-retry flow

    function init() {
        if (connected) {
            fetchBalance();
        }
    }

    // ── Connection state ──

    function setConnected(state) {
        connected = state;
        const dot = document.getElementById('mpDot');
        const label = document.getElementById('mpLabel');
        const connectBtn = document.getElementById('mpConnectBtn');
        const disconnectBtn = document.getElementById('mpDisconnectBtn');
        const topUpBtn = document.getElementById('mpTopUpBtn');
        const badge = document.getElementById('mpBalanceBadge');
        if (dot) dot.className = 'w-2 h-2 rounded-full ' + (state ? 'bg-green-500' : 'bg-gray-300');
        if (label) { label.textContent = state ? 'Connected' : 'Not connected'; label.className = 'text-xs ' + (state ? 'text-green-600' : 'text-gray-500'); }
        if (connectBtn) connectBtn.classList.toggle('hidden', state);
        if (disconnectBtn) disconnectBtn.classList.toggle('hidden', !state);
        if (topUpBtn) topUpBtn.classList.toggle('hidden', !state);
        if (!state && badge) badge.classList.add('hidden');
    }

    function autoConnect() {
        const w = 480, h = 600;
        const left = (screen.width - w) / 2;
        const top = (screen.height - h) / 2;
        const popup = window.open(
            'https://tubepress.io/connect?origin=' + encodeURIComponent(window.location.origin),
            'tubepress_connect',
            'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top + ',resizable=no,scrollbars=no'
        );
        if (!popup) {
            alert('Please allow popups for this site.');
        }
    }

    window.addEventListener('message', async function(e) {
        if (!e.data) return;

        // Handle connection errors (e.g. URL binding limit)
        if (e.data.type === 'tubepress-connect-error') {
            showConnectError(e.data.error || 'Connection failed.');
            return;
        }

        if (e.data.type !== 'tubepress-connect') return;
        // On the import page the CAT module handles this message
        // without reloading (to preserve CSV import state).
        // AT_CONNECTING: same pattern for the AI translate modal.
        if (window.IMP_TABS || window.AT_CONNECTING) return;
        const { api_key, api_secret, credits } = e.data;
        try {
            await fetch('/admin/catalogue/save-config', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({
                    catalogue_api_key: api_key,
                    catalogue_api_secret: api_secret,
                    catalogue_api_url: 'https://tubepress.io/v1',
                })
            });
        } catch (err) { /* silent */ }
        if (credits !== undefined) updateBalance(credits);
        setConnected(true);
        window.location.reload();
    });

    function showConnectError(msg) {
        // Remove any existing toast
        const existing = document.getElementById('mpConnectErrorToast');
        if (existing) existing.remove();

        const toast = document.createElement('div');
        toast.id = 'mpConnectErrorToast';
        toast.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:420px;padding:0.85rem 1.1rem;background:#fef2f2;border:1px solid #fecaca;border-radius:0.75rem;box-shadow:0 4px 20px rgba(0,0,0,0.12);display:flex;align-items:flex-start;gap:0.6rem;animation:slideIn .3s ease;';
        toast.innerHTML = '<div style="flex-shrink:0;width:22px;height:22px;background:#fee2e2;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-top:1px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg></div>'
            + '<div style="flex:1;min-width:0;"><div style="font-size:0.8rem;font-weight:700;color:#991b1b;margin-bottom:2px;">Connection Error</div><div style="font-size:0.78rem;color:#b91c1c;line-height:1.4;">' + msg.replace(/</g, '&lt;') + '</div></div>'
            + '<button onclick="this.parentElement.remove()" style="flex-shrink:0;background:none;border:none;color:#9ca3af;cursor:pointer;font-size:1.1rem;line-height:1;padding:0;">&times;</button>';
        document.body.appendChild(toast);
        setTimeout(() => { if (toast.parentElement) toast.style.opacity = '0'; toast.style.transition = 'opacity .3s'; setTimeout(() => toast.remove(), 300); }, 10000);
    }

    async function disconnect() {
        if (!confirm('Disconnect from TubePress? Your API credentials will be removed.')) return;
        try {
            await fetch('/admin/catalogue/save-config', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({ catalogue_api_key: '', catalogue_api_secret: '', catalogue_api_url: '' })
            });
        } catch (e) { /* silent */ }
        setConnected(false);
        window.location.reload();
    }

    // ── Balance ──

    async function fetchBalance() {
        try {
            const resp = await fetch('/admin/catalogue/balance');
            const data = await resp.json();
            if (data.credits !== undefined) updateBalance(data.credits);
        } catch (e) { /* silent */ }
    }

    function updateBalance(credits) {
        balance = parseFloat(credits);
        const usd = (balance * CREDIT_RATE).toFixed(2);
        const amountEl = document.getElementById('mpBalanceAmount');
        const badgeEl = document.getElementById('mpBalanceBadge');
        if (amountEl) amountEl.textContent = balance.toFixed(0) + ' Credits (~$' + usd + ')';
        if (badgeEl) badgeEl.classList.remove('hidden');
    }

    // ── Top Up Modal (multi-step) ──

    function showTopUpStep(step) {
        for (let i = 1; i <= 6; i++) {
            const el = document.getElementById('mpTopUpStep' + i);
            if (el) el.classList.toggle('hidden', i !== step);
        }
        const titles = {
            1: 'Top Up Credits', 2: 'Billing Information', 3: 'Choose Payment',
            4: 'Complete Your Payment', 5: 'Payment', 6: 'Top Up Credits'
        };
        document.getElementById('mpTopUpTitle').textContent = titles[step] || 'Top Up Credits';
        document.getElementById('mpTopUpFooter').classList.toggle('hidden', step === 5);
        const footerBtn = document.getElementById('mpTopUpFooterBtn');
        if (footerBtn) {
            footerBtn.textContent = step === 6 ? 'Done' : 'Cancel';
            footerBtn.className = step === 6
                ? 'text-xs font-semibold text-primary-700 hover:text-primary-800'
                : 'text-xs text-gray-500 hover:text-gray-700';
        }
        const note = document.getElementById('mpFooterNote');
        if (note) {
            note.textContent = (step === 3 || step === 4)
                ? 'Crypto payments are credited automatically'
                : 'Payments are processed securely';
        }
    }

    function esc(s) {
        return String(s === null || s === undefined ? '' : s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    async function openTopUpModal() {
        document.getElementById('mpTopUpOverlay').classList.remove('hidden');
        document.getElementById('mpTopUpModal').classList.remove('hidden');
        document.body.style.overflow = 'hidden';

        const balanceEl = document.getElementById('mpBalanceAmount');
        if (balanceEl && balanceEl.textContent && balanceEl.textContent !== '...') {
            document.getElementById('mpTopUpCurrentBalance').textContent = balanceEl.textContent;
        } else if (balance !== null) {
            const usd = (balance * CREDIT_RATE).toFixed(2);
            document.getElementById('mpTopUpCurrentBalance').textContent = balance.toFixed(0) + ' Credits (~$' + usd + ')';
        } else {
            document.getElementById('mpTopUpCurrentBalance').textContent = '0';
        }

        showTopUpStep(1);

        if (!cachedPacks) {
            try {
                const resp = await fetch('/admin/catalogue/packs');
                const data = await resp.json();
                cachedPacks = data.packs || [];
                cachedCustom = data.custom || null;
            } catch (e) {
                cachedPacks = [];
            }
        }
        renderTopUpPacks(cachedPacks);
    }

    function closeTopUpModal() {
        document.getElementById('mpTopUpOverlay').classList.add('hidden');
        document.getElementById('mpTopUpModal').classList.add('hidden');
        document.body.style.overflow = '';
        destroyCheckout();
        stopPoll();
        if (topUpResolve) {
            topUpResolve(false);
            topUpResolve = null;
        }
    }

    function renderTopUpPacks(packs) {
        const container = document.getElementById('mpTopUpPacks');
        if (!packs.length) {
            container.innerHTML = '<div class="col-span-2 text-center py-8 text-gray-400 text-sm">No credit packs available.</div>';
            return;
        }
        const bestIdx = packs.length - 1;
        container.innerHTML = packs.map((p, i) => {
            const isBest = i === bestIdx;
            const credits = Number(p.credits).toLocaleString();
            const price = p.price_display || ('$' + (p.price_cents / 100).toFixed(2));
            const perCredit = p.per_credit || ('$' + (p.price_cents / p.credits / 100).toFixed(4));
            // The volume discount, shown as the gift it is. Absent on older update
            // servers, which is why every use is guarded rather than assumed.
            const bonus = Number(p.bonus_credits || 0);
            const base = Number(p.base_credits || p.credits);
            const pct = Number(p.bonus_percent || 0);
            return `<button type="button" onclick="MP.buyPack(${p.id})"
                class="relative flex flex-col items-center gap-1.5 p-4 rounded-xl border-2 transition-all duration-200 hover:shadow-md hover:scale-[1.02] cursor-pointer group ${isBest ? 'border-primary-500 bg-primary-50/50 shadow-sm' : 'border-gray-200 hover:border-primary-300 bg-white'}">
                ${isBest ? '<span class="absolute -top-2.5 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-primary-600 text-white text-[9px] font-bold uppercase tracking-wider rounded-full">Best Value</span>' : ''}
                ${bonus > 0 ? `<span class="absolute top-2 right-2 px-1.5 py-0.5 bg-emerald-600 text-white text-[9px] font-extrabold rounded-full" dir="ltr">+${pct}%</span>` : ''}
                <span class="text-lg font-extrabold text-gray-900">${credits}</span>
                <span class="text-[10px] font-medium text-gray-400 uppercase tracking-wide">Credits</span>
                ${bonus > 0
                    ? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 text-[10px] font-semibold whitespace-nowrap">
                           <i class="fa-solid fa-gift text-[9px]"></i>${Number(base).toLocaleString()} + <strong class="font-extrabold">${Number(bonus).toLocaleString()} free</strong>
                       </span>`
                    : '<span class="h-[18px]"></span>'}
                <span class="text-xl font-extrabold ${isBest ? 'text-primary-600' : 'text-gray-800'}">${price}</span>
                <span class="text-[10px] text-gray-400">${perCredit}/credit</span>
                <span class="mt-1 w-full py-1.5 rounded-lg text-xs font-semibold text-center transition-colors ${isBest ? 'bg-primary-600 text-white group-hover:bg-primary-700' : 'bg-gray-100 text-gray-700 group-hover:bg-primary-600 group-hover:text-white'}">
                    Buy Now
                </span>
            </button>`;
        }).join('');

        renderCustomAmount(container);
    }

    /**
     * "Or choose your own amount" panel, appended under the pack grid.
     *
     * The preview mirrors the update server's own curve, whose control points arrive
     * with the pack list — nothing is re-typed here, so the two cannot drift apart.
     * It stays a preview: only an amount is ever sent, and the server prices it again
     * before a cent is charged.
     */
    function renderCustomAmount(container) {
        const cfg = cachedCustom;
        if (!cfg || !cfg.enabled || !cfg.curve || !cfg.curve.length) { return; }

        const minD = cfg.min_cents / 100, maxD = cfg.max_cents / 100;
        const wrap = document.createElement('div');
        wrap.className = 'col-span-2 mt-1 p-4 rounded-xl border border-gray-200 bg-gray-50/60';
        wrap.innerHTML = `
            <div class="flex items-start gap-2.5 mb-3">
                <span class="shrink-0 w-7 h-7 rounded-lg bg-primary-50 text-primary-600 flex items-center justify-center">
                    <i class="fa-solid fa-sliders text-xs"></i>
                </span>
                <div>
                    <div class="text-sm font-bold text-gray-900 leading-tight">Or choose your own amount</div>
                    <div class="text-[11px] text-gray-500 mt-0.5">Free credits are applied automatically, up to +${cfg.max_bonus_percent}%.</div>
                </div>
            </div>
            <div class="flex items-start gap-2 flex-wrap">
                <div class="relative">
                    <span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold text-sm pointer-events-none">$</span>
                    <input type="number" id="mpCustomAmount" min="${minD}" max="${maxD}" step="1" inputmode="decimal"
                           placeholder="${Math.round(minD)}" autocomplete="off"
                           class="w-32 pl-7 pr-3 py-2 border border-gray-300 rounded-lg text-sm font-bold tabular-nums focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
                </div>
                <div class="flex gap-1.5 flex-wrap" id="mpCustomChips"></div>
            </div>
            <div class="text-[11px] mt-1.5" id="mpCustomHint"></div>
            <div class="mt-3 hidden items-baseline gap-1.5" id="mpCustomOut">
                <span class="text-xl font-extrabold text-gray-900 tabular-nums" id="mpCustomTotal">—</span>
                <span class="text-[10px] font-medium text-gray-400 uppercase tracking-wide">Credits</span>
            </div>
            <div class="text-[11px] font-semibold text-emerald-700 mt-0.5 hidden items-center gap-1" id="mpCustomBreak"></div>
            <button type="button" id="mpCustomGo" disabled
                    class="mt-3 w-full py-2 rounded-lg text-xs font-bold bg-primary-600 text-white transition-colors hover:bg-primary-700 disabled:opacity-40 disabled:cursor-not-allowed">
                Continue
            </button>`;
        container.appendChild(wrap);

        const chips = wrap.querySelector('#mpCustomChips');
        chips.innerHTML = cfg.curve
            .filter(pt => pt.price_cents >= cfg.min_cents && pt.price_cents <= cfg.max_cents)
            .map(pt => `<button type="button" data-cents="${pt.price_cents}"
                    class="px-2.5 py-2 rounded-full border border-gray-300 bg-white text-xs font-semibold text-gray-700 hover:border-primary-400 hover:text-primary-700 transition-colors">
                    $${(pt.price_cents / 100).toLocaleString()}</button>`).join('');

        const amount = wrap.querySelector('#mpCustomAmount');
        const hint = wrap.querySelector('#mpCustomHint');
        const out = wrap.querySelector('#mpCustomOut');
        const total = wrap.querySelector('#mpCustomTotal');
        const brk = wrap.querySelector('#mpCustomBreak');
        const go = wrap.querySelector('#mpCustomGo');
        const rangeTxt = `Between $${minD.toLocaleString()} and $${maxD.toLocaleString()}`;

        // Mirror of Stripe::bonusRatio(): flat below the first control point, flat
        // above the last, linear in between, clamped by the ceiling.
        function ratio(c) {
            const pts = cfg.curve, ceiling = cfg.max_bonus_percent / 100;
            if (c <= pts[0].price_cents) { return Math.min(pts[0].bonus_ratio, ceiling); }
            const last = pts[pts.length - 1];
            if (c >= last.price_cents) { return Math.min(last.bonus_ratio, ceiling); }
            for (let i = 0; i < pts.length - 1; i++) {
                const lo = pts[i], hi = pts[i + 1];
                if (c < lo.price_cents || c > hi.price_cents) { continue; }
                const span = hi.price_cents - lo.price_cents;
                if (span <= 0) { return Math.min(hi.bonus_ratio, ceiling); }
                return Math.min(lo.bonus_ratio + (hi.bonus_ratio - lo.bonus_ratio) * ((c - lo.price_cents) / span), ceiling);
            }
            return Math.min(last.bonus_ratio, ceiling);
        }

        function refresh() {
            const d = parseFloat(amount.value);
            const c = Math.round((isNaN(d) ? 0 : d) * 100);
            customCents = 0;
            customCredits = 0;
            customBonus = 0;
            out.classList.add('hidden');
            brk.classList.add('hidden');
            go.disabled = true;

            if (!c) { hint.textContent = rangeTxt; hint.className = 'text-[11px] mt-1.5 text-gray-400'; return; }
            if (c < cfg.min_cents) { hint.textContent = `Minimum $${minD.toLocaleString()}`; hint.className = 'text-[11px] mt-1.5 text-amber-600 font-semibold'; return; }
            if (c > cfg.max_cents) { hint.textContent = `Maximum $${maxD.toLocaleString()}`; hint.className = 'text-[11px] mt-1.5 text-amber-600 font-semibold'; return; }

            hint.textContent = rangeTxt;
            hint.className = 'text-[11px] mt-1.5 text-gray-400';

            const base = Math.round(c * cfg.credits_per_cent);
            const bonus = Math.round(base * ratio(c));
            const pct = base > 0 ? Math.round(bonus / base * 100) : 0;

            total.textContent = (base + bonus).toLocaleString();
            out.classList.remove('hidden');
            out.classList.add('flex');
            if (bonus > 0) {
                brk.innerHTML = `<i class="fa-solid fa-gift text-[10px]"></i> ${base.toLocaleString()} paid + <strong class="font-extrabold">${bonus.toLocaleString()} free</strong>`
                    + `<span class="ml-1 px-1.5 py-0.5 bg-emerald-600 text-white rounded-full text-[9px] font-extrabold" dir="ltr">+${pct}%</span>`;
                brk.classList.remove('hidden');
                brk.classList.add('flex');
            }
            customCents = c;
            customCredits = base + bonus;
            customBonus = bonus;
            go.disabled = false;
        }

        amount.addEventListener('input', refresh);
        chips.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
            amount.value = String(Number(b.dataset.cents) / 100);
            refresh();
            amount.focus();
        }));
        go.addEventListener('click', () => { if (customCents > 0) { buyPack(0, customCents); } });
        refresh();
    }

    async function buyPack(packId, amountCents) {
        // Step 1 → check billing → step 2 or step 3
        try {
            const resp = await fetch('/admin/catalogue/billing-status');
            const data = await resp.json();

            if (data.has_billing) {
                await openPaymentStep(packId, amountCents);
            } else {
                // Show billing form, pre-fill if data available
                showTopUpStep(2);
                if (data.billing) {
                    const form = document.getElementById('mpBillingForm');
                    for (const [k, v] of Object.entries(data.billing)) {
                        if (!v) continue;
                        const input = form.querySelector('[name="' + k + '"]');
                        if (input) input.value = v;
                    }
                    // Sync billing type toggle
                    if (data.billing.billing_type) setBillingType(data.billing.billing_type);
                    // Sync country select & state visibility
                    toggleStateField();
                }
                // Attach submit handler
                document.getElementById('mpBillingForm').onsubmit = async function(e) {
                    e.preventDefault();
                    const formData = Object.fromEntries(new FormData(this));
                    const btn = this.querySelector('button[type="submit"]');
                    btn.disabled = true;
                    btn.textContent = 'Saving...';
                    try {
                        await fetch('/admin/catalogue/save-billing', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                            body: JSON.stringify(formData)
                        });
                        await openPaymentStep(packId, amountCents);
                    } catch (err) {
                        btn.disabled = false;
                        btn.textContent = 'Continue to Payment';
                        alert('Failed to save billing info. Please try again.');
                    }
                };
            }
        } catch (err) {
            await openPaymentStep(packId, amountCents);
        }
    }

    let cachedCustom = null;   // bloc `custom` de /account/packs (bornes + points de la courbe)
    let customCents = 0;      // montant libre saisi, en cents
    let customCredits = 0;    // total crédité pour ce montant (base + bonus), calculé par refresh()
    let customBonus = 0;      // part offerte dans ce total, pour le récapitulatif de paiement
    let cachedMethods = null;
    let payCoins = [];
    let payPack = null;
    let payCoinKey = '';
    let payEstimate = null;
    let payPayment = null;
    let payPollTimer = null;
    let payEstimateSeq = 0;
    let payBusy = false;

    const PAY_STATE = {
        waiting:    { tone: 'blue',  text: 'Waiting for your transfer…', pulse: true,  cancel: true },
        confirming: { tone: 'blue',  text: 'Transfer detected — waiting for network confirmations…', pulse: true, cancel: false },
        processing: { tone: 'blue',  text: 'Confirmed — crediting your account…', pulse: true, cancel: false },
        sending:    { tone: 'blue',  text: 'Confirmed — crediting your account…', pulse: true, cancel: false },
        finished:   { tone: 'green', text: 'Payment received.', pulse: false, cancel: false },
        failed:     { tone: 'red',   text: 'The payment failed. Nothing was charged — you can start a new one.', pulse: false, cancel: false },
        refunded:   { tone: 'red',   text: 'The payment was refunded by the network.', pulse: false, cancel: false },
        expired:    { tone: 'amber', text: 'This payment window expired. Start a new one to get a fresh address.', pulse: false, cancel: false },
        cancelled:  { tone: 'amber', text: 'Payment cancelled. You can start a new one.', pulse: false, cancel: false }
    };
    const PAY_TONES = {
        blue:  'bg-blue-50 border-blue-200 text-blue-800',
        green: 'bg-green-50 border-green-200 text-green-800',
        red:   'bg-red-50 border-red-200 text-red-800',
        amber: 'bg-amber-50 border-amber-200 text-amber-800'
    };
    const PAY_DOTS = { blue: 'bg-blue-500', green: 'bg-green-500', red: 'bg-red-500', amber: 'bg-amber-500' };

    function payEl(id) { return document.getElementById(id); }

    function showPayError(msg, second) {
        const box = payEl(second ? 'mpPayError2' : 'mpPayError');
        const txt = payEl(second ? 'mpPayError2Msg' : 'mpPayErrorMsg');
        if (!box || !txt) return;
        txt.textContent = msg;
        box.classList.remove('hidden');
    }

    function hidePayError() {
        ['mpPayError', 'mpPayError2'].forEach(function(id) {
            const box = payEl(id);
            if (box) box.classList.add('hidden');
        });
    }

    // pack_id 0 is the buyer-named amount: it is in no list, so it is built here
    // from what the preview already computed. The server prices it again anyway.
    function packOf(packId, amountCents) {
        if (Number(packId) === 0) {
            // customCredits is what refresh() computed, not what it printed. Reading the
            // total back out of #mpCustomTotal used to throw here — the Number( ... )
            // parenthesis closed before .replace, so .replace ran on a number, and that
            // number was NaN anyway since the text is toLocaleString()'d ("2,000").
            // The throw was swallowed by buyPack()'s catch-and-retry, so the button
            // simply did nothing. Fixed packs took the branch below and always worked.
            return { id: 0, custom: true, amount_cents: Number(amountCents || 0), price_cents: Number(amountCents || 0),
                     bonus_credits: customBonus, credits: customCredits };
        }
        return (cachedPacks || []).find(function(p) { return Number(p.id) === Number(packId); }) || { id: packId };
    }

    function money(cents) {
        return '$' + (Number(cents || 0) / 100).toFixed(2);
    }

    function packSummary(pack) {
        if (!pack || !pack.credits) return '';
        const line = Number(pack.credits).toLocaleString() + ' credits · ' + (pack.price_display || money(pack.price_cents));
        const bonus = Number(pack.bonus_credits || 0);
        return bonus > 0 ? line + ' · incl. ' + bonus.toLocaleString() + ' free' : line;
    }

    function coinCardHtml(c) {
        const active = c.key === payCoinKey;
        const badge = c.badge ? '<span class="px-1 py-0.5 bg-gray-100 text-gray-600 rounded text-[9px] font-semibold">' + esc(c.badge) + '</span>' : '';
        return '<button type="button" data-coin="' + esc(c.key) + '" onclick="MP.selectCoin(\'' + esc(c.key) + '\')" '
            + 'class="flex items-center gap-2 p-2 rounded-lg border text-left transition-all ' + (active ? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500' : 'border-gray-200 hover:border-primary-300 bg-white') + '">'
            + '<img src="' + esc(c.icon) + '" alt="" class="w-6 h-6 rounded-full shrink-0" onerror="this.style.visibility=\'hidden\'">'
            + '<span class="min-w-0 flex-1">'
            + '<span class="block text-xs font-bold text-gray-900 leading-tight truncate">' + esc(c.ticker) + ' ' + badge + '</span>'
            + '<span class="block text-[10px] text-gray-500 truncate">' + esc(c.network) + '</span>'
            + '</span></button>';
    }

    function renderCoins(list) {
        const grid = payEl('mpCoinGrid');
        const empty = payEl('mpCoinEmpty');
        if (!grid) return;
        grid.innerHTML = list.map(coinCardHtml).join('');
        if (empty) empty.classList.toggle('hidden', list.length > 0);
    }

    function filterCoins(q) {
        const needle = String(q || '').trim().toLowerCase();
        if (!needle) { renderCoins(payCoins); return; }
        renderCoins(payCoins.filter(function(c) {
            return (c.ticker + ' ' + c.name + ' ' + c.badge + ' ' + c.network).toLowerCase().indexOf(needle) !== -1;
        }));
    }

    async function openPaymentStep(packId, amountCents) {
        payPack = packOf(packId, amountCents);
        payCoinKey = '';
        payEstimate = null;
        payPayment = null;
        stopPoll();
        hidePayError();
        showTopUpStep(3);

        const search = payEl('mpCoinSearch');
        if (search) search.value = '';
        payEl('mpCoinConfirm').classList.add('hidden');
        payEl('mpPaySummary').textContent = packSummary(payPack);
        payEl('mpCoinGrid').innerHTML = '<div class="col-span-3 py-8 text-center text-xs text-gray-400">Loading payment methods…</div>';

        if (!cachedMethods) {
            try {
                const resp = await fetch('/admin/catalogue/payment-methods');
                const data = await resp.json();
                cachedMethods = data && !data.error ? data : null;
                if (data && data.error) { showPayError(data.error); }
            } catch (e) {
                cachedMethods = null;
            }
        }

        if (!cachedMethods) {
            payEl('mpCoinGrid').innerHTML = '';
            showPayError('Could not reach the billing service. Check your connection and try again.');
            return;
        }

        const crypto = cachedMethods.crypto || {};
        const stripeOn = !!(cachedMethods.stripe && cachedMethods.stripe.enabled);
        payEl('mpStripeSwitch').classList.toggle('hidden', !stripeOn);

        const coins = crypto.enabled ? (crypto.coins || []) : [];
        if (!coins.length) {
            payEl('mpCoinGrid').innerHTML = '';
            if (stripeOn) { await startStripe(); return; }
            showPayError('No payment method is available right now. Please contact support.');
            return;
        }

        payCoins = coins;
        renderCoins(payCoins);
    }

    async function selectCoin(key) {
        payCoinKey = key;
        payEstimate = null;
        hidePayError();
        renderCoins(payCoins.filter(function(c) {
            const q = String((payEl('mpCoinSearch') || {}).value || '').trim().toLowerCase();
            return !q || (c.ticker + ' ' + c.name + ' ' + c.badge + ' ' + c.network).toLowerCase().indexOf(q) !== -1;
        }));

        const box = payEl('mpCoinConfirm');
        const amountEl = payEl('mpCoinConfirmAmount');
        const totalsEl = payEl('mpCoinConfirmTotals');
        const btn = payEl('mpCoinConfirmBtn');
        box.classList.remove('hidden');
        amountEl.textContent = 'Calculating…';
        totalsEl.textContent = '';
        btn.disabled = true;

        const seq = ++payEstimateSeq;
        try {
            const resp = await fetch('/admin/catalogue/crypto-estimate?pack_id=' + encodeURIComponent(payPack.id) + '&amount_cents=' + encodeURIComponent(payPack.amount_cents || 0) + '&coin=' + encodeURIComponent(key));
            const data = await resp.json();
            if (seq !== payEstimateSeq) return;
            if (data.error) {
                amountEl.textContent = '—';
                showPayError(data.error);
                return;
            }
            payEstimate = data;
            amountEl.textContent = data.amount + ' ' + data.ticker;
            totalsEl.textContent = data.tax_cents > 0
                ? money(data.total_cents) + ' incl. ' + (data.tax_label || 'tax') + ' ' + money(data.tax_cents)
                : money(data.total_cents);
            btn.disabled = false;
        } catch (e) {
            if (seq !== payEstimateSeq) return;
            amountEl.textContent = '—';
            showPayError('Could not price that coin. Please try another one.');
        }
    }

    async function confirmCoin() {
        if (!payCoinKey || payBusy) return;
        const btn = payEl('mpCoinConfirmBtn');
        payBusy = true;
        btn.disabled = true;
        btn.textContent = 'Opening…';
        hidePayError();
        try {
            const resp = await fetch('/admin/catalogue/crypto-create', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({ pack_id: payPack.id, amount_cents: payPack.amount_cents || 0, coin: payCoinKey })
            });
            const data = await resp.json();
            if (data.error || !data.payment) {
                showPayError(data.error || 'Could not open the payment. Please try again.');
                return;
            }
            payPayment = data.payment;
            showTopUpStep(4);
            renderPayment(payPayment);
            startPoll();
        } catch (e) {
            showPayError('Could not reach the billing service. Please try again.');
        } finally {
            payBusy = false;
            btn.disabled = false;
            btn.textContent = 'Continue';
        }
    }

    function renderPayment(p) {
        const coin = p.coin || {};
        payEl('mpPayCoinIcon').src = coin.icon || '';
        payEl('mpPayCoinName').textContent = coin.name || coin.ticker || '';
        const badgeEl = payEl('mpPayCoinBadge');
        badgeEl.textContent = coin.badge || '';
        badgeEl.classList.toggle('hidden', !coin.badge);
        payEl('mpPayNetwork').textContent = coin.network ? coin.network + ' network' : '';
        payEl('mpPayCredits').textContent = '+' + Number(p.credits || 0).toLocaleString() + ' credits';

        payEl('mpPayAmount').textContent = p.amount + ' ' + (coin.ticker || '');
        payEl('mpPayAddress').textContent = p.address || '';

        const memoWrap = payEl('mpPayMemoWrap');
        if (p.extra_id) {
            payEl('mpPayMemo').textContent = p.extra_id;
            memoWrap.classList.remove('hidden');
        } else {
            memoWrap.classList.add('hidden');
        }

        const qr = payEl('mpPayQr');
        const qrFallback = payEl('mpPayQrFallback');
        if (p.qr) {
            qr.src = p.qr;
            qr.classList.remove('hidden');
            qrFallback.classList.add('hidden');
        } else {
            qr.classList.add('hidden');
            qrFallback.classList.remove('hidden');
        }

        const tax = Number(p.usd_tax || 0);
        payEl('mpPayTotals').textContent = tax > 0
            ? money(Math.round(p.usd * 100)) + ' total · incl. ' + (p.tax_label || 'tax') + ' ' + money(Math.round(tax * 100))
            : money(Math.round(p.usd * 100)) + ' total';
        if (p.created_ts) {
            payEl('mpPayOpened').textContent = 'Opened ' + new Date(p.created_ts * 1000).toLocaleTimeString();
        }

        applyPayStatus(p);
    }

    function applyPayStatus(p) {
        const state = PAY_STATE[p.status] || PAY_STATE.waiting;
        const strip = payEl('mpPayStatus');
        const dot = payEl('mpPayStatusDot');
        strip.className = 'mt-3 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border text-xs font-medium ' + PAY_TONES[state.tone];
        dot.innerHTML = state.pulse
            ? '<span class="animate-ping absolute inline-flex h-full w-full rounded-full ' + PAY_DOTS[state.tone] + ' opacity-75"></span><span class="relative inline-flex rounded-full h-2.5 w-2.5 ' + PAY_DOTS[state.tone] + '"></span>'
            : '<span class="relative inline-flex rounded-full h-2.5 w-2.5 ' + PAY_DOTS[state.tone] + '"></span>';
        payEl('mpPayStatusText').textContent = state.text;
        payEl('mpPayCancel').classList.toggle('hidden', !state.cancel);
    }

    function stopPoll() {
        if (payPollTimer) { clearTimeout(payPollTimer); payPollTimer = null; }
    }

    function startPoll() {
        stopPoll();
        payPollTimer = setTimeout(pollPayment, 5000);
    }

    async function pollPayment() {
        if (!payPayment) return;
        if (document.hidden) { payPollTimer = setTimeout(pollPayment, 8000); return; }
        try {
            const resp = await fetch('/admin/catalogue/crypto-status?id=' + encodeURIComponent(payPayment.id));
            const data = await resp.json();
            if (data.payment) {
                const p = data.payment;
                payPayment = Object.assign({}, payPayment, p);
                applyPayStatus(p);

                if (typeof p.balance === 'number') {
                    balance = p.balance;
                    updateBalanceUi(p.balance);
                }

                if (p.status === 'finished') {
                    stopPoll();
                    const granted = p.granted !== null && p.granted !== undefined ? p.granted : p.credits;
                    payEl('mpGrantedLine').textContent = '+' + Number(granted).toLocaleString() + ' credits added to your account.';
                    payEl('mpNewBalance').textContent = typeof p.balance === 'number'
                        ? Number(p.balance).toLocaleString() + ' Credits'
                        : Number(granted).toLocaleString() + ' Credits';
                    showTopUpStep(6);
                    if (topUpResolve) { topUpResolve(true); topUpResolve = null; }
                    return;
                }
                if (['failed', 'refunded', 'expired', 'cancelled'].indexOf(p.status) !== -1) {
                    stopPoll();
                    return;
                }
            }
        } catch (e) {
        }
        payPollTimer = setTimeout(pollPayment, 5000);
    }

    async function cancelCryptoPayment() {
        if (!payPayment) return;
        stopPoll();
        try {
            const resp = await fetch('/admin/catalogue/crypto-cancel', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({ id: payPayment.id })
            });
            const data = await resp.json();
            if (data.payment) { payPayment = data.payment; applyPayStatus(data.payment); }
        } catch (e) {
            showPayError('Could not cancel the payment.', true);
        }
        setTimeout(function() { openPaymentStep(payPack.id); }, 900);
    }

    function copyField(id, btn) {
        const el = payEl(id);
        if (!el) return;
        const text = el.textContent.trim();
        const done = function() {
            if (!btn) return;
            const old = btn.innerHTML;
            btn.innerHTML = '<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>';
            setTimeout(function() { btn.innerHTML = old; }, 1400);
        };
        if (navigator.clipboard && window.isSecureContext) {
            navigator.clipboard.writeText(text).then(done);
            return;
        }
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.position = 'fixed';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        try { document.execCommand('copy'); done(); } catch (e) {}
        document.body.removeChild(ta);
    }

    function updateBalanceUi(value) {
        updateBalance(value);
        const usd = (Number(value) * CREDIT_RATE).toFixed(2);
        const cur = document.getElementById('mpTopUpCurrentBalance');
        if (cur) cur.textContent = Number(value).toFixed(0) + ' Credits (~$' + usd + ')';
    }

    async function startStripe() {
        showTopUpStep(5);
        try {
            const resp = await fetch('/admin/catalogue/create-checkout', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({ pack_id: payPack.id, amount_cents: payPack.amount_cents || 0 })
            });
            const data = await resp.json();
            if (data.error) {
                showTopUpStep(3);
                showPayError(data.error);
                return;
            }
            await mountCheckout(data.client_secret, data.publishable_key, data.session_id);
        } catch (err) {
            showTopUpStep(3);
            showPayError('Could not start the card checkout. Please try again.');
        }
    }

    // ── Stripe Embedded Checkout ──

    async function loadStripe() {
        if (window.Stripe) return window.Stripe;
        return new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = 'https://js.stripe.com/v3/';
            script.onload = () => resolve(window.Stripe);
            script.onerror = () => reject(new Error('Failed to load Stripe.js'));
            document.head.appendChild(script);
        });
    }

    async function mountCheckout(clientSecret, publishableKey, sessionId) {
        try {
            const StripeFactory = await loadStripe();
            if (!stripeInstance || stripeInstance._publishableKey !== publishableKey) {
                stripeInstance = StripeFactory(publishableKey);
                stripeInstance._publishableKey = publishableKey;
            }

            embeddedCheckout = await stripeInstance.initEmbeddedCheckout({
                clientSecret: clientSecret,
                onComplete: () => onCheckoutComplete(sessionId),
            });

            embeddedCheckout.mount('#mpStripeCheckout');
        } catch (err) {
            console.error('Stripe mount error:', err);
            alert('Failed to load payment form. Please try again.');
            showTopUpStep(1);
        }
    }

    function destroyCheckout() {
        if (embeddedCheckout) {
            try { embeddedCheckout.destroy(); } catch (e) { /* silent */ }
            embeddedCheckout = null;
        }
        document.getElementById('mpStripeCheckout').innerHTML = '';
    }

    async function onCheckoutComplete(sessionId) {
        destroyCheckout();

        // Poll session status until balance is updated
        let attempts = 0;
        const poll = async () => {
            try {
                const resp = await fetch('/admin/catalogue/session-status?session_id=' + encodeURIComponent(sessionId));
                const data = await resp.json();
                if (data.status === 'complete' && data.balance !== undefined) {
                    updateBalance(data.balance);
                    const bal = parseFloat(data.balance);
                    const balUsd = (bal * CREDIT_RATE).toFixed(2);
                    const balText = bal.toFixed(0) + ' Credits (~$' + balUsd + ')';
                    document.getElementById('mpNewBalance').textContent = balText;
                    document.getElementById('mpTopUpCurrentBalance').textContent = balText;
                    showTopUpStep(4);
                    if (topUpResolve) {
                        topUpResolve(true);
                        topUpResolve = null;
                    }
                    return;
                }
            } catch (e) { /* silent */ }
            attempts++;
            if (attempts < 15) setTimeout(poll, 2000);
            else {
                // Fallback: refresh balance
                await fetchBalance();
                showTopUpStep(4);
                document.getElementById('mpNewBalance').textContent = balance !== null ? balance.toFixed(2) : '?';
                if (topUpResolve) {
                    topUpResolve(true);
                    topUpResolve = null;
                }
            }
        };
        setTimeout(poll, 1500);
    }

    // ── Purchase Modal (tier selection) ──

    async function openPurchaseModal(slug, type, name) {
        if (!connected) {
            autoConnect();
            return;
        }

        currentPurchase = { slug, type, name, selectedTierId: null, selectedTierPrice: 0 };

        // Reset UI
        document.getElementById('mpPurchaseName').textContent = name;
        document.getElementById('mpPurchaseLoading').classList.remove('hidden');
        document.getElementById('mpPurchaseContent').classList.add('hidden');
        document.getElementById('mpPurchaseInstalling').classList.add('hidden');
        document.getElementById('mpPurchaseSuccess').classList.add('hidden');
        document.getElementById('mpPurchaseError').classList.add('hidden');

        document.getElementById('mpPurchaseOverlay').classList.remove('hidden');
        document.getElementById('mpPurchaseModal').classList.remove('hidden');
        document.body.style.overflow = 'hidden';

        try {
            const resp = await fetch('/admin/catalogue/marketplace-item-info?type=' + encodeURIComponent(type) + '&slug=' + encodeURIComponent(slug));
            const data = await resp.json();

            if (data.error) {
                showPurchaseError(data.error);
                return;
            }

            const item = data.item;
            balance = parseFloat(data.balance) || 0;
            updateBalance(balance);
            document.getElementById('mpPurchaseBalance').textContent = balance.toFixed(0) + ' Credits (~$' + (balance * CREDIT_RATE).toFixed(2) + ')';

            // Already owned? Just install
            if (item.owned) {
                await doInstall(slug, type);
                return;
            }

            // Render tier cards
            const tiers = item.pricing_tiers || [];
            const tiersContainer = document.getElementById('mpTierCards');

            if (tiers.length > 0) {
                const basePrice = tiers.length > 0 ? parseFloat(tiers[0].price_credits) : 0;
                tiersContainer.innerHTML = tiers.map((tier, i) => {
                    const price = parseFloat(tier.price_credits);
                    const sites = tier.max_sites == 0 ? 'Unlimited' : tier.max_sites;
                    const label = tier.label || (sites + ' Site' + (tier.max_sites != 1 ? 's' : ''));
                    const perSite = tier.max_sites > 0 ? (price / tier.max_sites).toFixed(0) : '';
                    const savings = i > 0 && basePrice > 0 && tier.max_sites > 0
                        ? Math.round((1 - (price / tier.max_sites) / (basePrice / (tiers[0].max_sites || 1))) * 100)
                        : 0;
                    const usd = (price * CREDIT_RATE).toFixed(2);
                    return `<label class="flex items-center gap-3 p-3 rounded-xl border-2 cursor-pointer transition-all hover:border-primary-300 ${i === 0 ? 'border-primary-500 bg-primary-50/30' : 'border-gray-200'}" data-tier-card>
                        <input type="radio" name="mp_tier" value="${tier.id}" data-price="${price}" ${i === 0 ? 'checked' : ''}
                            class="w-4 h-4 text-primary-600 border-gray-300 focus:ring-primary-500"
                            onchange="MP.selectTier(${tier.id}, ${price})">
                        <div class="flex-1">
                            <div class="flex items-center gap-2">
                                <span class="text-sm font-semibold text-gray-900">${label}</span>
                                ${savings > 0 ? '<span class="px-1.5 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded">Save ' + savings + '%</span>' : ''}
                            </div>
                            ${perSite ? '<p class="text-[11px] text-gray-400 mt-0.5">' + Number(perSite).toLocaleString() + ' Credits/site</p>' : ''}
                        </div>
                        <div class="text-right">
                            <span class="text-sm font-bold text-gray-900">${Number(price).toLocaleString()} Credits</span>
                            <p class="text-[11px] text-gray-400">~$${usd}</p>
                        </div>
                    </label>`;
                }).join('');

                // Default to first tier
                selectTier(tiers[0].id, parseFloat(tiers[0].price_credits));
            } else {
                // No tiers — single price
                const price = parseFloat(item.price_credits);
                const singleUsd = (price * CREDIT_RATE).toFixed(2);
                tiersContainer.innerHTML = `<div class="p-3 rounded-xl border-2 border-primary-500 bg-primary-50/30">
                    <div class="flex items-center justify-between">
                        <span class="text-sm font-semibold text-gray-900">${name}</span>
                        <div class="text-right">
                            <span class="text-sm font-bold text-gray-900">${Number(price).toLocaleString()} Credits</span>
                            <p class="text-[11px] text-gray-400">~$${singleUsd}</p>
                        </div>
                    </div>
                </div>`;
                currentPurchase.selectedTierId = null;
                currentPurchase.selectedTierPrice = price;
                updatePurchaseButton();
            }

            document.getElementById('mpPurchaseLoading').classList.add('hidden');
            document.getElementById('mpPurchaseContent').classList.remove('hidden');

        } catch (err) {
            showPurchaseError('Failed to load item details.');
        }
    }

    function selectTier(tierId, price) {
        currentPurchase.selectedTierId = tierId;
        currentPurchase.selectedTierPrice = price;

        // Update card visual
        document.querySelectorAll('[data-tier-card]').forEach(card => {
            const radio = card.querySelector('input[type="radio"]');
            const isSelected = radio && parseInt(radio.value) === tierId;
            card.className = card.className.replace(/border-primary-500 bg-primary-50\/30|border-gray-200/g, '');
            card.classList.add(isSelected ? 'border-primary-500' : 'border-gray-200');
            if (isSelected) card.classList.add('bg-primary-50/30');
        });

        updatePurchaseButton();
    }

    function updatePurchaseButton() {
        const btn = document.getElementById('mpPurchaseBtn');
        const btnText = document.getElementById('mpPurchaseBtnText');
        const insufficient = document.getElementById('mpInsufficientCredits');
        const price = currentPurchase.selectedTierPrice;

        const usdPrice = (price * CREDIT_RATE).toFixed(2);
        btnText.textContent = 'Purchase & Install (' + Number(price).toLocaleString() + ' Credits \u2248 $' + usdPrice + ')';

        if (balance !== null && balance < price) {
            insufficient.classList.remove('hidden');
            btn.disabled = true;
        } else {
            insufficient.classList.add('hidden');
            btn.disabled = false;
        }
    }

    function closePurchaseModal() {
        document.getElementById('mpPurchaseOverlay').classList.add('hidden');
        document.getElementById('mpPurchaseModal').classList.add('hidden');
        document.body.style.overflow = '';
    }

    function showPurchaseError(msg) {
        document.getElementById('mpPurchaseLoading').classList.add('hidden');
        document.getElementById('mpPurchaseContent').classList.add('hidden');
        document.getElementById('mpPurchaseInstalling').classList.add('hidden');
        document.getElementById('mpPurchaseError').classList.remove('hidden');
        document.getElementById('mpPurchaseErrorMsg').textContent = msg;
    }

    async function confirmPurchase() {
        const { slug, type, selectedTierId, selectedTierPrice } = currentPurchase;

        // Show installing state
        document.getElementById('mpPurchaseContent').classList.add('hidden');
        document.getElementById('mpPurchaseInstalling').classList.remove('hidden');

        try {
            // 1. Purchase via marketplace
            const purchaseResp = await fetch('/admin/catalogue/marketplace-purchase', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF },
                body: JSON.stringify({ item_type: type, item_slug: slug, tier_id: selectedTierId })
            });
            const purchaseData = await purchaseResp.json();

            if (purchaseData.error) {
                if (purchaseResp.status === 402) {
                    // Insufficient credits — offer top up
                    document.getElementById('mpPurchaseInstalling').classList.add('hidden');
                    document.getElementById('mpPurchaseContent').classList.remove('hidden');
                    updatePurchaseButton();
                } else {
                    showPurchaseError(purchaseData.error);
                }
                return;
            }

            // Update balance
            if (purchaseData.new_balance !== undefined) {
                updateBalance(purchaseData.new_balance);
                const nb = parseFloat(purchaseData.new_balance);
                document.getElementById('mpPurchaseBalance').textContent = nb.toFixed(0) + ' Credits (~$' + (nb * CREDIT_RATE).toFixed(2) + ')';
            }

            // 2. Install
            await doInstall(slug, type);

        } catch (err) {
            showPurchaseError('Purchase failed. Please try again.');
        }
    }

    async function doInstall(slug, type) {
        document.getElementById('mpPurchaseLoading').classList.add('hidden');
        document.getElementById('mpPurchaseContent').classList.add('hidden');
        document.getElementById('mpPurchaseInstalling').classList.remove('hidden');

        try {
            const installUrl = type === 'plugin'
                ? '/admin/plugins/' + encodeURIComponent(slug) + '/install'
                : '/admin/themes/' + encodeURIComponent(slug) + '/install';

            const formData = new FormData();
            formData.append('csrf_token', CSRF);

            const resp = await fetch(installUrl, {
                method: 'POST',
                headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
                body: formData,
            });
            const data = await resp.json();

            if (data.success) {
                document.getElementById('mpPurchaseInstalling').classList.add('hidden');
                document.getElementById('mpPurchaseSuccess').classList.remove('hidden');
                document.getElementById('mpPurchaseSuccessMsg').textContent = data.message || 'Successfully installed!';

                // Change button to "Activate Plugin/Theme" with form POST
                const btn = document.getElementById('mpPurchaseSuccessBtn');
                const label = type === 'plugin' ? 'Activate Plugin' : 'Activate Theme';
                btn.textContent = label;
                btn.onclick = function() {
                    const form = document.createElement('form');
                    form.method = 'POST';
                    form.action = '/admin/' + (type === 'plugin' ? 'plugins' : 'themes') + '/' + encodeURIComponent(slug) + '/activate';
                    const csrf = document.createElement('input');
                    csrf.type = 'hidden';
                    csrf.name = 'csrf_token';
                    csrf.value = CSRF;
                    form.appendChild(csrf);
                    document.body.appendChild(form);
                    form.submit();
                };
            } else {
                showPurchaseError(data.message || 'Installation failed.');
            }
        } catch (err) {
            showPurchaseError('Installation failed. Please try again.');
        }
    }

    // ── Billing form helpers ──

    function setBillingType(type) {
        const form = document.getElementById('mpBillingForm');
        form.querySelector('[name="billing_type"]').value = type;
        form.querySelectorAll('.mp-billing-toggle').forEach(btn => {
            const isActive = btn.dataset.btype === type;
            btn.className = 'mp-billing-toggle flex-1 py-2 text-xs font-semibold text-center transition-colors ' +
                (isActive ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600');
        });
        document.getElementById('mpBusinessFields').classList.toggle('hidden', type !== 'business');
    }

    function toggleStateField() {
        const country = document.getElementById('mpBillingCountry').value;
        const needsState = ['US', 'CA', 'AU', 'IN', 'BR', 'MX'];
        document.getElementById('mpStateGroup').classList.toggle('hidden', !needsState.includes(country));
    }

    // Top up, then retry purchase
    function topUpThenRetry() {
        closePurchaseModal();
        openTopUpModal();
        // When top-up completes, re-open purchase modal
        topUpResolve = function(success) {
            if (success) {
                setTimeout(() => {
                    closeTopUpModal();
                    openPurchaseModal(currentPurchase.slug, currentPurchase.type, currentPurchase.name);
                }, 1500);
            }
        };
    }

    document.addEventListener('DOMContentLoaded', init);

    return {
        autoConnect, disconnect, updateBalance,
        openTopUpModal, closeTopUpModal, buyPack,
        selectCoin, filterCoins, confirmCoin, cancelCryptoPayment, copyField, startStripe,
        openPurchaseModal, closePurchaseModal, selectTier, confirmPurchase,
        topUpThenRetry, setBillingType, toggleStateField,
        showConnectError,
    };
})();
