Plugin Directory

Changeset 3630953


Ignore:
Timestamp:
08/01/2026 12:30:08 PM (3 months ago)
Author:
gamerhubstore01
Message:

Release 1.2.0

Location:
gamerhub-for-woocommerce
Files:
2 added
24 edited
1 copied

Legend:

Unmodified
Added
Removed
  • gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-admin.css

    r3626288 r3630953  
    165165.gamerhub-adv-map > summary { cursor: pointer; color: #2271b1; font-size: 13px; }
    166166.gamerhub-adv-map .gamerhub-fieldmap-input { margin-top: 2px; }
     167
     168/* Catalog table: "You pay" (account currency) + inline "Your profit" editor. */
     169.gamerhub-mk-row-cell { white-space: nowrap; }
     170.gamerhub-mk-inline input[type="number"] { width: 68px; }
     171.gamerhub-mk-inline select { max-width: 78px; }
     172.gamerhub-mk-inline .button { margin-left: 2px; }
     173.gamerhub-mk-inline .gamerhub-row-mk-result { margin-left: 6px; font-size: 12px; }
     174.gamerhub-mk-inline .description { display: inline-block; margin-top: 2px; color: #787c82; }
     175
     176/* ── Catalog table width ──────────────────────────────────────────────────────
     177   Region / Type / Status are now badges inside the Game cell (they each have a
     178   dropdown filter of their own), and the two widest controls are tightened, so
     179   the money columns fit without a horizontal scrollbar. The wrapper still
     180   scrolls as a last resort on very narrow screens — the PAGE never does. */
     181#gamerhub-catalog { overflow-x: auto; }
     182#gamerhub-catalog table { min-width: 720px; }
     183.gamerhub-game-cell code { font-size: 11px; color: #787c82; }
     184.gamerhub-game-meta { display: block; margin-top: 2px; font-size: 11px; color: #787c82; }
     185.gamerhub-game-meta .gamerhub-badge { font-size: 10px; padding: 0 4px; }
     186.gamerhub-mk-inline input[type="number"] { width: 54px; }
     187.gamerhub-mk-inline select { max-width: 62px; padding-right: 18px; }
     188.gamerhub-mk-inline .button { padding: 0 6px; }
     189.gamerhub-autosync-lbl { font-size: 12px; }
     190.gamerhub-synced-at { font-size: 11px; color: #787c82; }
  • gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-admin.js

    r3626288 r3630953  
    1313            .replace(/'/g, ''');
    1414    }
    15     function money(v) { return v == null ? '—' : (GAMERHUB_ADMIN.currency + ' ' + Number(v).toFixed(2)); }
     15    // Matches PHP's number_format($n, 2) exactly. The same cell is rendered server-side on page
     16    // load and client-side after ↻ / an inline save — without this, "1,872.69" silently became
     17    // "1872.69" the moment you touched it.
     18    function num2(v) {
     19        var parts = Number(v).toFixed(2).split('.');
     20        return parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + parts[1];
     21    }
     22    function money(v) { return v == null ? '—' : (GAMERHUB_ADMIN.currency + ' ' + num2(v)); }
    1623    function post(data) { return $.post(GAMERHUB_ADMIN.ajax, $.extend({ nonce: GAMERHUB_ADMIN.nonce_sync }, data)); }
    1724    function sym() { return GAMERHUB_ADMIN.symbol || GAMERHUB_ADMIN.currency; }
     25
     26    // Translated string by key, with the English text as a fallback. The fallback matters: a key that
     27    // is missing (old cached script, partial upgrade) must never render "undefined" on the money screen.
     28    function t(key, fallback) {
     29        var v = (GAMERHUB_ADMIN.i18n || {})[key];
     30        return (typeof v === 'string' && v !== '') ? v : fallback;
     31    }
     32    // Fills %s / %1$s / %2$s in a translated string, in order.
     33    function tf(key, fallback) {
     34        var args = Array.prototype.slice.call(arguments, 2), i = 0;
     35        return t(key, fallback).replace(/%(\d+\$)?s/g, function (m, pos) {
     36            return String(pos ? args[parseInt(pos, 10) - 1] : args[i++]);
     37        });
     38    }
    1839
    1940    $(function () {
    … …  
    2748                    if (r && r.success) {
    2849                        var d = r.data || {};
    29                         $tres.addClass('ok').text('✓ ' + (d.currency ? d.currency + ' ' : '') + (d.balance || ''));
     50                        var txt = '✓ ' + t('connectedBal', 'Connected. Your GamerHub balance:') + ' ' +
     51                            (d.currency ? d.currency + ' ' : '') + (d.balance || '');
     52                        if (d.currency) { txt += ' — ' + tf('chargedIn', 'GamerHub charges you in %s.', d.currency); }
     53                        $tres.addClass('ok').text(txt);
    3054                    } else {
    3155                        var e = (r && r.data) ? r.data : {};
    32                         var msg = e.message || 'Failed';
    33                         if (e.ip) { msg += ' — whitelist this IP in the portal: ' + e.ip; }
     56                        var msg = e.message || t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.');
     57                        if (e.ip) { msg += ' — ' + t('blockedIp', 'GamerHub is blocking this website. Sign in to GamerHub, open your API Key page, and add this address to the allowed list:') + ' ' + e.ip; }
    3458                        $tres.addClass('err').text('✗ ' + msg);
    3559                    }
    3660                })
    37                 .fail(function () { $tres.addClass('err').text('✗ Request failed'); })
     61                .fail(function () { $tres.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    3862                .always(function () { $test.prop('disabled', false); });
    3963        });
    … …  
    5478            return false;
    5579        }
     80        // Search the GAME NAME + CODE only, not the whole row. Every other cell carries text that
     81        // is identical on every row — currency codes, "Imported", the profit dropdown's "+BDT",
     82        // the hint sentences — so matching row text made searches like "bdt" or "add" return the
     83        // entire catalog. Cached per row; a re-render produces fresh elements with fresh data.
     84        function rowSearchText($tr) {
     85            var cached = $tr.data('ghSearch');
     86            if (cached == null) {
     87                // The game NAME only (the <strong>), plus the code from the row attribute. The Game
     88                // cell now also holds the region/type/status badges, and matching those would put
     89                // every row behind a search for "added" or "shop".
     90                cached = ($tr.children('td').eq(1).find('strong').first().text() + ' ' +
     91                          ($tr.attr('data-code') || '')).toLowerCase();
     92                $tr.data('ghSearch', cached);
     93            }
     94            return cached;
     95        }
     96
    5697        function applyCatFilter() {
    5798            var q = $.trim($search.val() || '').toLowerCase();
    … …  
    63104                if ($tr.hasClass('gamerhub-detail-row')) { return; }
    64105                total++;
    65                 var match = (q === '' || $tr.text().toLowerCase().indexOf(q) !== -1);
     106                var match = (q === '' || rowSearchText($tr).indexOf(q) !== -1);
    66107                if (match && fi !== '') { match = (String($tr.attr('data-imported')) === fi); }
    67108                if (match && ft !== '') { match = (String($tr.attr('data-type')) === ft); }
    … …  
    92133                    setBody(r.data.html);
    93134                    $cat.show();
    94                     $scr.addClass('ok').text('✓ ' + r.data.count + ' games');
     135                    $scr.addClass('ok').text('✓ ' + tf('gamesFound', '%s games found. Nothing has been added to your shop yet — tick the games you want, then press “Add to my shop”.', r.data.count));
    95136                    $('#gamerhub-catalog-time').text('· ' + GAMERHUB_ADMIN.i18n.justnow);
    96137                })
    97                 .fail(function () { $scr.addClass('err').text('✗ Request failed'); })
     138                .fail(function () { $scr.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    98139                .always(function () { $sc.prop('disabled', false); });
    99140        });
    100141
    101142
    102         // ── Per-row ↻ : refresh just this game's price (no expand, no import) ─────────
    103         $body.on('click', '.gamerhub-price-refresh', function (e) {
    104             e.preventDefault();
    105             var $lnk = $(this), code = $lnk.data('code');
    106             var $val = $lnk.closest('.gamerhub-price-cell').find('.gamerhub-price-val');
    107             var old = $val.text();
    108             $val.text('…');
    109             post({ action: 'gamerhub_price_one', code: code })
    110                 .done(function (r) {
    111                     if (!r || !r.success) { $val.text(old || '—'); return; }
    112                     var d = r.data;
    113                     if (d.price_min != null) {
    114                         var pmin = Number(d.price_min), pmax = Number(d.price_max);
    115                         $val.text(sym() + ' ' + pmin.toFixed(2) + (pmax > pmin ? ' – ' + pmax.toFixed(2) : ''));
    116                     } else { $val.text('—'); }
    117                 })
    118                 .fail(function () { $val.text(old || '—'); });
     143        // ── Inline profit editor, right in the table row ──────────────────────────────
     144        // Works whether or not the game is in the shop: for a game already added it writes the
     145        // product's profit and reprices immediately; for one that is not, the server parks the
     146        // value and applies it on import.
     147        $body.on('click', '.gamerhub-save-row-mk', function () {
     148            var $btn = $(this), code = $btn.data('code');
     149            var $wrap = $btn.closest('.gamerhub-mk-inline');
     150            var $res = $wrap.find('.gamerhub-row-mk-result');
     151            var val = $.trim($wrap.find('.gamerhub-row-mk-value').val() || '');
     152            var type = $wrap.find('.gamerhub-row-mk-type').val();
     153            $btn.prop('disabled', true);
     154            $res.removeClass('ok err').text(t('working', 'Working…'));
     155            post({ action: 'gamerhub_set_markup', code: code, sku_code: '', mk_type: type, mk_value: val })
     156                .done(function (r) {
     157                    if (!r || !r.success) {
     158                        $res.removeClass('ok').addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed'));
     159                        return;
     160                    }
     161                    // Re-rendering the whole body would close every open panel and lose focus, so
     162                    // swap in just this row's Profit + Buyer-pays cells from the fresh HTML.
     163                    if (r.data.html) {
     164                        var $fresh = $('<tbody>').html(r.data.html)
     165                            .find('tr[data-code="' + code + '"]').not('.gamerhub-detail-row').first();
     166                        if ($fresh.length) {
     167                            var $tr = $btn.closest('tr');
     168                            $tr.find('.gamerhub-mk-row-cell').html($fresh.find('.gamerhub-mk-row-cell').html());
     169                            // The replaced cell carries its own (empty) result span.
     170                            $tr.find('.gamerhub-row-mk-result').addClass('ok').text('✓');
     171                            // An open Prices panel now shows stale numbers — force a reload.
     172                            $('.gamerhub-detail-row[data-code="' + code + '"]').hide()
     173                                .find('.gamerhub-detail-cell').removeData('loaded').empty();
     174                            $tr.find('.gamerhub-expand').attr('aria-expanded', 'false')
     175                               .find('.gamerhub-caret').html('&#9656;');
     176                            return;
     177                        }
     178                    }
     179                    $res.removeClass('err').addClass('ok').text('✓ ' + (r.data.message || ''));
     180                })
     181                .fail(function () { $res.removeClass('ok').addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
     182                .always(function () { $btn.prop('disabled', false); });
    119183        });
    120184
    … …  
    137201                    var d = r.data, imported = d.imported;
    138202
    139                     // Fill the row's Price column with the live retail range — no import needed.
    140                     if (d.price_min != null) {
    141                         var pmin = Number(d.price_min), pmax = Number(d.price_max);
    142                         $btn.closest('tr').find('.gamerhub-price-cell .gamerhub-price-val')
    143                             .text(sym() + ' ' + pmin.toFixed(2) + (pmax > pmin ? ' – ' + pmax.toFixed(2) : ''));
    144                     }
    145 
    146                     function mkType(sel) {
    147                         return '<option value="percent"' + (sel === 'fixed' ? '' : ' selected') + '>%</option>' +
    148                             '<option value="fixed"' + (sel === 'fixed' ? ' selected' : '') + '>' + esc(sym()) + '</option>';
     203
     204                    // What GamerHub charges, in GamerHub's OWN currency, plus the converted figure
     205                    // underneath when the two differ. Each number always carries its own currency —
     206                    // an unlabelled number next to a labelled one is exactly the confusion this
     207                    // screen exists to prevent.
     208                    var acct = d.cost_currency || '';
     209                    function costCell(s) {
     210                        // No account currency (the /balance lookup failed) means we cannot label the
     211                        // number. An unlabelled money figure is worse than none on this screen.
     212                        if (s.cost_src == null || !acct) { return '—'; }
     213                        var srcTxt = acct + ' ' + num2(s.cost_src);
     214                        if (acct === GAMERHUB_ADMIN.currency) { return esc(srcTxt); }
     215                        if (s.cost == null) {
     216                            return esc(srcTxt) + '<br /><span class="description" style="font-size:11px;">'
     217                                 + esc(t('noRate', 'no exchange rate')) + '</span>';
     218                        }
     219                        return esc(srcTxt) + '<br /><span class="description" style="font-size:11px;">= '
     220                             + esc(money(s.cost)) + '</span>';
    149221                    }
    150222
    … …  
    154226                                '<input type="number" class="gamerhub-sku-mk-value" step="0.01" min="0" style="width:70px;" value="' + (s.mk_value != null ? esc(s.mk_value) : '') + '" placeholder="—" title="Blank = use the product / default markup" /> ' +
    155227                                '<select class="gamerhub-sku-mk-type">' + mkType(s.mk_type) + '</select> ' +
    156                                 '<button type="button" class="button button-primary gamerhub-save-sku-mk" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '">Save</button>' +
     228                                '<button type="button" class="button button-primary gamerhub-save-sku-mk" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '">' + esc(t('save', 'Save')) + '</button>' +
    157229                              '</td>'
    158                             : '<td><em style="color:#999">import first</em></td>';
     230                            : '<td><em style="color:#999">' + esc(t('addFirst', 'add this game to your shop first')) + '</em></td>';
    159231                        return '<tr data-sku="' + esc(s.code) + '"><td><strong>' + esc(s.name) + '</strong></td>' +
    160232                            '<td><code>' + esc(s.code) + '</code></td>' +
    161                             '<td style="text-align:right">' + money(s.cost) + '</td>' +
     233                            '<td style="text-align:right">' + costCell(s) + '</td>' +
    162234                            '<td style="text-align:right" class="gamerhub-retail-cell"><strong>' + money(s.retail) + '</strong></td>' +
    163                             '<td>' + (s.stock === 'out_of_stock' ? '<span class="gamerhub-badge err">Out</span>' : '<span class="gamerhub-badge ok">In stock</span>') + '</td>' +
     235                            '<td>' + (s.stock === 'out_of_stock'
     236                                ? '<span class="gamerhub-badge err">' + esc(t('soldOut', 'Sold out')) + '</span>'
     237                                : '<span class="gamerhub-badge ok">' + esc(t('available', 'Available')) + '</span>') + '</td>' +
    164238                            mkCell +
    165                             '<td><button type="button" class="button gamerhub-import-sku" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '" data-name="' + esc(gameName + ' — ' + s.name) + '" data-price="' + (s.retail != null ? s.retail : '') + '" title="Import just this denomination as its own product">Import as product</button></td></tr>';
     239                            '<td><button type="button" class="button gamerhub-import-sku" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '" data-name="' + esc(gameName + ' — ' + s.name) + '" data-price="' + (s.retail != null ? s.retail : '') + '" title="' + esc(t('sellAloneTip', 'Creates a SECOND, separate listing in your shop for this one amount only, with its own name and price. The game itself is not changed, and GamerHub never updates this new listing.')) + '">' + esc(t('sellAlone', 'Sell this amount on its own')) + '</button></td></tr>';
    166240                    }).join('');
    167241
    … …  
    170244                        var pm = d.product_markup || {};
    171245                        prodMk = '<div class="gamerhub-prod-mk">' +
    172                             '<strong>Markup for the whole product:</strong> ' +
     246                            '<strong>' + esc(t('gameProfit', 'Profit for this whole game')) + ':</strong> ' +
    173247                            '<input type="number" class="gamerhub-prod-mk-value" step="0.01" min="0" style="width:80px;" value="' + (pm.value != null ? esc(pm.value) : '') + '" placeholder="default" /> ' +
    174248                            '<select class="gamerhub-prod-mk-type">' + mkType(pm.type) + '</select> ' +
    175                             '<button type="button" class="button button-primary gamerhub-save-prod-mk" data-code="' + esc(code) + '">Apply to all denominations</button> ' +
    176                             '<span class="description">A single denomination’s markup below overrides this. Blank = use the default markup.</span></div>';
     249                            '<button type="button" class="button button-primary gamerhub-save-prod-mk" data-code="' + esc(code) + '">' + esc(t('saveGameProfit', 'Save profit for this game')) + '</button> ' +
     250                            '<span class="description">' + esc(t('gameProfitHint', 'Used for every amount below whose own profit box is empty. An amount that has its own number keeps that number. Leave this empty to use your Default profit %.')) + '</span></div>';
    177251                    }
    178252
    … …  
    184258                        var refItems = d.input_form.map(function (f) {
    185259                            var isSel = (f.type === 'select' && f.options && f.options.length);
    186                             var t = isSel ? 'dropdown' : (f.type || 'text');
    187                             var li = '<li><strong>' + esc(f.label) + '</strong> <span class="description">(' + t + (f.required ? ' · required' : '') + ')</span>';
     260                            var ftype = isSel ? 'dropdown' : (f.type || 'text');   // NB: not `t` — that is the i18n helper
     261                            var li = '<li><strong>' + esc(f.label) + '</strong> <span class="description">(' + ftype + (f.required ? ' · required' : '') + ')</span>';
    188262                            if (isSel) {
    189263                                li += '<div class="gamerhub-optvals"><span class="description">Your add-on dropdown must use these exact values:</span><br>' +
    … …  
    199273                            adv = '<details class="gamerhub-adv-map"><summary>' + esc('Advanced: manual field mapping') + '</summary>' +
    200274                                '<p class="description">Only if a field can’t auto-connect: type your add-on’s field name for each.</p>' + mrows +
    201                                 '<button type="button" class="button gamerhub-save-fieldmap" data-code="' + esc(code) + '">Save mapping</button> <span class="gamerhub-fieldmap-result gamerhub-result"></span></details>';
     275                                '<button type="button" class="button gamerhub-save-fieldmap" data-code="' + esc(code) + '">' + esc(t('saveMapping', 'Save')) + '</button> <span class="gamerhub-fieldmap-result gamerhub-result"></span></details>';
    202276                        }
    203277                        fieldsRef = '<div class="gamerhub-fields-ref">' +
    204                             '<strong>Buyer fields this game needs</strong>' +
     278                            '<strong>' + esc(t('buyerFields', 'Details the buyer must enter for this game')) + '</strong>' +
    205279                            '<ul class="gamerhub-fieldref-list">' + refItems + '</ul>' +
    206280                            '<p class="description">If you use a PPOM / add-on plugin: name your fields the same as above, and for a dropdown use the exact values shown — they connect automatically.</p>' +
    … …  
    213287                        '<div class="gamerhub-detail-inner">' + prodMk + fieldsRef +
    214288                        '<table class="gamerhub-sku-table"><thead><tr>' +
    215                         '<th>Denomination</th><th>SKU code</th><th style="text-align:right">Cost</th><th style="text-align:right">Retail</th><th>Stock</th>' +
    216                         '<th>' + (imported ? 'Markup' : '') + '</th><th></th>' +
     289                        '<th>' + esc(t('thAmount', 'Top-up amount')) + '</th>' +
     290                        '<th>' + esc(t('thCode', 'GamerHub code')) + '</th>' +
     291                        '<th style="text-align:right">' + esc(t('thYouPay', 'You pay')) + '</th>' +
     292                        '<th style="text-align:right">' + esc(t('thBuyerPays', 'Buyer pays')) + '</th>' +
     293                        '<th>' + esc(t('thStock', 'Stock')) + '</th>' +
     294                        '<th>' + (imported ? esc(t('thProfit', 'Your profit')) : '') + '</th><th></th>' +
    217295                        '</tr></thead><tbody>' + rows + '</tbody></table></div>'
    218296                    );
    219297                    $cell.data('loaded', true);
    220298                })
    221                 .fail(function () { $cell.html('<span class="gamerhub-result err">✗ Request failed</span>'); });
     299                .fail(function () { $cell.html('<span class="gamerhub-result err">✗ ' + esc(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')) + '</span>'); });
    222300        });
    223301
    … …  
    239317                    setTimeout(function () { $btn.text(restoreLabel); }, 1200);
    240318                })
    241                 .fail(function () { alert('Request failed'); $btn.text(restoreLabel); })
     319                .fail(function () { alert(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); $btn.text(restoreLabel); })
    242320                .always(function () { $btn.prop('disabled', false); });
    243321        }
    … …  
    248326        $body.on('click', '.gamerhub-save-prod-mk', function () {
    249327            var $btn = $(this), $wrap = $btn.closest('.gamerhub-prod-mk');
    250             saveMarkup($btn, $btn.data('code'), '', $wrap.find('.gamerhub-prod-mk-type').val(), $wrap.find('.gamerhub-prod-mk-value').val(), 'Apply to all');
     328            saveMarkup($btn, $btn.data('code'), '', $wrap.find('.gamerhub-prod-mk-type').val(), $wrap.find('.gamerhub-prod-mk-value').val(), t('saveGameProfit', 'Save profit for this game'));
    251329        });
    252330
    … …  
    266344                    $res.addClass('ok').text('✓ Saved');
    267345                })
    268                 .fail(function () { $res.addClass('err').text('✗ Request failed'); })
    269                 .always(function () { $btn.prop('disabled', false).text('Save mapping'); });
     346                .fail(function () { $res.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
     347                .always(function () { $btn.prop('disabled', false).text(t('saveMapping', 'Save')); });
    270348        });
    271349
    … …  
    298376                    setBody(r.data.html);
    299377                })
    300                 .fail(function () { $btn.prop('disabled', false); alert('Request failed'); });
     378                .fail(function () { $btn.prop('disabled', false); alert(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); });
    301379        });
    302380
    … …  
    318396            $('#gamerhub-modal-name').val($btn.data('name') || '');
    319397            $('#gamerhub-modal-price').val($btn.data('price') || '');
    320             $('.gamerhub-modal-sku').text('SKU code: ' + $btn.data('sku'));
     398            $('.gamerhub-modal-sku').text(t('thCode', 'GamerHub code') + ': ' + $btn.data('sku'));
    321399            $('#gamerhub-modal-result').removeClass('ok err').text('');
    322400            $('#gamerhub-modal-autoprice').prop('checked', true);
    … …  
    332410                name = $.trim($('#gamerhub-modal-name').val()),
    333411                price = $('#gamerhub-modal-price').val();
    334             if (!name) { $('#gamerhub-modal-result').addClass('err').text('Enter a product name'); return; }
     412            if (!name) { $('#gamerhub-modal-result').addClass('err').text(t('enterName', 'Enter a product name.')); return; }
    335413            $c.prop('disabled', true);
    336414            post({ action: 'gamerhub_add_promo', code: pendData.code, sku_code: pendData.sku, name: name, price: price, autoprice: $('#gamerhub-modal-autoprice').is(':checked') ? '1' : '0' })
    … …  
    343421                    }
    344422                })
    345                 .fail(function () { $('#gamerhub-modal-result').addClass('err').text('Request failed'); })
     423                .fail(function () { $('#gamerhub-modal-result').addClass('err').text(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    346424                .always(function () { $c.prop('disabled', false); });
    347425        });
    … …  
    377455                autosync = $('#gamerhub-bulk-autosync').val() || '';
    378456            if (!mkv && !brand && (cat === '0' || cat === '') && !autosync) {
    379                 $res.removeClass('ok').addClass('err').text('Nothing to change'); return;
     457                $res.removeClass('ok').addClass('err').text(t('nothingToChange', 'Nothing to change — fill in at least one box.')); return;
    380458            }
    381459            $btn.prop('disabled', true);
    382             $res.removeClass('ok err').text(GAMERHUB_ADMIN.i18n.syncing);
     460            $res.removeClass('ok err').text(t('working', 'Working…'));
    383461            post({ action: 'gamerhub_bulk_edit', codes: codes, mk_value: mkv, mk_type: mkt, brand: brand, cat: cat, autosync: autosync })
    384462                .done(function (r) {
    385463                    if (!r || !r.success) { $res.addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed')); return; }
    386464                    setBody(r.data.html);
    387                     $res.removeClass('err').addClass('ok').text('✓ ' + r.data.updated + ' ' + GAMERHUB_ADMIN.i18n.done);
    388                 })
    389                 .fail(function () { $res.addClass('err').text('✗ Request failed'); })
     465                    var txt = '✓ ' + tf('bulkResult', 'Changed %1$s of %2$s — only games already in your shop can be changed.', r.data.updated, codes.length);
     466                    if (r.data.failed) {
     467                        // Profit saved but no price moved (usually a currency/rate problem) — never
     468                        // let those be counted as "changed".
     469                        txt += ' ' + tf('bulkFailed', '%s could not be repriced — check Currency handling.', r.data.failed);
     470                        $res.removeClass('ok').addClass('err').text('⚠ ' + txt);
     471                        return;
     472                    }
     473                    $res.removeClass('err').addClass('ok').text(txt);
     474                })
     475                .fail(function () { $res.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    390476                .always(function () { $btn.prop('disabled', false); });
    391477        });
    … …  
    417503                        $imModal.hide(); setBody(r.data.html);
    418504                    })
    419                     .fail(function () { $('#gamerhub-im-result').removeClass('ok').addClass('err').text('✗ Request failed'); })
     505                    .fail(function () { $('#gamerhub-im-result').removeClass('ok').addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    420506                    .always(function () { $c.prop('disabled', false); });
    421507            } else {
    … …  
    461547
    462548        function syncFail(r) {
    463             $sres.addClass('err').text('✗ ' + ((r && r.data && r.data.message) ? r.data.message : 'Request failed'));
     549            $sres.addClass('err').text('✗ ' + ((r && r.data && r.data.message) ? r.data.message : t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')));
    464550            $sync.prop('disabled', false);
    465551        }
  • gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-frontend.js

    r3626288 r3630953  
    3737                .done(function (r) {
    3838                    if (r && r.success) {
    39                         $res.removeClass('err').addClass('ok').text('✓ ' + (r.data.name || ''));
     39                        // The buyer must actually READ the name — a bare tick and a nickname prompts
     40                        // nobody, and a top-up sent to a mistyped ID cannot be recovered.
     41                        $res.removeClass('err').addClass('ok').text(
     42                            '✓ ' + (GAMERHUB_F.i18n.found || 'Account found:') + ' ' + (r.data.name || '') +
     43                            ' — ' + (GAMERHUB_F.i18n.confirmYou || 'please check this is you before you buy.')
     44                        );
    4045                    } else {
    41                         var m = (r && r.data && r.data.message) ? r.data.message : 'Invalid';
     46                        var m = (r && r.data && r.data.message) ? r.data.message : (GAMERHUB_F.i18n.notFound || 'We could not find that account.');
    4247                        $res.removeClass('ok').addClass('err').text('✗ ' + m);
    4348                    }
  • gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-product.js

    r3617487 r3630953  
    1919    }
    2020
     21    function pt(k, fallback) {
     22        var v = (GAMERHUB_P.i18n || {})[k];
     23        return (typeof v === 'string' && v !== '') ? v : fallback;
     24    }
     25    function num2(v) {
     26        var p = Number(v).toFixed(2).split('.');
     27        return p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + p[1];
     28    }
     29
     30    // What GamerHub charges for the selected amount, spelled out under the picker. Without it the
     31    // only number on this screen was a suggested retail, so anyone typing their own price was
     32    // guessing at their own margin.
     33    function renderDenomInfo($sel) {
     34        var $info = $sel.siblings('.gamerhub-denom-info');
     35        if (!$info.length) { return; }
     36        var skus = $sel.data('ghSkus') || [];
     37        var acct = $sel.data('ghAcct') || '';
     38        var code = $sel.val();
     39        var s = null;
     40        for (var i = 0; i < skus.length; i++) { if (skus[i].code === code) { s = skus[i]; break; } }
     41        if (!s || s.cost_src == null || !acct) { $info.empty(); return; }
     42
     43        var out = '<strong>' + esc(pt('youPay','You pay')) + '</strong> ' + esc(acct + ' ' + num2(s.cost_src));
     44        // s.cost is the same figure in the SHOP currency, and is null when the two currencies are
     45        // the same or no rate is available — never print an unconverted number under the shop label.
     46        if (s.cost != null) { out += ' <span style="color:#787c82;">(= ' + esc(GAMERHUB_P.cur + ' ' + num2(s.cost)) + ')</span>'; }
     47        if (s.retail != null) {
     48            out += ' &nbsp;·&nbsp; <strong>' + esc(pt('autoPrice','Auto price')) + '</strong> ' +
     49                   esc(GAMERHUB_P.cur + ' ' + num2(s.retail));
     50        }
     51        // Inline styles on purpose: the product editor loads no GamerHub stylesheet (see
     52        // Gamerhub_Product_Meta::assets), so a class here would render unstyled.
     53        $info.html('<span class="gamerhub-denom-cost" style="display:inline-block;margin-top:4px;font-size:12px;color:#1d2327;">' + out + '</span>');
     54    }
     55
    2156    function loadDenoms($sel, code, current, $preview) {
    2257        if (!code) {
    2358            $sel.html('<option value="">— choose a game first —</option>');
     59            $sel.removeData('ghSkus').siblings('.gamerhub-denom-info').empty();
    2460            if ($preview) { renderPreview($preview, []); }
    2561            return;
    … …  
    2965            .done(function (r) {
    3066                if (r && r.success) {
     67                    var acct = r.data.cost_currency || '';
    3168                    var opts = '<option value="">— choose —</option>';
    3269                    (r.data.skus || []).forEach(function (s) {
    33                         var price = (s.retail != null) ? ' — ' + GAMERHUB_P.cur + ' ' + Number(s.retail).toFixed(2) : '';
     70                        // Lead with the COST — that is the number you price against — and give it in
     71                        // BOTH currencies here as well, not only in the line under the picker. An
     72                        // <option> cannot be styled or wrapped and gets truncated to the select's
     73                        // width once closed, which is exactly why that second copy exists; but the
     74                        // open list still has to be complete enough to compare amounts in.
     75                        var cost = '';
     76                        if (s.cost_src != null && acct) {
     77                            cost = '  —  ' + pt('youPay', 'You pay') + ' ' + acct + ' ' + num2(s.cost_src);
     78                            if (s.cost != null) { cost += ' = ' + GAMERHUB_P.cur + ' ' + num2(s.cost); }
     79                        }
     80                        var price = (s.retail != null)
     81                            ? '  —  ' + pt('sells', 'Sells') + ' ' + GAMERHUB_P.cur + ' ' + num2(s.retail) : '';
    3482                        var sel = (s.code === current) ? ' selected' : '';
    35                         opts += '<option value="' + esc(s.code) + '"' + sel + '>' + esc(s.name) + price + '  —  ' + esc(s.code) + '</option>';
     83                        opts += '<option value="' + esc(s.code) + '"' + sel + '>' + esc(s.name) + cost + price + '  —  ' + esc(s.code) + '</option>';
    3684                    });
    37                     $sel.html(opts);
     85                    $sel.html(opts).data('ghSkus', r.data.skus || []).data('ghAcct', acct);
     86                    renderDenomInfo($sel);
    3887                    if ($preview) { renderPreview($preview, r.data.input_form || []); }
    3988                } else {
    … …  
    5099        $row.find('.gamerhub-var-pcode').val(game);
    51100        loadDenoms($row.find('.gamerhub-var-denom'), game, '');
     101    });
     102    $(document).on('change', '.gamerhub-var-denom, #gamerhub_denom', function () {
     103        renderDenomInfo($(this));
    52104    });
    53105    $(document).on('change', '.gamerhub-var-denom', function () {
  • gamerhub-for-woocommerce/tags/1.2.0/gamerhub-for-woocommerce.php

    r3626288 r3630953  
    44 * Plugin URI:        https://portal.gamerhubstore.shop
    55 * Description:       Sell game top-ups & vouchers through GamerHub. Auto-fulfills WooCommerce orders on payment and syncs status back via a signed webhook.
    6  * Version:           1.1.0
     6 * Version:           1.2.0
    77 * Author:            GamerHub
    88 * License:           GPL-2.0-or-later
    … …  
    2020}
    2121
    22 define('GAMERHUB_VERSION', '1.1.0');
     22define('GAMERHUB_VERSION', '1.2.0');
    2323define('GAMERHUB_FILE', __FILE__);
    2424define('GAMERHUB_DIR', plugin_dir_path(__FILE__));
    … …  
    7070
    7171/**
     72 * How this store handles a store-currency ≠ GamerHub-account-currency mismatch.
     73 *   'block'   — refuse to import/price at all (DEFAULT, and the only mode that cannot lose money)
     74 *   'convert' — multiply every GamerHub cost by a conversion rate the owner supplies
     75 *   'ignore'  — proceed with the raw number, owner prices manually (DANGEROUS with auto-price on)
     76 *
     77 * Anything unrecognised falls back to 'block': an unreadable setting must fail CLOSED.
     78 */
     79function gamerhub_currency_mode(): string
     80{
     81    $m = (string) gamerhub_opt('currency_mode', 'block');
     82    return in_array($m, ['block', 'convert', 'ignore'], true) ? $m : 'block';
     83}
     84
     85/** Where the conversion rate comes from: 'auto' (GamerHub) or 'manual' (owner-typed). */
     86function gamerhub_fx_source(): string
     87{
     88    $s = (string) gamerhub_opt('fx_source', 'auto');
     89    return in_array($s, ['auto', 'manual'], true) ? $s : 'auto';
     90}
     91
     92/** The owner-supplied rate: 1 unit of the GamerHub ACCOUNT currency = N units of the STORE currency. */
     93function gamerhub_fx_rate(): float
     94{
     95    return (float) gamerhub_opt('fx_rate', '0');
     96}
     97
     98/**
     99 * Safety margin added on top of the raw rate, in percent.
     100 *
     101 * Standard practice for every WooCommerce multi-currency plugin, and for good reason: a rate is a
     102 * snapshot, but the price it produces sits in the catalog until the next sync. Without a buffer any
     103 * adverse move between syncs comes straight out of the markup.
     104 */
     105function gamerhub_fx_buffer(): float
     106{
     107    return max(0.0, (float) gamerhub_opt('fx_buffer', '2'));
     108}
     109
     110/** Round finished prices UP to this step (0 = off). Up only — rounding down would eat the margin. */
     111function gamerhub_price_rounding(): float
     112{
     113    return max(0.0, (float) gamerhub_opt('price_rounding', '0'));
     114}
     115
     116/** How old an automatic rate may be before we refuse to price with it. */
     117function gamerhub_fx_max_age(): int
     118{
     119    return DAY_IN_SECONDS;
     120}
     121
     122/**
     123 * The rate actually used to turn a GamerHub cost into a store-currency cost, buffer included.
     124 *
     125 * Returns NULL when no usable rate exists — a missing manual rate, an unreachable server, or a
     126 * server rate older than gamerhub_fx_max_age(). NULL means "do not price": callers must block
     127 * rather than fall back to a guess or a stale number.
     128 */
     129function gamerhub_effective_fx_rate(): ?float
     130{
     131    // RESOLVED ONCE PER REQUEST. A long sync writes hundreds of prices in one request while the
     132    // /balance response is only cached for 5 minutes; without this, the rate could silently vanish
     133    // half way through a batch and later SKUs would be priced differently from earlier ones.
     134    static $memo = null;
     135    static $done = false;
     136
     137    // Any settings write invalidates the memo, so a request that changes the configuration and then
     138    // prices in the same pass cannot use the previous answer.
     139    $stamp = (string) gamerhub_currency_mode() . '|' . gamerhub_fx_source() . '|'
     140        . gamerhub_fx_rate() . '|' . gamerhub_fx_buffer() . '|' . gamerhub_fx_rate_pair()
     141        . '|' . (function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : '');
     142    static $stampSeen = null;
     143    if ($done && $stampSeen === $stamp) {
     144        return $memo;
     145    }
     146
     147    $done      = true;
     148    $stampSeen = $stamp;
     149    $memo      = null;
     150
     151    if (gamerhub_currency_mode() !== 'convert') {
     152        return null;
     153    }
     154
     155    $raw = null;
     156
     157    if (gamerhub_fx_source() === 'manual') {
     158        $manual = gamerhub_fx_rate();
     159        // A manual rate is only meaningful for the pair it was typed for. If the shop has since
     160        // changed its currency, that number now means something else entirely — refuse it.
     161        $pair = gamerhub_fx_rate_pair();
     162        if ($manual > 0 && ($pair === '' || $pair === gamerhub_current_pair())) {
     163            $raw = $manual;
     164        }
     165    } elseif (class_exists('Gamerhub_Sync')) {
     166        $server = Gamerhub_Sync::server_fx_rate();          // ['value'=>float,'age'=>int] | null
     167        if ($server && $server['value'] > 0 && $server['age'] <= gamerhub_fx_max_age()) {
     168            $raw = $server['value'];
     169        }
     170    }
     171
     172    if ($raw === null || $raw <= 0) {
     173        return null;
     174    }
     175
     176    $memo = $raw * (1 + gamerhub_fx_buffer() / 100);
     177    return $memo;
     178}
     179
     180/** The "ACCOUNT>STORE" pair a manual rate was entered for ('' when never recorded). */
     181function gamerhub_fx_rate_pair(): string
     182{
     183    return strtoupper((string) gamerhub_opt('fx_rate_pair', ''));
     184}
     185
     186/** The pair this store needs right now, e.g. "USD>BDT" — '' when it cannot be determined. */
     187function gamerhub_current_pair(): string
     188{
     189    if (! class_exists('Gamerhub_Sync')) {
     190        return '';
     191    }
     192    $mis = Gamerhub_Sync::currency_mismatch();
     193    if (! $mis) {
     194        return '';
     195    }
     196    return strtoupper($mis['api'] . '>' . $mis['wc']);
     197}
     198
     199/**
     200 * Round a finished price UP to the configured step (e.g. 1 → 1247.83 becomes 1248).
     201 * Always upward: a converted price like ৳1,247.83 is unsellable, and rounding down would quietly
     202 * cut into the markup.
     203 */
     204function gamerhub_round_price(float $price): float
     205{
     206    $step = gamerhub_price_rounding();
     207    if ($step <= 0) {
     208        return round($price, 2);   // default: untouched behaviour
     209    }
     210
     211    // Quantise the quotient before ceil(). A markup produces values like 50.00 * 1.10 =
     212    // 55.00000000000001 in IEEE754; feeding that straight into ceil() jumps a whole step
     213    // (55.00 → 60.00 at step 5) and silently overcharges by up to ~9%. Rounding the quotient to 9
     214    // decimals moves the price by at most 5e-8, and only when it is already within that of an exact
     215    // multiple — where ceil() should be a no-op anyway — so it cannot open a below-cost hole.
     216    return round(ceil(round($price / $step, 9)) * $step, 2);
     217}
     218
     219/**
     220 * Convert a GamerHub cost (always in the ACCOUNT currency) into the STORE currency.
     221 *
     222 * This is the ONE place the conversion happens. Every price the plugin ever writes goes through
     223 * gamerhub_apply_markup() below, which calls this first — so a single correct implementation here
     224 * makes catalog sync, single import, promo products, markup edits, bulk edits, the hand-link
     225 * auto-price and the Price column all consistent, with no per-path FX code.
     226 *
     227 * TRULY FAIL-CLOSED — it THROWS rather than returning an unconverted number.
     228 *
     229 * It used to return the cost unchanged when no rate was available, on the assumption that
     230 * currency_error() would already have blocked the request. That assumption is wrong in both
     231 * directions and it is the difference between "no sale" and "sold at 1/122 of cost":
     232 *   - the gate runs ONCE per request, but a catalog sync writes hundreds of prices in a loop
     233 *     afterwards (and ajax_batch has no gate of its own at all);
     234 *   - the same outage that makes the rate unavailable also makes currency_mismatch() return null,
     235 *     so the gate cannot even see the problem.
     236 * A throw stops that product cleanly and surfaces an error; a wrong number sells silently.
     237 *
     238 * It also converts ONLY across a CONFIRMED mismatch. Gating on currency_mode alone meant a store
     239 * whose currencies actually match — but which still had 'convert' selected and an old rate saved —
     240 * multiplied every price by that leftover rate.
     241 *
     242 * @throws RuntimeException when conversion is required but cannot be done honestly.
     243 */
     244function gamerhub_cost_to_store(float $cost): float
     245{
     246    if (gamerhub_currency_mode() !== 'convert') {
     247        return $cost;   // 'block' never reaches a price write; 'ignore' means "use the raw number"
     248    }
     249
     250    // Only convert across a pair we can positively confirm differs. currency_mismatch() returns null
     251    // for BOTH "identical" and "cannot reach the API", so ask for the pair explicitly.
     252    if (gamerhub_current_pair() === '') {
     253        return $cost;   // currencies match (nothing to convert) or unknown — see the guard below
     254    }
     255
     256    $rate = gamerhub_effective_fx_rate();
     257    if ($rate === null || $rate <= 0) {
     258        throw new RuntimeException(
     259            'GamerHub: no usable exchange rate, so no price was written. '
     260            . 'Open the GamerHub menu in WordPress and check "Currency handling".'
     261        );
     262    }
     263
     264    return round($cost * $rate, 6);
     265}
     266
     267/**
    72268 * Retail price for a supplier cost, applying the effective markup.
    73269 * Fallback: per-SKU (variation) override → per-product override → global % (settings).
    74270 * Never returns below cost — a markup is a mark-UP (money-safety backstop).
     271 *
     272 * The incoming $cost is in the GamerHub ACCOUNT currency and is converted to the STORE currency
     273 * FIRST, so the "never below cost" floor below compares like with like.
    75274 */
    76275function gamerhub_apply_markup(float $cost, int $variation_id = 0, int $product_id = 0): float
    77276{
     277    $cost = gamerhub_cost_to_store($cost);   // account currency → store currency (no-op when equal)
     278
    78279    $mk = gamerhub_markup_meta($variation_id);
    79280    if ($mk === null) {
    … …  
    88289        : ($cost * (1 + $mk['value'] / 100));
    89290
    90     return round(max($price, $cost), 2);
     291    // Floor at cost FIRST (a markup is a mark-UP), then round UP — so rounding can only ever move
     292    // the price further above cost, never below it.
     293    return gamerhub_round_price(max($price, $cost));
    91294}
    92295
  • gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-checkout.php

    r3626288 r3630953  
    239239            echo '<button type="button" class="button gamerhub-verify-btn" data-product="'
    240240                . esc_attr($verify_code) . '">'
    241                 . esc_html__('Verify ID', 'gamerhub-for-woocommerce') . '</button> ';
     241                . esc_html__('Check my game account', 'gamerhub-for-woocommerce') . '</button> ';
    242242            echo '<span class="gamerhub-verify-result" aria-live="polite"></span>';
    243243            echo '</p>';
    … …  
    363363            'nonce' => wp_create_nonce('gamerhub_verify'),
    364364            'i18n'  => [
    365                 'checking' => __('Checking…', 'gamerhub-for-woocommerce'),
    366                 'enterId'  => __('Enter the Player ID first.', 'gamerhub-for-woocommerce'),
     365                'checking'   => __('Checking…', 'gamerhub-for-woocommerce'),
     366                'enterId'    => __('Please fill in your details above first.', 'gamerhub-for-woocommerce'),
     367                'found'      => __('Account found:', 'gamerhub-for-woocommerce'),
     368                'confirmYou' => __('please check this is you before you buy.', 'gamerhub-for-woocommerce'),
     369                'notFound'   => __('We could not find that account. Please check what you typed and try again.', 'gamerhub-for-woocommerce'),
    367370            ],
    368371        ]);
    … …  
    403406
    404407        if ($product_code === '' || empty($payload)) {
    405             wp_send_json_error(['message' => __('Enter the Player ID first.', 'gamerhub-for-woocommerce')]);
     408            wp_send_json_error(['message' => __('Please fill in your details above first.', 'gamerhub-for-woocommerce')]);
    406409        }
    407410
    … …  
    422425
    423426        wp_send_json_error([
    424             'message' => (string) ($json['message'] ?? __('Player ID / Server Incorrect', 'gamerhub-for-woocommerce')),
     427            'message' => (string) ($json['message'] ?? __('We could not find that account. Please check what you typed and try again.', 'gamerhub-for-woocommerce')),
    425428        ]);
    426429    }
  • gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-orders.php

    r3626288 r3630953  
    115115                    $item->save();
    116116                    $detail = ! empty($missing) ? (': ' . implode(', ', $missing)) : '';
    117                     $order->add_order_note(sprintf('GamerHub: "%s" was NOT submitted — player details are missing%s. Contact the customer or refund.', $item->get_name(), $detail));
     117                    $order->add_order_note(sprintf(
     118                    /* translators: 1: product name, 2: extra detail about which field is missing */
     119                    __('GamerHub did not send "%1$s" because the player details are missing%2$s. Nothing was taken from your GamerHub balance. The customer has already paid — ask them for the missing details, or refund the order.', 'gamerhub-for-woocommerce'),
     120                    $item->get_name(),
     121                    $detail
     122                ));
    118123                    $touched = true;
    119124                    continue;
  • gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-product-meta.php

    r3626288 r3630953  
    6262                <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option>
    6363            </select>
     64            <span class="gamerhub-denom-info"></span>
    6465        </div>
    6566        <div class="form-row form-row-first">
    … …  
    7172            <input type="text" class="gamerhub-var-scode" name="gamerhub_var_scode[<?php echo esc_attr($loop); ?>]" value="<?php echo esc_attr($scode); ?>" placeholder="freefire-mysg-110" />
    7273        </div>
    73         <p class="form-row form-row-full description" style="margin:4px 0 0;"><?php esc_html_e('Price / markup is managed under GamerHub → Products (expand the game).', 'gamerhub-for-woocommerce'); ?></p>
     74        <p class="form-row form-row-full description" style="margin:4px 0 0;"><?php esc_html_e('To change the price or the profit %, open the GamerHub menu in WordPress and press Prices on this game’s row.', 'gamerhub-for-woocommerce'); ?></p>
    7475        <?php
    7576        // Buyer-field reference + optional PPOM / add-on mapping for THIS variation's linked game.
    … …  
    256257                    <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option>
    257258                </select>
     259                <span class="gamerhub-denom-info"></span>
    258260            </p>
    259261            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    … …  
    272274                <?php echo self::render_form_preview($form); // phpcs:ignore WordPress.Security.EscapeOutput ?>
    273275            </div>
    274             <?php $ap_checked = ($pcode === '') ? true : (get_post_meta($post->ID, '_gamerhub_autoprice', true) === '1'); ?>
     276            <?php
     277            $ap_checked = ($pcode === '') ? true : (get_post_meta($post->ID, '_gamerhub_autoprice', true) === '1');
     278            $mk_val     = (string) get_post_meta($post->ID, '_gamerhub_markup_value', true);
     279            $mk_type    = get_post_meta($post->ID, '_gamerhub_markup_type', true) === 'fixed' ? 'fixed' : 'percent';
     280            $mk_default = (string) gamerhub_opt('markup', '0');
     281            ?>
    275282            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    276                 <label><input type="checkbox" name="gamerhub_autoprice" value="1" <?php checked($ap_checked); ?> /> <strong><?php esc_html_e('Auto-update price & stock from GamerHub', 'gamerhub-for-woocommerce'); ?></strong></label>
    277                 <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: the price follows GamerHub cost × your Default markup and stock stays in sync automatically. Off: set your own fixed price in Product data → General → Regular price.', 'gamerhub-for-woocommerce'); ?></span>
    278             </p>
     283                <label><input type="checkbox" name="gamerhub_autoprice" id="gamerhub_autoprice" value="1" <?php checked($ap_checked); ?> /> <strong><?php esc_html_e('Keep price and stock up to date from GamerHub (recommended)', 'gamerhub-for-woocommerce'); ?></strong></label>
     284                <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: GamerHub sets the price for you — what it costs you, plus the profit below — and marks it sold out when they run out. Careful: switching this on replaces any price you typed, as soon as you save. Off: your own price stays frozen, but so does the stock — if GamerHub runs out, your shop keeps taking orders you cannot deliver. Type your price in Product data → General → Regular price.', 'gamerhub-for-woocommerce'); ?></span>
     285            </p>
     286            <?php // Without this field a hand-linked product could only ever use the global default:
     287                  // the catalog table's inline editor reaches sync-owned games only, and turning
     288                  // auto-pricing off just to change one margin also freezes the sold-out protection. ?>
     289            <p id="gamerhub-mk-row" style="margin-top:8px;<?php echo $ap_checked ? '' : 'display:none;'; ?>">
     290                <label for="gamerhub_markup_value"><strong><?php esc_html_e('Profit for this product', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     291                <input type="number" name="gamerhub_markup_value" id="gamerhub_markup_value" step="0.01" min="0" style="width:90px;" value="<?php echo esc_attr($mk_val); ?>" placeholder="<?php echo esc_attr($mk_default); ?>" />
     292                <select name="gamerhub_markup_type" id="gamerhub_markup_type">
     293                    <option value="percent" <?php selected($mk_type, 'percent'); ?>><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     294                    <option value="fixed" <?php selected($mk_type, 'fixed'); ?>><?php
     295                        /* translators: %s: store currency code, e.g. MYR */
     296                        printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     297                    ?></option>
     298                </select>
     299                <span class="description" style="display:block;margin-top:2px;"><?php
     300                    printf(
     301                        /* translators: %s: the Default profit % from the GamerHub settings page */
     302                        esc_html__('Leave empty to use your Default profit %%, which is %s%%. The price is rewritten as soon as you save.', 'gamerhub-for-woocommerce'),
     303                        esc_html($mk_default)
     304                    );
     305                ?></span>
     306            </p>
     307            <script>
     308            (function () {
     309                var cb = document.getElementById('gamerhub_autoprice'),
     310                    row = document.getElementById('gamerhub-mk-row');
     311                if (cb && row) {
     312                    cb.addEventListener('change', function () { row.style.display = cb.checked ? '' : 'none'; });
     313                }
     314            }());
     315            </script>
    279316            <?php if (! empty($form)) :
    280317                $fmap = json_decode((string) get_post_meta($post->ID, '_gamerhub_field_map', true) ?: '[]', true);
    … …  
    337374        if ($gh_show_hide) : ?>
    338375            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    339                 <label><input type="checkbox" name="gamerhub_hide_fields" value="1" <?php checked(get_post_meta($post->ID, '_gamerhub_hide_fields', true) === '1'); ?> /> <strong><?php esc_html_e('Use my own fields (PPOM / add-on) — hide GamerHub’s built-in fields', 'gamerhub-for-woocommerce'); ?></strong></label>
    340                 <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('By default GamerHub shows the buyer fields itself (nothing to set up). Turn this on only if a PPOM / product-addons plugin already shows the Player ID / Server fields — it removes our duplicate fields. We still auto-capture the buyer’s values and fulfil.', 'gamerhub-for-woocommerce'); ?></span>
     376                <label><input type="checkbox" name="gamerhub_hide_fields" value="1" <?php checked(get_post_meta($post->ID, '_gamerhub_hide_fields', true) === '1'); ?> /> <strong><?php esc_html_e('Another plugin already asks for the Player ID — hide GamerHub’s own fields', 'gamerhub-for-woocommerce'); ?></strong></label>
     377                <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('Leave this OFF unless you are sure. GamerHub already shows the Player ID / Server boxes for you and nothing needs setting up. Turn it on only if a plugin such as PPOM or Product Add-Ons already shows those boxes and buyers see them twice. Once it is on, GamerHub stops checking that the buyer filled them in — if the details end up missing, the customer can pay and the order will NOT be delivered. Place one test order before you go live.', 'gamerhub-for-woocommerce'); ?></span>
    341378            </p>
    342379        <?php endif; ?>
    … …  
    442479            if (! empty($_POST['gamerhub_autoprice'])) {
    443480                update_post_meta($post_id, '_gamerhub_autoprice', '1');
     481
     482                // This product's own profit. Written BEFORE the price below, because
     483                // gamerhub_apply_markup($cost, 0, $post_id) reads it straight back out of meta —
     484                // set it after and the first save would still price at the global default.
     485                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verifies on woocommerce_process_product_meta.
     486                $mk_in = isset($_POST['gamerhub_markup_value']) ? sanitize_text_field(wp_unslash($_POST['gamerhub_markup_value'])) : '';
     487                if ($mk_in === '') {
     488                    delete_post_meta($post_id, '_gamerhub_markup_value');
     489                    delete_post_meta($post_id, '_gamerhub_markup_type');
     490                } else {
     491                    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- as above.
     492                    $mk_t = (isset($_POST['gamerhub_markup_type']) && $_POST['gamerhub_markup_type'] === 'fixed') ? 'fixed' : 'percent';
     493                    update_post_meta($post_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_in));
     494                    update_post_meta($post_id, '_gamerhub_markup_type', $mk_t);
     495                }
    444496
    445497                // Money safety: the SKU price is in the GamerHub ACCOUNT currency and we do no FX. On a
    … …  
    455507                            $wc = wc_get_product($post_id);
    456508                            if ($wc) {
    457                                 $wc->set_regular_price((string) gamerhub_apply_markup((float) $s['price'], 0, $post_id));
    458                                 $wc->set_price((string) gamerhub_apply_markup((float) $s['price'], 0, $post_id));
    459                                 $wc->set_stock_status(($s['stock_status'] ?? 'in_stock') !== 'out_of_stock' ? 'instock' : 'outofstock');
    460                                 $wc->save();
     509                                // gamerhub_apply_markup() throws when a conversion is required but no
     510                                // honest rate exists. Never let that escape a post save — it would
     511                                // white-screen the product editor. Leave the price alone and tell the
     512                                // owner why instead.
     513                                try {
     514                                    $price = gamerhub_apply_markup((float) $s['price'], 0, $post_id);
     515                                    $wc->set_regular_price((string) $price);
     516                                    $wc->set_price((string) $price);
     517                                    $wc->set_stock_status(($s['stock_status'] ?? 'in_stock') !== 'out_of_stock' ? 'instock' : 'outofstock');
     518                                    $wc->save();
     519                                } catch (\Throwable $e) {
     520                                    set_transient('gamerhub_meta_notice_' . get_current_user_id(), $e->getMessage(), MINUTE_IN_SECONDS);
     521                                }
    461522                            }
    462523                            break;
    … …  
    487548            'nonce' => wp_create_nonce('gamerhub_sync'),
    488549            'cur'   => get_woocommerce_currency(),
     550            'i18n'  => [
     551                'youPay'    => __('You pay', 'gamerhub-for-woocommerce'),
     552                'autoPrice' => __('Auto price', 'gamerhub-for-woocommerce'),
     553                'sells'     => __('Sells', 'gamerhub-for-woocommerce'),
     554            ],
    489555        ]);
    490556    }
  • gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-settings.php

    r3626288 r3630953  
    4545            'nonce_sync' => wp_create_nonce('gamerhub_sync'),
    4646            'currency'   => get_woocommerce_currency(),
    47             'symbol'     => get_woocommerce_currency_symbol(),
     47            // Deliberately the CODE, not get_woocommerce_currency_symbol(). Everything on the
     48            // GamerHub admin screens must name the currency the same way: a symbol is ambiguous
     49            // ("$" = USD, SGD, CAD…) and mixing "৳ 98.00" with "BDT 98.00" on one screen is exactly
     50            // the currency confusion these screens exist to prevent.
     51            'symbol'     => get_woocommerce_currency(),
    4852            'i18n'       => [
    4953                'testing'       => __('Testing…', 'gamerhub-for-woocommerce'),
    5054                'loading'       => __('Loading…', 'gamerhub-for-woocommerce'),
    51                 'syncing'       => __('Syncing…', 'gamerhub-for-woocommerce'),
     55                'syncing'       => __('Getting the list…', 'gamerhub-for-woocommerce'),
     56                'working'       => __('Working…', 'gamerhub-for-woocommerce'),
    5257                'done'          => __('Done', 'gamerhub-for-woocommerce'),
    5358                'none'          => __('Tick at least one game.', 'gamerhub-for-woocommerce'),
    54                 'justnow'       => __('catalog synced just now', 'gamerhub-for-woocommerce'),
     59                'justnow'       => __('game list updated just now', 'gamerhub-for-woocommerce'),
    5560                'created'       => __('Created — edit', 'gamerhub-for-woocommerce'),
    56                 'importing'     => __('Importing', 'gamerhub-for-woocommerce'),
    57                 'selected'      => __('selected', 'gamerhub-for-woocommerce'),
    58                 'confirmRemove' => __('Remove the WooCommerce product(s) for the selected game(s)?', 'gamerhub-for-woocommerce'),
     61                'importing'     => __('Adding', 'gamerhub-for-woocommerce'),
     62                'selected'      => __('ticked', 'gamerhub-for-woocommerce'),
     63                'confirmRemove' => __('Delete these products from your shop for good? The WooCommerce products, their photos and any text you wrote are deleted. They do NOT go to the Trash and cannot be brought back. Your GamerHub account is not touched — you can add them again later.', 'gamerhub-for-woocommerce'),
     64
     65                // Everything below used to be hard-coded English inside gamerhub-admin.js, which meant
     66                // the one screen where money is set could never be translated. Read via t() over there.
     67                'thAmount'      => __('Top-up amount', 'gamerhub-for-woocommerce'),
     68                'thCode'        => __('GamerHub code', 'gamerhub-for-woocommerce'),
     69                'thYouPay'      => __('You pay', 'gamerhub-for-woocommerce'),
     70                'thBuyerPays'   => __('Buyer pays', 'gamerhub-for-woocommerce'),
     71                'thStock'       => __('Stock', 'gamerhub-for-woocommerce'),
     72                'noRate'        => __('no exchange rate', 'gamerhub-for-woocommerce'),
     73                'thProfit'      => __('Your profit', 'gamerhub-for-woocommerce'),
     74                'soldOut'       => __('Sold out', 'gamerhub-for-woocommerce'),
     75                'available'     => __('Available', 'gamerhub-for-woocommerce'),
     76                'pctOnCost'     => __('% on top of cost', 'gamerhub-for-woocommerce'),
     77                /* translators: %s: store currency code, e.g. MYR */
     78                'curOnCost'     => sprintf(__('+ %s on top of cost', 'gamerhub-for-woocommerce'), get_woocommerce_currency()),
     79                'save'          => __('Save', 'gamerhub-for-woocommerce'),
     80                'addFirst'      => __('add this game to your shop first', 'gamerhub-for-woocommerce'),
     81                'sellAlone'     => __('Sell this amount on its own', 'gamerhub-for-woocommerce'),
     82                'sellAloneTip'  => __('Creates a SECOND, separate listing in your shop for this one amount only, with its own name and price. The game itself is not changed, and GamerHub never updates this new listing.', 'gamerhub-for-woocommerce'),
     83                'gameProfit'    => __('Profit for this whole game', 'gamerhub-for-woocommerce'),
     84                'saveGameProfit' => __('Save profit for this game', 'gamerhub-for-woocommerce'),
     85                'gameProfitHint' => __('Used for every amount below whose own profit box is empty. An amount that has its own number keeps that number. Leave this empty to use your Default profit %.', 'gamerhub-for-woocommerce'),
     86                'nothingToChange' => __('Nothing to change — fill in at least one box.', 'gamerhub-for-woocommerce'),
     87                'reqFailed'     => __('Could not reach your site. Nothing was changed. Check your connection and try again.', 'gamerhub-for-woocommerce'),
     88                'enterName'     => __('Enter a product name.', 'gamerhub-for-woocommerce'),
     89                'buyerFields'   => __('Details the buyer must enter for this game', 'gamerhub-for-woocommerce'),
     90                'saveMapping'   => __('Save', 'gamerhub-for-woocommerce'),
     91                'connectedBal'  => __('Connected. Your GamerHub balance:', 'gamerhub-for-woocommerce'),
     92                /* translators: %s: GamerHub account currency code, e.g. USD */
     93                'chargedIn'     => __('GamerHub charges you in %s.', 'gamerhub-for-woocommerce'),
     94                'blockedIp'     => __('GamerHub is blocking this website. Sign in to GamerHub, open your API Key page, and add this address to the allowed list:', 'gamerhub-for-woocommerce'),
     95                /* translators: %s: number of games */
     96                'gamesFound'    => __('%s games found. Nothing has been added to your shop yet — tick the games you want, then press “Add to my shop”.', 'gamerhub-for-woocommerce'),
     97                /* translators: 1: number changed, 2: number ticked */
     98                'bulkResult'    => __('Changed %1$s of %2$s — only games already in your shop can be changed.', 'gamerhub-for-woocommerce'),
     99                /* translators: %s: number of games whose price could not be rewritten */
     100                'bulkFailed'    => __('%s could not be repriced — check Currency handling.', 'gamerhub-for-woocommerce'),
    59101            ],
    60102        ]);
    … …  
    80122        if (! in_array($auto_sync, ['off', '1', '3', '6', '12', '24'], true)) {
    81123            $auto_sync = 'off';
     124        }
     125
     126        // How to handle a store-currency ≠ GamerHub-currency mismatch. Anything unrecognised falls
     127        // back to 'block' — an unreadable value must fail CLOSED, never open.
     128        $currency_mode = isset($_POST['currency_mode']) ? sanitize_text_field(wp_unslash($_POST['currency_mode'])) : 'block';
     129        if (! in_array($currency_mode, ['block', 'convert', 'ignore'], true)) {
     130            $currency_mode = 'block';
     131        }
     132        $fx_rate = isset($_POST['fx_rate']) ? (float) $_POST['fx_rate'] : 0;
     133        if ($fx_rate < 0) {
     134            $fx_rate = 0;
     135        }
     136        $fx_source = isset($_POST['fx_source']) ? sanitize_text_field(wp_unslash($_POST['fx_source'])) : 'auto';
     137        if (! in_array($fx_source, ['auto', 'manual'], true)) {
     138            $fx_source = 'auto';
     139        }
     140        $fx_buffer = isset($_POST['fx_buffer']) ? (float) $_POST['fx_buffer'] : 2;
     141        if ($fx_buffer < 0) {
     142            $fx_buffer = 0;
     143        }
     144        $price_rounding = isset($_POST['price_rounding']) ? (float) $_POST['price_rounding'] : 0;
     145        if ($price_rounding < 0) {
     146            $price_rounding = 0;
     147        }
     148        // "Ignore" is genuinely dangerous with auto-price on, so it requires an explicit tick every
     149        // time it is saved. Without it we silently fall back to blocking.
     150        if ($currency_mode === 'ignore' && empty($_POST['currency_ack'])) {
     151            $currency_mode = 'block';
    82152        }
    83153
    … …  
    96166            'auto_sync'      => $auto_sync,
    97167            'auto_import'    => ! empty($_POST['auto_import']) ? '1' : '0',
     168            'currency_mode'  => $currency_mode,
     169            'fx_source'      => $fx_source,
     170            'fx_rate'        => (string) $fx_rate,
     171            // Bind the manual rate to the pair it was typed for. "1 USD = 4.7 MYR" is meaningless
     172            // once the shop switches to BDT, so record the pair and refuse the rate if it changes.
     173            'fx_rate_pair'   => $fx_rate > 0
     174                ? (function_exists('gamerhub_current_pair') ? gamerhub_current_pair() : '')
     175                : '',
     176            'fx_buffer'      => (string) $fx_buffer,
     177            'price_rounding' => (string) $price_rounding,
     178            // Never rewrite from the form — it records what the SYNC last priced in.
     179            'priced_in_currency' => (string) ($existing['priced_in_currency'] ?? ''),
    98180        ];
    99181
    … …  
    153235        }
    154236        $auto_import    = gamerhub_opt('auto_import', '0');
     237        $currency_mode  = gamerhub_currency_mode();
     238        $fx_rate        = gamerhub_opt('fx_rate', '0');
     239        $fx_source      = gamerhub_fx_source();
     240        $fx_buffer      = gamerhub_opt('fx_buffer', '2');
     241        $price_rounding = gamerhub_opt('price_rounding', '0');
    155242        $callback_url   = rest_url('gamerhub/v1/callback');
    156243        $configured     = $api_key !== '' && $api_secret !== '';
    … …  
    178265                <div class="gamerhub-stat">
    179266                    <div class="gamerhub-stat-n"><?php echo esc_html($configured ? '✓' : '—'); ?></div>
    180                     <div class="gamerhub-stat-l"><?php esc_html_e('Connection', 'gamerhub-for-woocommerce'); ?></div>
     267                    <div class="gamerhub-stat-l"><?php esc_html_e('Keys saved (test below)', 'gamerhub-for-woocommerce'); ?></div>
    181268                </div>
    182269                <div class="gamerhub-stat">
    … …  
    225312                        <td>
    226313                            <input name="webhook_secret" id="gamerhub_webhook_secret" type="password" class="regular-text" value="" autocomplete="new-password" placeholder="<?php echo esc_attr($webhook_secret !== '' ? __('•••••••• saved — leave blank to keep', 'gamerhub-for-woocommerce') : __('Not set', 'gamerhub-for-woocommerce')); ?>" />
    227                             <p class="description"><?php esc_html_e('Verifies status callbacks. Must match the Webhook Secret set in the portal. Stored server-side only — leave blank to keep the saved value.', 'gamerhub-for-woocommerce'); ?></p>
     314                            <p class="description"><?php esc_html_e('GamerHub uses this to prove that the delivery messages it sends back to your shop are genuine. It must be EXACTLY the same as the Webhook Secret in your GamerHub account. If the two do not match, GamerHub still delivers and still charges your balance, but your shop never marks the order Complete and the customer never gets their code. We save it but never show it again — leave the box empty to keep the one you already saved.', 'gamerhub-for-woocommerce'); ?></p>
    228315                        </td>
    229316                    </tr>
    230317                    <tr>
    231                         <th scope="row"><label for="gamerhub_markup"><?php esc_html_e('Default markup %', 'gamerhub-for-woocommerce'); ?></label></th>
     318                        <th scope="row"><label for="gamerhub_markup"><?php esc_html_e('Default profit %', 'gamerhub-for-woocommerce'); ?></label></th>
    232319                        <td>
    233320                            <input name="markup" id="gamerhub_markup" type="number" step="0.01" min="0" class="small-text" value="<?php echo esc_attr($markup); ?>" /> %
    234                             <p class="description"><?php esc_html_e('Your profit added on top of cost, used when a product has no markup of its own. e.g. 15 = cost × 1.15. You can override it per product / per denomination under GamerHub → Products.', 'gamerhub-for-woocommerce'); ?></p>
     321                            <p class="description"><?php esc_html_e('How much you add on top of what GamerHub charges you. Example: type 15 and an item that costs you 10.00 is sold at 11.50. Set this before you add any game to your shop. You can give one game, or one single top-up amount, its own profit % further down this page — press Prices on that game’s row.', 'gamerhub-for-woocommerce'); ?></p>
     322                            <?php if ((float) $markup <= 0) : ?>
     323                                <p class="description" style="color:#b32d2e;font-weight:600;margin-top:4px;">
     324                                    <?php esc_html_e('⚠ This is 0, so every game you add is sold at exactly what you pay GamerHub and you earn nothing.', 'gamerhub-for-woocommerce'); ?>
     325                                </p>
     326                            <?php endif; ?>
    235327                        </td>
    236328                    </tr>
    … …  
    246338                                <option value="24"  <?php selected($auto_sync, '24'); ?>><?php esc_html_e('Every 24 hours', 'gamerhub-for-woocommerce'); ?></option>
    247339                            </select>
    248                             <p class="description"><?php esc_html_e('Automatically refreshes prices & stock of your imported products in the background — no manual sync needed.', 'gamerhub-for-woocommerce'); ?></p>
     340                            <p class="description" style="max-width:640px;"><?php esc_html_e('How often the plugin checks GamerHub on its own. Each time, it REWRITES the prices of the games already in your shop using your profit %, marks sold-out items sold out, and adds any new top-up amounts. It also puts the GamerHub name back and puts the product back on sale — so a price or a name you typed by hand on one of these products will be replaced. To protect one game from this, untick “Keep updated” on its row further down.', 'gamerhub-for-woocommerce'); ?></p>
    249341                            <label style="display:inline-block;margin-top:8px;">
    250342                                <input type="checkbox" name="auto_import" value="1" <?php checked($auto_import, '1'); ?> />
    251                                 <?php esc_html_e('Also auto-import newly listed games', 'gamerhub-for-woocommerce'); ?>
     343                                <?php esc_html_e('Also add brand-new games to my shop automatically', 'gamerhub-for-woocommerce'); ?>
    252344                            </label>
     345                            <p class="description" style="margin-top:4px;max-width:640px;">
     346                                <?php esc_html_e('You do NOT need this box to keep your existing games up to date — that already happens above.', 'gamerhub-for-woocommerce'); ?><br />
     347                                <?php esc_html_e('This box only works while the box above is not set to Off. Tick it and every new game GamerHub starts selling appears in your shop by itself, on sale straight away, priced with your Default profit % — you are not asked first, and it can be hundreds of products.', 'gamerhub-for-woocommerce'); ?>
     348                            </p>
     349                        </td>
     350                    </tr>
     351                    <tr>
     352                        <th scope="row"><label for="gamerhub_currency_mode"><?php esc_html_e('Currency handling', 'gamerhub-for-woocommerce'); ?></label></th>
     353                        <td>
     354                            <?php
     355                            $mismatch = class_exists('Gamerhub_Sync') ? Gamerhub_Sync::currency_mismatch() : null;
     356                            if ($mismatch) {
     357                                printf(
     358                                    '<p class="notice notice-warning" style="padding:8px 12px;margin:0 0 10px;">%s</p>',
     359                                    esc_html(sprintf(
     360                                        /* translators: 1: store currency, 2: GamerHub currency */
     361                                        __('Your shop sells in %1$s, but GamerHub charges you in %2$s. A %2$s cost cannot be used as a %1$s price directly — choose what should happen below.', 'gamerhub-for-woocommerce'),
     362                                        $mismatch['wc'],
     363                                        $mismatch['api']
     364                                    ))
     365                                );
     366                            }
     367                            ?>
     368                            <?php
     369                            // Name the real currencies in the options — "convert with my rate" tells a
     370                            // shop owner nothing, "Convert USD prices into BDT" tells them everything.
     371                            $cWc  = $mismatch ? $mismatch['wc']  : __('your currency', 'gamerhub-for-woocommerce');
     372                            $cApi = $mismatch ? $mismatch['api'] : __('the account currency', 'gamerhub-for-woocommerce');
     373                            ?>
     374                            <select name="currency_mode" id="gamerhub_currency_mode">
     375                                <option value="convert" <?php selected($currency_mode, 'convert'); ?>><?php
     376                                    /* translators: 1: GamerHub account currency, 2: store currency */
     377                                    printf(esc_html__('Convert %1$s prices into %2$s', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     378                                ?></option>
     379                                <option value="block"   <?php selected($currency_mode, 'block'); ?>><?php esc_html_e('Pause — do not import or reprice', 'gamerhub-for-woocommerce'); ?></option>
     380                                <option value="ignore"  <?php selected($currency_mode, 'ignore'); ?>><?php
     381                                    /* translators: 1: GamerHub account currency, 2: store currency */
     382                                    printf(esc_html__('Use the %1$s number as the %2$s price (no conversion)', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     383                                ?></option>
     384                            </select>
     385
     386                            <ul style="margin:8px 0 0 2px;list-style:disc;padding-left:18px;font-size:13px;color:#50575e;max-width:640px;">
     387                                <li><strong><?php printf(esc_html__('Convert', 'gamerhub-for-woocommerce')); ?></strong> — <?php
     388                                    /* translators: 1: GamerHub account currency, 2: store currency */
     389                                    printf(esc_html__('each %1$s cost is multiplied by the rate below, then your markup is added. Prices stay correct and you keep selling in %2$s. This is what most shops want.', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     390                                ?></li>
     391                                <li><strong><?php esc_html_e('Pause', 'gamerhub-for-woocommerce'); ?></strong> — <?php esc_html_e('nothing is imported and no price is changed while the two currencies are different. This is the safe default. To start selling, choose Convert above. Do not change your shop currency instead — WooCommerce keeps your saved price numbers and only swaps the label, so your whole shop would suddenly be priced wrong.', 'gamerhub-for-woocommerce'); ?></li>
     392                                <li><strong><?php esc_html_e('No conversion', 'gamerhub-for-woocommerce'); ?></strong> — <?php
     393                                    /* translators: 1: GamerHub account currency, 2: store currency */
     394                                    printf(esc_html__('the number is copied across unchanged, so a cost of 2.50 %1$s becomes a price of 2.50 %2$s. Only safe if you type every price yourself and leave auto-pricing off.', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     395                                ?></li>
     396                            </ul>
     397
     398                            <p style="margin-top:12px;">
     399                                <label><strong><?php esc_html_e('Rate source', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     400                                <label style="margin-right:14px;">
     401                                    <input type="radio" name="fx_source" value="auto" <?php checked($fx_source, 'auto'); ?> />
     402                                    <?php esc_html_e('Use GamerHub’s rate — recommended, it updates itself', 'gamerhub-for-woocommerce'); ?>
     403                                </label><br />
     404                                <label>
     405                                    <input type="radio" name="fx_source" value="manual" <?php checked($fx_source, 'manual'); ?> />
     406                                    <?php esc_html_e('Type my own rate below', 'gamerhub-for-woocommerce'); ?>
     407                                </label>
     408                            </p>
     409                            <p class="description" style="max-width:640px;margin-top:-4px;">
     410                                <?php esc_html_e('If GamerHub has no rate, or its rate is more than a day old, the plugin stops importing and stops changing prices until it has a fresh one — so you are never priced from a stale rate.', 'gamerhub-for-woocommerce'); ?>
     411                            </p>
     412
     413                            <?php
     414                            // Show the live automatic rate so the owner can see exactly what is used.
     415                            if ($mismatch && class_exists('Gamerhub_Sync')) {
     416                                $srv = Gamerhub_Sync::server_fx_rate();
     417                                if ($srv && $srv['value'] > 0) {
     418                                    $fresh = $srv['age'] <= DAY_IN_SECONDS;
     419                                    printf(
     420                                        '<p class="description" style="margin:4px 0 0;">%s <strong>1 %s = %s %s</strong> — %s</p>',
     421                                        esc_html__('GamerHub rate:', 'gamerhub-for-woocommerce'),
     422                                        esc_html($mismatch['api']),
     423                                        esc_html(number_format($srv['value'], 4)),
     424                                        esc_html($mismatch['wc']),
     425                                        $fresh
     426                                            ? esc_html(sprintf(
     427                                                /* translators: %s: human-readable age, e.g. "8 mins" */
     428                                                __('updated %s ago', 'gamerhub-for-woocommerce'),
     429                                                human_time_diff(time() - $srv['age'])
     430                                            ))
     431                                            : '<span style="color:#b32d2e;">' . esc_html__('OUT OF DATE — automatic pricing is paused', 'gamerhub-for-woocommerce') . '</span>'
     432                                    );
     433                                } else {
     434                                    printf(
     435                                        '<p class="description" style="margin:4px 0 0;color:#b32d2e;">%s</p>',
     436                                        esc_html__('GamerHub has no rate for this pair right now — use a manual rate, or imports stay paused.', 'gamerhub-for-woocommerce')
     437                                    );
     438                                }
     439                            }
     440                            ?>
     441
     442                            <p style="margin-top:10px;">
     443                                <label for="gamerhub_fx_rate"><strong><?php esc_html_e('My own exchange rate', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     444                                <?php // The direction must be visible even when /balance is unreachable and we
     445                                      // cannot name the two currencies — typing it backwards is a catastrophe. ?>
     446                                <span class="description"><?php
     447                                    printf(
     448                                        /* translators: %s: the GamerHub account currency code, e.g. USD */
     449                                        esc_html__('1 %s =', 'gamerhub-for-woocommerce'),
     450                                        esc_html($cApi)
     451                                    );
     452                                ?></span>
     453                                <input name="fx_rate" id="gamerhub_fx_rate" type="number" step="0.0001" min="0" class="small-text" value="<?php echo esc_attr($fx_rate); ?>" />
     454                                <span class="description"><?php echo esc_html($cWc); ?></span>
     455                            </p>
     456                            <p class="description" style="max-width:640px;">
     457                                <?php esc_html_e('How many units of YOUR shop currency equal 1 unit of the GamerHub currency. Example: 1 USD = 4.70 MYR, so type 4.70 — not 0.21. Only used when you pick “Type my own rate” above. If you pick it and leave this at 0, nothing will be imported.', 'gamerhub-for-woocommerce'); ?>
     458                            </p>
     459
     460                            <p style="margin-top:10px;">
     461                                <label for="gamerhub_fx_buffer"><strong><?php esc_html_e('Rate buffer', 'gamerhub-for-woocommerce'); ?></strong></label>
     462                                <input name="fx_buffer" id="gamerhub_fx_buffer" type="number" step="0.1" min="0" class="small-text" value="<?php echo esc_attr($fx_buffer); ?>" /> %
     463                                <span class="description"><?php esc_html_e('added on top of the rate', 'gamerhub-for-woocommerce'); ?></span>
     464                            </p>
     465                            <p class="description">
     466                                <?php esc_html_e('Your prices stay as they are until the next sync, but exchange rates move every day. This small extra covers that drift — without it, a rate moving the wrong way comes straight out of your profit. 2% is a sensible starting point.', 'gamerhub-for-woocommerce'); ?>
     467                            </p>
     468
     469                            <p style="margin-top:10px;">
     470                                <label for="gamerhub_price_rounding"><strong><?php esc_html_e('Round prices up to', 'gamerhub-for-woocommerce'); ?></strong></label>
     471                                <select name="price_rounding" id="gamerhub_price_rounding">
     472                                    <option value="0"    <?php selected((float) $price_rounding, 0.0); ?>><?php esc_html_e('Off (exact price)', 'gamerhub-for-woocommerce'); ?></option>
     473                                    <option value="0.5"  <?php selected((float) $price_rounding, 0.5); ?>>0.50</option>
     474                                    <option value="1"    <?php selected((float) $price_rounding, 1.0); ?>>1</option>
     475                                    <option value="5"    <?php selected((float) $price_rounding, 5.0); ?>>5</option>
     476                                    <option value="10"   <?php selected((float) $price_rounding, 10.0); ?>>10</option>
     477                                    <option value="100"  <?php selected((float) $price_rounding, 100.0); ?>>100</option>
     478                                </select>
     479                                <span class="description"><?php esc_html_e('with 1: 1,247.83 becomes 1,248 — with 100: it becomes 1,300', 'gamerhub-for-woocommerce'); ?></span>
     480                            </p>
     481                            <p class="description" style="max-width:640px;">
     482                                <?php esc_html_e('This tidies up EVERY price the plugin writes, not only converted ones. Rounding is always upward, never down, so it can never push a price below what you paid. Leave it on “Off (exact price)” unless you want round numbers.', 'gamerhub-for-woocommerce'); ?>
     483                            </p>
     484
     485                            <label style="display:inline-block;margin-top:6px;">
     486                                <?php // Pre-ticked once the mode is already saved, otherwise saving any other
     487                                      // field on this page would silently drop the shop back to Pause. ?>
     488                                <input type="checkbox" name="currency_ack" value="1" <?php checked($currency_mode, 'ignore'); ?> />
     489                                <?php
     490                                printf(
     491                                    /* translators: 1: GamerHub account currency, 2: WooCommerce store currency */
     492                                    esc_html__('I understand: with “no conversion” the GamerHub cost number is copied straight into my shop price, with no exchange rate. A cost of 2.50 %1$s becomes a price of 2.50 %2$s. Products that price themselves can then sell for less than I pay.', 'gamerhub-for-woocommerce'),
     493                                    esc_html($cApi),
     494                                    esc_html($cWc)
     495                                );
     496                                ?>
     497                            </label>
     498                            <p class="description">
     499                                <?php esc_html_e('This box must stay ticked while “no conversion” is chosen. If you untick it and save, the setting goes back to Pause and nothing will be imported or repriced. Only choose “no conversion” if you type every price by hand.', 'gamerhub-for-woocommerce'); ?>
     500                            </p>
    253501                        </td>
    254502                    </tr>
    … …  
    260508            <div class="gamerhub-card">
    261509                <h2><?php esc_html_e('2 · Test the connection', 'gamerhub-for-woocommerce'); ?></h2>
    262                 <p class="description"><?php esc_html_e('Confirms your credentials and that this server\'s IP is whitelisted in the portal.', 'gamerhub-for-woocommerce'); ?></p>
     510                <p class="description" style="max-width:640px;"><?php esc_html_e('Checks that your keys work and that GamerHub accepts requests from this website. It also shows your GamerHub balance and which currency GamerHub charges you in — that is the answer you need for Currency handling above. If it fails, the message shows the exact address you must add to the API Key page in your GamerHub account.', 'gamerhub-for-woocommerce'); ?></p>
    263511                <p>
    264512                    <button type="button" class="button" id="gamerhub-test-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Test connection', 'gamerhub-for-woocommerce'); ?></button>
    … …  
    269517            <div class="gamerhub-card">
    270518                <h2><?php esc_html_e('3 · Products', 'gamerhub-for-woocommerce'); ?></h2>
    271                 <p class="description"><?php esc_html_e('Your catalog is saved here — no need to reload each visit. Click a game name to see its SKU prices, use the row buttons to Import / Re-sync / Remove, or tick several and act in bulk.', 'gamerhub-for-woocommerce'); ?></p>
     519                <p class="description" style="max-width:760px;"><?php esc_html_e('This is the list of games GamerHub can sell for you, saved here so it loads instantly each visit. Press Prices on any row to see that game’s top-up amounts, what you pay and what the buyer pays. Press “Add to my shop” to start selling one. To handle many games at once, tick their boxes and use the buttons above the list.', 'gamerhub-for-woocommerce'); ?></p>
    272520                <p>
    273                     <button type="button" class="button" id="gamerhub-synccat-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Sync catalog', 'gamerhub-for-woocommerce'); ?></button>
     521                    <button type="button" class="button" id="gamerhub-synccat-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Get the game list', 'gamerhub-for-woocommerce'); ?></button>
    274522                    <span id="gamerhub-synccat-result" class="gamerhub-result"></span>
    275523                    <span class="gamerhub-last-sync" id="gamerhub-catalog-time">
    … …  
    279527                            echo esc_html(sprintf(__('· catalog synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($catalog_at)));
    280528                        } else {
    281                             esc_html_e('· not synced yet — click Sync catalog', 'gamerhub-for-woocommerce');
     529                            esc_html_e('· no games loaded yet — press “Get the game list”', 'gamerhub-for-woocommerce');
    282530                        }
    283531                        ?>
    … …  
    296544                        <button type="button" class="button button-primary" id="gamerhub-sync-btn"><?php esc_html_e('Import selected', 'gamerhub-for-woocommerce'); ?></button>
    297545                        <button type="button" class="button" id="gamerhub-bulk-btn"><?php esc_html_e('Bulk edit', 'gamerhub-for-woocommerce'); ?></button>
    298                         <button type="button" class="button" id="gamerhub-remove-btn"><?php esc_html_e('Remove selected', 'gamerhub-for-woocommerce'); ?></button>
     546                        <button type="button" class="button" id="gamerhub-remove-btn"><?php esc_html_e('Delete ticked from shop', 'gamerhub-for-woocommerce'); ?></button>
    299547                        <span id="gamerhub-sync-result" class="gamerhub-result"></span>
    300548                        <input type="search" id="gamerhub-cat-search" class="gamerhub-cat-search" placeholder="<?php esc_attr_e('Search games…', 'gamerhub-for-woocommerce'); ?>" autocomplete="off" style="margin-left:auto;min-width:220px;" />
    … …  
    359607
    360608                    <div id="gamerhub-bulk-panel" class="gamerhub-bulk-panel" style="display:none;">
    361                         <strong><?php esc_html_e('Bulk edit selected:', 'gamerhub-for-woocommerce'); ?></strong>
    362                         <label><?php esc_html_e('Markup:', 'gamerhub-for-woocommerce'); ?>
     609                        <strong><?php esc_html_e('Change all ticked games at once:', 'gamerhub-for-woocommerce'); ?></strong>
     610                        <span class="description" style="display:block;margin:2px 0 6px;"><?php esc_html_e('Changing the profit here changes the prices in your shop straight away. There is no undo. Games that are not in your shop yet are skipped.', 'gamerhub-for-woocommerce'); ?></span>
     611                        <label><?php esc_html_e('Profit:', 'gamerhub-for-woocommerce'); ?>
    363612                            <input type="number" id="gamerhub-bulk-mk-value" step="0.01" min="0" style="width:72px;" placeholder="<?php esc_attr_e('no change', 'gamerhub-for-woocommerce'); ?>" />
    364613                            <select id="gamerhub-bulk-mk-type">
    365                                 <option value="percent">%</option>
    366                                 <option value="fixed"><?php echo esc_html(get_woocommerce_currency_symbol()); ?></option>
     614                                <option value="percent"><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     615                                <option value="fixed"><?php
     616                                    /* translators: %s: store currency code, e.g. MYR */
     617                                    printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     618                                ?></option>
    367619                            </select>
    368620                        </label>
    … …  
    401653                                <th class="gamerhub-cb"></th>
    402654                                <th><?php esc_html_e('Game', 'gamerhub-for-woocommerce'); ?></th>
    403                                 <th><?php esc_html_e('Region', 'gamerhub-for-woocommerce'); ?></th>
    404                                 <th><?php esc_html_e('Type', 'gamerhub-for-woocommerce'); ?></th>
    405                                 <th><?php esc_html_e('Price', 'gamerhub-for-woocommerce'); ?></th>
    406                                 <th><?php esc_html_e('Status', 'gamerhub-for-woocommerce'); ?></th>
     655                                <?php // No price columns here on purpose: a whole game spans every one of its
     656                                      // amounts, so the range (USD 0.04 – 483.06) answers no question anyone
     657                                      // actually has. Press Prices on a row for the exact per-amount figures. ?>
     658                                <th title="<?php esc_attr_e('What you add on top. Set it here even before you add the game to your shop — it will be applied when you do.', 'gamerhub-for-woocommerce'); ?>"><?php esc_html_e('Your profit', 'gamerhub-for-woocommerce'); ?></th>
    407659                                <th><?php esc_html_e('Auto-sync', 'gamerhub-for-woocommerce'); ?></th>
    408660                                <th><?php esc_html_e('Actions', 'gamerhub-for-woocommerce'); ?></th>
    … …  
    416668            <div class="gamerhub-card">
    417669                <h2><?php esc_html_e('Your webhook URL', 'gamerhub-for-woocommerce'); ?></h2>
    418                 <p class="description"><?php esc_html_e('The plugin attaches this to every order automatically — nothing to configure in the portal. It must be reachable over public HTTPS.', 'gamerhub-for-woocommerce'); ?></p>
     670                <p class="description" style="max-width:760px;"><?php esc_html_e('This is the address GamerHub sends delivery confirmations to. The plugin sends it with every order, so there is nothing for you to set up here. It only works if your site is live on a working https:// address — on a local or test site GamerHub cannot reach it and your orders stay in Processing forever. (The Webhook Secret in section 1 is a different thing — that one you do copy from your GamerHub account.)', 'gamerhub-for-woocommerce'); ?></p>
    419671                <p><code class="gamerhub-copy"><?php echo esc_html($callback_url); ?></code></p>
    420672            </div>
    … …  
    422674            <div id="gamerhub-modal" class="gamerhub-modal" style="display:none;">
    423675                <div class="gamerhub-modal-box">
    424                     <h3><?php esc_html_e('Import as product', 'gamerhub-for-woocommerce'); ?></h3>
     676                    <h3><?php esc_html_e('Sell this one amount on its own', 'gamerhub-for-woocommerce'); ?></h3>
     677                    <p class="description" style="max-width:520px;"><?php esc_html_e('This creates a SECOND, separate listing in your shop for this single amount, with its own name and price. The game itself is not changed, and this new listing is never touched again by GamerHub — you keep it up to date yourself.', 'gamerhub-for-woocommerce'); ?></p>
    425678                    <p class="gamerhub-modal-sku"></p>
    426679                    <p>
    … …  
    429682                    </p>
    430683                    <p>
    431                         <label class="gamerhub-modal-autoprice-lbl"><input type="checkbox" id="gamerhub-modal-autoprice" checked /> <strong><?php esc_html_e('Auto-update price & stock from GamerHub', 'gamerhub-for-woocommerce'); ?></strong></label>
    432                         <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: the price follows GamerHub cost × your default markup and stock stays in sync (the Price below is ignored). Off: a fixed price you set (good for promos).', 'gamerhub-for-woocommerce'); ?></span>
     684                        <label class="gamerhub-modal-autoprice-lbl"><input type="checkbox" id="gamerhub-modal-autoprice" checked /> <strong><?php esc_html_e('Keep price and stock up to date from GamerHub (recommended)', 'gamerhub-for-woocommerce'); ?></strong></label>
     685                        <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('Ticked: GamerHub sets the price — what it costs you, plus your profit % — and marks it sold out when they run out. The Price box below is not used. Not ticked: your own price, frozen. If GamerHub raises the cost you sell at a loss, and if they run out your shop keeps taking orders you cannot deliver.', 'gamerhub-for-woocommerce'); ?></span>
    433686                    </p>
    434687                    <p>
    … …  
    468721                    <?php endif; ?>
    469722                    <p>
    470                         <label for="gamerhub-im-mk-value"><strong><?php esc_html_e('Markup', 'gamerhub-for-woocommerce'); ?></strong></label><br />
    471                         <input type="number" id="gamerhub-im-mk-value" step="0.01" min="0" style="width:90px;" placeholder="<?php esc_attr_e('global', 'gamerhub-for-woocommerce'); ?>" />
     723                        <label for="gamerhub-im-mk-value"><strong><?php esc_html_e('Profit', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     724                        <input type="number" id="gamerhub-im-mk-value" step="0.01" min="0" style="width:90px;" placeholder="<?php esc_attr_e('use default', 'gamerhub-for-woocommerce'); ?>" />
    472725                        <select id="gamerhub-im-mk-type">
    473                             <option value="percent">%</option>
    474                             <option value="fixed"><?php echo esc_html(get_woocommerce_currency_symbol()); ?></option>
     726                            <option value="percent"><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     727                            <option value="fixed"><?php
     728                                /* translators: %s: store currency code, e.g. MYR */
     729                                printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     730                            ?></option>
    475731                        </select>
    476732                    </p>
    477                     <p class="description"><?php esc_html_e('Leave blank for the default category & global markup. You can change these anytime with Bulk edit or by expanding the product.', 'gamerhub-for-woocommerce'); ?></p>
     733                    <p class="description" style="max-width:520px;"><?php esc_html_e('Leave these empty to use the default category and your Default profit %. Careful: if you type a profit here and some of the ticked games are already in your shop, this REPLACES the profit they had. You can change it later — tick the game and use “Change all ticked games at once”, or press Prices on its row.', 'gamerhub-for-woocommerce'); ?></p>
    478734                    <p class="gamerhub-modal-actions">
    479735                        <button type="button" class="button button-primary" id="gamerhub-im-confirm"><?php esc_html_e('Import', 'gamerhub-for-woocommerce'); ?></button>
  • gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-sync.php

    r3626288 r3630953  
    4343        add_action('admin_post_gamerhub_sync_one', [__CLASS__, 'handle_sync_one']);
    4444        add_action('admin_notices', [__CLASS__, 'row_action_notice']);
     45        add_action('admin_notices', [__CLASS__, 'currency_change_notice']);
    4546    }
    4647
    … …  
    5354        }
    5455
    55         // Store currency must match the GamerHub account currency (no FX here).
    56         $bal = Gamerhub_Client::get('/balance');
    57         if (Gamerhub_Client::ok($bal)) {
    58             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    59             $wc_cur  = strtoupper(get_woocommerce_currency());
    60             if ($api_cur !== '' && $api_cur !== $wc_cur) {
    61                 wp_send_json_error(['message' => sprintf(
    62                     /* translators: 1: store currency, 2: GamerHub currency */
    63                     __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s). Change it under WooCommerce → Settings → General, then load again.', 'gamerhub-for-woocommerce'),
    64                     $wc_cur,
    65                     $api_cur
    66                 )]);
    67             }
     56        // Currency gate — ALWAYS via currency_error(), never an inline comparison, so the owner's
     57        // "Currency handling" choice (convert / pause / no conversion) is honoured everywhere.
     58        if ($err = self::currency_error()) {
     59            wp_send_json_error(['message' => $err]);
    6860        }
    6961
    … …  
    143135        $state = get_transient(self::STATE);
    144136        if (! is_array($state)) {
    145             wp_send_json_error(['message' => __('Sync session expired — load the catalog again.', 'gamerhub-for-woocommerce')]);
     137            wp_send_json_error(['message' => __('This took too long and stopped. Nothing was damaged — the games already added are fine. Press “Get the game list” at the top, tick the ones you still need, and add them again.', 'gamerhub-for-woocommerce')]);
    146138        }
    147139
    … …  
    232224        }
    233225
    234         $labels = [];
     226        // Resolve the FINAL denomination label for every SKU up-front, de-duplicating exactly the way
     227        // the variation loop below does.
     228        //
     229        // These two lists MUST be built from the same computation. They used to diverge: the parent's
     230        // options were array_unique() of the RAW labels while a variation whose label collided got a
     231        // " (sku_code)" suffix — so the second SKU's attribute value did not exist among the parent's
     232        // options and WooCommerce could never match it. The variation was created, priced and in
     233        // stock, but no customer could select it, and nothing reported an error. That happens the
     234        // moment a supplier adds a second SKU sharing a denomination name to a product you already
     235        // imported, which is precisely what auto-sync is supposed to handle.
     236        $labelBySku = [];
     237        $seen_label = [];
    235238        foreach ($skus as $s) {
    236             $labels[] = self::denom_label($s);
    237         }
    238         $labels = array_values(array_unique($labels));
     239            $label = self::denom_label($s);
     240            if (isset($seen_label[$label])) {
     241                $label .= ' (' . (string) $s['code'] . ')';
     242            }
     243            $seen_label[$label]              = true;
     244            $labelBySku[(string) $s['code']] = $label;
     245        }
     246        $labels = array_values($labelBySku);
    239247
    240248        $attr = new WC_Product_Attribute();
    … …  
    266274        }
    267275
    268         // Per-product markup chosen at import → store on the parent. Blank = keep whatever is there,
    269         // so a markup set here (or per-variation below) survives re-sync + auto-sync price refreshes.
    270         if ($mk_value !== '') {
    271             update_post_meta($parent_id, '_gamerhub_markup_type', in_array($mk_type, ['percent', 'fixed'], true) ? $mk_type : 'percent');
    272             update_post_meta($parent_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_value));
    273         }
     276        // Per-product profit chosen at import → store on the parent. Blank = keep whatever is there,
     277        // so a profit set here (or per-variation below) survives re-sync + auto-sync price refreshes.
     278        //
     279        // A profit typed in the catalog table BEFORE the game was added lives in the pending option;
     280        // PROMOTE it here (an explicit import-dialog value still wins) and then drop the entry, so a
     281        // game that exists in the shop only ever has ONE source of truth for its profit.
     282        $mk_effective_type  = $mk_type;
     283        $mk_effective_value = $mk_value;
     284        if ($mk_effective_value === '' && $is_new) {
     285            $pending = self::pending_markup($code);
     286            if ($pending !== null) {
     287                $mk_effective_type  = $pending['type'];
     288                $mk_effective_value = (string) $pending['value'];
     289            }
     290        }
     291        if ($mk_effective_value !== '') {
     292            update_post_meta($parent_id, '_gamerhub_markup_type', in_array($mk_effective_type, ['percent', 'fixed'], true) ? $mk_effective_type : 'percent');
     293            update_post_meta($parent_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_effective_value));
     294        }
     295        self::clear_pending_markup($code);   // the product owns the profit from here on
    274296
    275297        // Brand chosen at import → assign it (by name: reuses an existing term or creates it).
    … …  
    283305
    284306        // ── Variations — matched by _gamerhub_sku_code ──
    285         $prices    = [];
    286         $seen_label = [];
    287         $present    = array_fill_keys(array_column($skus, 'code'), true); // sellable sku_codes this sync
     307        $prices  = [];
     308        $present = array_fill_keys(array_column($skus, 'code'), true); // sellable sku_codes this sync
    288309        foreach ($skus as $sku) {
    289310            $sku_code = (string) $sku['code'];
    290             $label    = self::denom_label($sku);
    291             // guard duplicate labels within one product (attribute options must be unique)
    292             if (isset($seen_label[$label])) {
    293                 $label .= ' (' . $sku_code . ')';
    294             }
    295             $seen_label[$label] = true;
     311            // The SAME label the parent's attribute options were built from — never recompute it here,
     312            // or the two can drift apart again and the variation becomes unselectable.
     313            $label    = $labelBySku[$sku_code] ?? self::denom_label($sku);
    296314
    297315            $cost = (float) $sku['price'];
    … …  
    347365        wc_delete_product_transients($parent_id);
    348366        update_post_meta($parent_id, '_gamerhub_synced_at', time());
     367        self::remember_priced_currency();   // prices were just written in the CURRENT store currency
    349368
    350369        return [
    … …  
    394413    // ── Anti-hammer helpers ─────────────────────────────────────────────
    395414
     415    /**
     416     * The transient key for a cached GET. Callers that bust the cache MUST go through this —
     417     * building the md5 by hand is how the force-refresh silently stopped matching once $query
     418     * was added to the key.
     419     */
     420    private static function cache_key(string $path, array $query = []): string
     421    {
     422        return 'gamerhub_c_' . md5($path . '|' . wp_json_encode($query));
     423    }
     424
    396425    /** Cached GET for catalog endpoints — repeated loads/syncs reuse the cache instead of re-hitting the API. */
    397     private static function cached_get(string $path, int $ttl = 600)
    398     {
    399         $key    = 'gamerhub_c_' . md5($path);
     426    private static function cached_get(string $path, int $ttl = 600, array $query = [])
     427    {
     428        $key    = self::cache_key($path, $query);
    400429        $cached = get_transient($key);
    401430        if (is_array($cached)) {
    402431            return $cached;
    403432        }
    404         $r = Gamerhub_Client::get($path);
     433        $r = Gamerhub_Client::get($path, $query);
    405434        if (Gamerhub_Client::ok($r)) {
    406435            set_transient($key, $r, $ttl);
    … …  
    409438    }
    410439
     440    /**
     441     * The conversion rate GamerHub publishes for THIS store's currency, via /balance.
     442     *
     443     * Rides the /balance call the plugin already makes (cached 5 min), so it costs no extra request.
     444     * The server only includes `rate` when we ask for it, so nothing changes for anyone who doesn't.
     445     *
     446     * @return array{value:float,age:int,to:string}|null  null when the server has no usable rate
     447     */
     448    public static function server_fx_rate(): ?array
     449    {
     450        $store = strtoupper((string) get_woocommerce_currency());
     451        if ($store === '') {
     452            return null;
     453        }
     454
     455        $bal = self::cached_get('/balance', 300, ['store_currency' => $store]);
     456        if (! Gamerhub_Client::ok($bal)) {
     457            return null;
     458        }
     459
     460        $rate = $bal['json']['data']['rate'] ?? null;
     461        if (! is_array($rate) || ! isset($rate['value'])) {
     462            return null;   // older API, or no rate for this pair
     463        }
     464
     465        $value = (float) $rate['value'];
     466        if ($value <= 0) {
     467            return null;
     468        }
     469
     470        // Age drives the staleness refusal upstream. An absent timestamp is treated as UNKNOWN age
     471        // (PHP_INT_MAX) so it can never pass a freshness check by omission.
     472        //
     473        // EXCEPT the identity rate: when the store currency equals the account currency the server
     474        // answers value = 1 with no timestamp, and a 1:1 rate cannot go stale — 1 USD is always
     475        // 1 USD. Calling that "infinitely old" would be wrong the moment this age is reused.
     476        $age = PHP_INT_MAX;
     477        if (! empty($rate['updated_at'])) {
     478            $ts = strtotime((string) $rate['updated_at']);
     479            if ($ts) {
     480                $age = max(0, time() - $ts);
     481            }
     482        } elseif (abs($value - 1.0) < 1e-9) {
     483            $age = 0;
     484        }
     485
     486        return ['value' => $value, 'age' => $age, 'to' => (string) ($rate['to'] ?? $store)];
     487    }
     488
    411489    /** Store currency must equal the GamerHub account currency (no FX on import). Returns an error message, or null if OK/unknown. */
    412490    public static function currency_error(): ?string
    413491    {
     492        $mis = self::currency_mismatch();
     493        if ($mis === null) {
     494            return null;   // currencies match, or we could not determine them
     495        }
     496
     497        $mode = gamerhub_currency_mode();
     498
     499        // 'ignore' — the owner has explicitly accepted responsibility for pricing.
     500        if ($mode === 'ignore') {
     501            return null;
     502        }
     503
     504        // 'convert' — allowed ONLY with a usable rate. No rate, or a stale automatic one, keeps
     505        // BLOCKING: the plugin must never write a raw foreign number while merely *claiming* to
     506        // convert, and must never price from a rate old enough to have drifted.
     507        if ($mode === 'convert') {
     508            if (gamerhub_effective_fx_rate() !== null) {
     509                return null;
     510            }
     511
     512            if (gamerhub_fx_source() === 'manual') {
     513                return sprintf(
     514                    /* translators: 1: GamerHub currency, 2: store currency */
     515                    __('You chose to type your own exchange rate, but the rate box is still empty, so nothing was imported and no price was changed. Open the GamerHub menu and type how many %2$s one %1$s is worth.', 'gamerhub-for-woocommerce'),
     516                    $mis['api'],
     517                    $mis['wc']
     518                );
     519            }
     520
     521            return sprintf(
     522                /* translators: 1: GamerHub currency, 2: store currency */
     523                __('GamerHub has no up-to-date %1$s → %2$s exchange rate right now, so no price was written — this protects you from pricing off a stale rate. Wait a few minutes and try again. If it keeps happening, open the GamerHub menu and choose “Type my own rate”.', 'gamerhub-for-woocommerce'),
     524                $mis['api'],
     525                $mis['wc']
     526            );
     527        }
     528
     529        return sprintf(
     530            /* translators: 1: store currency, 2: GamerHub currency */
     531            __('Your shop sells in %1$s but GamerHub charges you in %2$s, so nothing was imported and no price was changed. Open the GamerHub menu and, under “Currency handling”, choose “Convert %2$s prices into %1$s”.', 'gamerhub-for-woocommerce'),
     532            $mis['wc'],
     533            $mis['api']
     534        );
     535    }
     536
     537    /**
     538     * Remember which WooCommerce currency the catalog prices were last WRITTEN in.
     539     *
     540     * WHY THIS MATTERS MORE THAN THE RATE ITSELF: WooCommerce stores a price as a bare number and
     541     * labels it with whatever the store currency currently is. If the owner switches the store from
     542     * MYR to BDT after importing, every imported price keeps its number and silently changes meaning
     543     * — RM 11.75 becomes ৳11.75, roughly a tenth of its worth — with no error anywhere. Recording
     544     * the currency at write time is what lets priced_currency_warning() catch that.
     545     */
     546    public static function remember_priced_currency(): void
     547    {
     548        $settings = get_option(Gamerhub_Settings::OPTION, []);
     549        $settings = is_array($settings) ? $settings : [];
     550        $cur      = strtoupper((string) get_woocommerce_currency());
     551
     552        if ($cur !== '' && ($settings['priced_in_currency'] ?? '') !== $cur) {
     553            $settings['priced_in_currency'] = $cur;
     554            update_option(Gamerhub_Settings::OPTION, $settings);
     555        }
     556    }
     557
     558    /**
     559     * A warning when the store currency changed after prices were written, or null.
     560     *
     561     * @return array{was:string,now:string}|null
     562     */
     563    public static function priced_currency_warning(): ?array
     564    {
     565        $was = strtoupper((string) gamerhub_opt('priced_in_currency', ''));
     566        $now = strtoupper((string) get_woocommerce_currency());
     567
     568        if ($now === '') {
     569            return null;
     570        }
     571
     572        // FAST PATH FIRST. This runs on admin_notices, i.e. on every single admin page, and the
     573        // check below is an unbounded product query. Two option reads answer it for every store
     574        // whose currency has not changed — which is all of them, almost all of the time.
     575        if ($was !== '' && $was === $now) {
     576            return null;
     577        }
     578
     579        // Only meaningful if something is actually priced. (Reached only when the currency really
     580        // differs, or on the one-off self-arming pass below.)
     581        if (empty(self::imported_codes())) {
     582            return null;
     583        }
     584
     585        // SELF-ARMING for stores that imported BEFORE this tracking existed — i.e. every install
     586        // upgrading from an older version, which is exactly the group most exposed. We cannot know
     587        // what currency those prices were written in, so we adopt the current one as the baseline.
     588        // No warning this time (there is nothing to compare against yet), but any LATER change is
     589        // caught. Without this the protection would stay dormant until the next full sync.
     590        if ($was === '') {
     591            self::remember_priced_currency();
     592            return null;
     593        }
     594
     595        if ($was === $now) {
     596            return null;
     597        }
     598
     599        return ['was' => $was, 'now' => $now];
     600    }
     601
     602    /**
     603     * gamerhub_cost_to_store() for DISPLAY paths: returns null instead of throwing.
     604     *
     605     * A read-only screen must never take the whole panel down, but it must also never fall back to
     606     * the raw account-currency number — that is exactly the mislabelled figure this feature exists
     607     * to prevent. Null renders as an em dash.
     608     */
     609    private static function safeCostToStore(float $cost): ?float
     610    {
     611        try {
     612            return gamerhub_cost_to_store($cost);
     613        } catch (\Throwable $e) {
     614            return null;
     615        }
     616    }
     617
     618    /**
     619     * A cost expressed in the STORE currency, or null when that figure does not exist.
     620     *
     621     * Null both when there is nothing to convert (the two currencies are the same) and when we
     622     * CANNOT convert — no confirmed mismatch because /balance is unreachable, or no usable rate.
     623     *
     624     * The distinction matters: gamerhub_cost_to_store() deliberately no-ops when there is no
     625     * confirmed mismatch, so it hands back the untouched account-currency number. A caller that
     626     * prints that under the store's currency label produces exactly the mislabelled figure this
     627     * whole column exists to prevent — and one transient /balance failure is enough to cause it,
     628     * because the currency CODE beside it is read from the cache and still says USD. Callers must
     629     * render nothing at all rather than fall back.
     630     */
     631    private static function cost_in_store_currency(float $cost): ?float
     632    {
     633        if (! function_exists('gamerhub_current_pair') || gamerhub_current_pair() === '') {
     634            return null;
     635        }
     636        return self::safeCostToStore($cost);
     637    }
     638
     639    /**
     640     * The raw currency comparison, independent of how the store chose to handle it.
     641     *
     642     * @return array{wc:string,api:string}|null  null when they match or the API answer is unavailable
     643     */
     644    /**
     645     * The currency GamerHub charges THIS merchant in, '' when it cannot be determined.
     646     *
     647     * currency_mismatch() only reports the account currency when it DIFFERS from the store's, but
     648     * the admin screens need to label a cost figure with its real currency even when the two match.
     649     * Rides the same cached /balance call, so it costs no extra request.
     650     */
     651    public static function account_currency(): string
     652    {
    414653        $bal = self::cached_get('/balance', 300);
    415654        if (! Gamerhub_Client::ok($bal)) {
    416             return null; // can't determine → don't block
    417         }
    418         $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
     655            return '';
     656        }
     657        return strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
     658    }
     659
     660    public static function currency_mismatch(): ?array
     661    {
     662        $api_cur = self::account_currency();
    419663        $wc_cur  = strtoupper(get_woocommerce_currency());
    420         if ($api_cur !== '' && $api_cur !== $wc_cur) {
    421             return sprintf(
    422                 /* translators: 1: store currency, 2: GamerHub currency */
    423                 __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s).', 'gamerhub-for-woocommerce'),
    424                 $wc_cur,
    425                 $api_cur
    426             );
    427         }
    428         return null;
     664
     665        if ($api_cur === '' || $api_cur === $wc_cur) {
     666            return null;
     667        }
     668        return ['wc' => $wc_cur, 'api' => $api_cur];
    429669    }
    430670
    … …  
    460700    }
    461701
     702    // ─────────────────────────────────────────────────────────────────────────────────────────
     703    //  PENDING PROFIT — a profit % chosen for a game that is NOT in the shop yet.
     704    //
     705    //  The real profit lives in post meta on the WooCommerce product, so before a game is added
     706    //  there is nowhere to put it. This option holds it until then. On import it is PROMOTED to
     707    //  product meta and the entry is dropped, so there is never more than one source of truth for
     708    //  a game that exists in the shop.
     709    // ─────────────────────────────────────────────────────────────────────────────────────────
     710
     711    const PENDING_MK = 'gamerhub_pending_markup';
     712
     713    /** @return array<string,array{type:string,value:float}> */
     714    private static function pending_markup_all(): array
     715    {
     716        $all = get_option(self::PENDING_MK, []);
     717        return is_array($all) ? $all : [];
     718    }
     719
     720    /** @return array{type:string,value:float}|null */
     721    public static function pending_markup(string $code): ?array
     722    {
     723        $row = self::pending_markup_all()[$code] ?? null;
     724        if (! is_array($row) || ! isset($row['value']) || $row['value'] === '') {
     725            return null;
     726        }
     727        return [
     728            'type'  => (isset($row['type']) && $row['type'] === 'fixed') ? 'fixed' : 'percent',
     729            'value' => max(0, (float) $row['value']),
     730        ];
     731    }
     732
     733    /** Store (or, with a blank $value, clear) the pre-import profit for one game. */
     734    public static function set_pending_markup(string $code, string $type, string $value): void
     735    {
     736        $all = self::pending_markup_all();
     737        if ($value === '') {
     738            unset($all[$code]);
     739        } else {
     740            $all[$code] = [
     741                'type'  => $type === 'fixed' ? 'fixed' : 'percent',
     742                'value' => max(0, (float) $value),
     743            ];
     744        }
     745        update_option(self::PENDING_MK, $all, false);
     746    }
     747
     748    /** Drop one game's pending profit (called once the product owns it). */
     749    private static function clear_pending_markup(string $code): void
     750    {
     751        $all = self::pending_markup_all();
     752        if (array_key_exists($code, $all)) {
     753            unset($all[$code]);
     754            update_option(self::PENDING_MK, $all, false);
     755        }
     756    }
     757
     758    /** Keep the option from growing forever: drop entries for codes the catalog no longer lists. */
     759    private static function prune_pending_markup(array $valid_codes): void
     760    {
     761        $all = self::pending_markup_all();
     762        if (! $all) {
     763            return;
     764        }
     765        $keep = array_intersect_key($all, array_fill_keys($valid_codes, true));
     766        if (count($keep) !== count($all)) {
     767            update_option(self::PENDING_MK, $keep, false);
     768        }
     769    }
     770
     771    /**
     772     * The profit that will actually be applied to this game's amounts that have no override of
     773     * their own — i.e. gamerhub_apply_markup()'s fallback chain, at the product level.
     774     *
     775     * MUST mirror that chain exactly. A column that shows one number while the price is written
     776     * from another is worse than showing nothing.
     777     *
     778     * @return array{type:string,value:float,source:string}  source: product|pending|default
     779     */
     780    public static function effective_markup(string $code, int $parent_id = 0): array
     781    {
     782        if ($parent_id > 0) {
     783            $mk = gamerhub_markup_meta($parent_id);
     784            if ($mk !== null) {
     785                return $mk + ['source' => 'product'];
     786            }
     787            // Imported and no product-level profit → the global default. A pending entry must NOT
     788            // win here: the product exists, so product meta (or its absence) is authoritative.
     789            return ['type' => 'percent', 'value' => max(0, (float) gamerhub_opt('markup', '0')), 'source' => 'default'];
     790        }
     791
     792        $pending = self::pending_markup($code);
     793        if ($pending !== null) {
     794            return $pending + ['source' => 'pending'];
     795        }
     796        return ['type' => 'percent', 'value' => max(0, (float) gamerhub_opt('markup', '0')), 'source' => 'default'];
     797    }
     798
     799    /**
     800     * Parent ids that have at least one variation carrying its own profit override.
     801     *
     802     * Deliberately computed for ALL parents in ONE query: the catalog table renders a hint per row,
     803     * and doing this per row would load a full product object and re-read meta for every variation
     804     * of every imported game. 'id=>parent' is the whole point — it answers the question directly.
     805     *
     806     * @param int[] $parent_ids
     807     * @return array<int,true>
     808     */
     809    private static function sku_markup_override_parents(array $parent_ids): array
     810    {
     811        $parent_ids = array_values(array_filter(array_map('intval', $parent_ids)));
     812        if (! $parent_ids) {
     813            return [];
     814        }
     815        $rows = get_posts([
     816            'post_type'        => 'product_variation',
     817            'post_status'      => 'any',
     818            'numberposts'      => -1,
     819            'fields'           => 'id=>parent',
     820            'post_parent__in'  => $parent_ids,
     821            'meta_query'       => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin-only, one query for the whole table.
     822                ['key' => '_gamerhub_markup_value', 'value' => '', 'compare' => '!='],
     823            ],
     824        ]);
     825        return array_fill_keys(array_map('intval', array_values((array) $rows)), true);
     826    }
     827
    462828    /** code => WC product id for imported VARIABLE products (excludes manual/promo). */
    463829    public static function imported_map(): array
    … …  
    467833            ['key' => '_gamerhub_manual', 'compare' => 'NOT EXISTS'],
    468834        ]]);
     835        // 'fields' => 'ids' skips WP's meta priming, so the loop below would be one query per
     836        // product. Prime it in a single query instead.
     837        if ($ids) {
     838            update_meta_cache('post', $ids);
     839        }
     840
    469841        $map = [];
    470842        foreach ($ids as $id) {
    … …  
    490862    public static function refresh_imported(): void
    491863    {
    492         // Never mis-price on a currency mismatch.
    493         $bal = self::cached_get('/balance', 300);
    494         if (Gamerhub_Client::ok($bal)) {
    495             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    496             if ($api_cur !== '' && $api_cur !== strtoupper(get_woocommerce_currency())) {
    497                 gamerhub_log('auto-sync skipped: currency mismatch');
    498                 return;
    499             }
     864        // Never mis-price on a currency problem — but go through currency_error() so a store that
     865        // has chosen to convert (or to price manually) is NOT silently skipped forever. This ran on
     866        // a raw comparison before, which meant auto-sync did nothing at all, with no visible sign,
     867        // for any store whose currency differed — even one correctly set up to convert.
     868        if ($err = self::currency_error()) {
     869            gamerhub_log('auto-sync skipped: ' . $err);
     870            return;
    500871        }
    501872
    … …  
    6401011        $autoprice = get_post_meta($product_id, '_gamerhub_autoprice', true) === '1';
    6411012        if ($manual && ! $autoprice) {
    642             return ['ok' => false, 'msg' => __('Auto-update is off for this product — it keeps the fixed price you set.', 'gamerhub-for-woocommerce')];
     1013            // Correct, expected behaviour — returning ok=false painted it as a red error notice.
     1014            return ['ok' => true, 'msg' => __('Nothing to update — you chose a fixed price for this product, so GamerHub does not change it. To let GamerHub set the price, tick “Keep price and stock up to date from GamerHub” in the GamerHub box on this product.', 'gamerhub-for-woocommerce')];
    6431015        }
    6441016        // Never mis-price on a currency mismatch (same guard as every other import/sync path).
    … …  
    7081080    }
    7091081
     1082    /**
     1083     * Loud, undismissable warning when the store currency changed after prices were written.
     1084     *
     1085     * This is the single most damaging currency mistake available: every imported price keeps its
     1086     * number and silently changes meaning, so the whole catalog can end up on sale at a fraction of
     1087     * cost with nothing in the logs. Shown on every admin screen until it is resolved.
     1088     */
     1089    public static function currency_change_notice(): void
     1090    {
     1091        if (! current_user_can('manage_woocommerce')) {
     1092            return;
     1093        }
     1094        $w = self::priced_currency_warning();
     1095        if (! $w) {
     1096            return;
     1097        }
     1098
     1099        $url = admin_url('admin.php?page=gamerhub');
     1100
     1101        echo '<div class="notice notice-error"><p><strong>'
     1102            . esc_html__('GamerHub: your store currency changed after products were imported.', 'gamerhub-for-woocommerce')
     1103            . '</strong></p><p>'
     1104            . esc_html(sprintf(
     1105                /* translators: 1: old currency, 2: new currency */
     1106                __('Prices were written in %1$s but your store now sells in %2$s. Those product prices are the OLD numbers with a NEW label, so they are almost certainly wrong. Re-sync your products to reprice them before taking orders.', 'gamerhub-for-woocommerce'),
     1107                $w['was'],
     1108                $w['now']
     1109            ))
     1110            . '</p><p><a class="button button-primary" href="' . esc_url($url) . '">'
     1111            . esc_html__('Open GamerHub and re-import my products', 'gamerhub-for-woocommerce')
     1112            . '</a></p></div>';
     1113    }
     1114
    7101115    /** Admin notice after a single-product sync — status is carried in our own redirect's query args. */
    7111116    public static function row_action_notice(): void
    … …  
    7351140        }
    7361141
    737         $bal = Gamerhub_Client::get('/balance');
    738         if (Gamerhub_Client::ok($bal)) {
    739             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    740             $wc_cur  = strtoupper(get_woocommerce_currency());
    741             if ($api_cur !== '' && $api_cur !== $wc_cur) {
    742                 wp_send_json_error(['message' => sprintf(
    743                     /* translators: 1: store currency, 2: GamerHub currency */
    744                     __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s).', 'gamerhub-for-woocommerce'),
    745                     $wc_cur,
    746                     $api_cur
    747                 )]);
    748             }
     1142        // Same single gate as every other price-touching path (honours the currency mode).
     1143        if ($err = self::currency_error()) {
     1144            wp_send_json_error(['message' => $err]);
    7491145        }
    7501146
    … …  
    7651161        update_option('gamerhub_catalog', ['at' => time(), 'games' => $games]);
    7661162        // wipe cached detail so expanded prices are fresh next time
    767         delete_transient('gamerhub_c_' . md5('/products'));
     1163        delete_transient(self::cache_key('/products'));
     1164        // Drop parked profits for games GamerHub no longer lists, so the option cannot grow forever.
     1165        self::prune_pending_markup(array_column($games, 'code'));
    7681166
    7691167        wp_send_json_success(['at' => time(), 'count' => count($games), 'html' => self::render_catalog_rows()]);
    … …  
    7771175    }
    7781176
    779     /** Server-rendered table body: catalog rows + any delisted (imported-but-gone) rows. */
    780     public static function render_catalog_rows(): string
     1177    /**
     1178     * Server-rendered table body: catalog rows + any delisted (imported-but-gone) rows.
     1179     *
     1180     * @param string $only Render just this one game's row. Used by the inline editors, which would
     1181     *                     otherwise ship the entire table (hundreds of KB) on every keystroke-save.
     1182     */
     1183    public static function render_catalog_rows(string $only = ''): string
    7811184    {
    7821185        $cat   = self::get_catalog();
    7831186        $games = $cat['games'] ?? [];
     1187        if ($only !== '') {
     1188            $games = array_values(array_filter($games, static fn($g) => (string) ($g['code'] ?? '') === $only));
     1189        }
    7841190        $map   = self::imported_map();
    7851191        $btax  = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : '';
    7861192        $seen  = [];
    7871193
    788         // Cached retail price ranges (filled when a game's "Prices" panel is opened — no import needed).
    789         $priceCache = get_option('gamerhub_price_cache', []);
    790         $priceCache = is_array($priceCache) ? $priceCache : [];
    791         $curCode    = strtoupper(get_woocommerce_currency());
    792         $curSym     = html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8');
    793         $priceCell  = static function (string $code) use ($priceCache, $curCode, $curSym): string {
    794             $prc = $priceCache[$code] ?? null;
    795             if (is_array($prc) && isset($prc['min']) && (empty($prc['cur']) || strtoupper((string) $prc['cur']) === $curCode)) {
    796                 $min = (float) $prc['min'];
    797                 $max = isset($prc['max']) ? (float) $prc['max'] : $min;
    798                 return esc_html($curSym . ' ' . number_format($min, 2) . ($max > $min ? ' – ' . number_format($max, 2) : ''));
    799             }
    800             return '<span style="color:#c3c4c7;">—</span>';
     1194        $curCode = strtoupper(get_woocommerce_currency());
     1195
     1196        // The profit that will be applied — editable right here, whether or not the game is in the
     1197        // shop yet (a value set before import is parked and applied on import).
     1198        $globalMk  = max(0, (float) gamerhub_opt('markup', '0'));
     1199        $overrideP = self::sku_markup_override_parents(array_values($map));   // ONE query for the whole table
     1200        $mkCell    = static function (string $code, int $parentId) use ($curCode, $globalMk, $overrideP): string {
     1201            $eff       = self::effective_markup($code, $parentId);
     1202            $isSet     = $eff['source'] !== 'default';
     1203            $val       = $isSet ? (string) (0 + $eff['value']) : '';
     1204            $overrides = $parentId > 0 && isset($overrideP[$parentId]);
     1205
     1206            $out  = '<div class="gamerhub-mk-inline">';
     1207            $out .= '<input type="number" class="gamerhub-row-mk-value" step="0.01" min="0" value="' . esc_attr($val) . '"'
     1208                 . ' placeholder="' . esc_attr(number_format($globalMk, 2)) . '"'
     1209                 . ' title="' . esc_attr__('Leave empty to use your Default profit %.', 'gamerhub-for-woocommerce') . '" /> ';
     1210            $out .= '<select class="gamerhub-row-mk-type">'
     1211                 . '<option value="percent"' . selected($eff['type'], 'percent', false) . '>%</option>'
     1212                 . '<option value="fixed"' . selected($eff['type'], 'fixed', false) . '>+' . esc_html($curCode) . '</option>'
     1213                 . '</select> ';
     1214            $out .= '<button type="button" class="button gamerhub-save-row-mk" data-code="' . esc_attr($code) . '">'
     1215                 . esc_html__('Save', 'gamerhub-for-woocommerce') . '</button>';
     1216            $out .= '<span class="gamerhub-row-mk-result gamerhub-result"></span>';
     1217
     1218            if ($eff['source'] === 'pending') {
     1219                $out .= '<br /><span class="description" style="font-size:11px;">'
     1220                     . esc_html__('will apply when you add this game', 'gamerhub-for-woocommerce') . '</span>';
     1221            } elseif (! $isSet) {
     1222                $out .= '<br /><span class="description" style="font-size:11px;">'
     1223                     . esc_html__('using your default', 'gamerhub-for-woocommerce') . '</span>';
     1224            }
     1225            if ($overrides) {
     1226                $out .= '<br /><span class="description" style="font-size:11px;">'
     1227                     . esc_html__('some amounts have their own — see Prices', 'gamerhub-for-woocommerce') . '</span>';
     1228            }
     1229            return $out . '</div>';
    8011230        };
    8021231
    … …  
    8281257            <tr data-code="<?php echo esc_attr($code); ?>" data-type="<?php echo esc_attr($g['type']); ?>" data-region="<?php echo esc_attr($g['region']); ?>" data-imported="<?php echo $imported ? '1' : '0'; ?>" data-brand="<?php echo esc_attr($brandNames); ?>" data-cat="<?php echo esc_attr($catNames); ?>">
    8291258                <td class="gamerhub-cb"><input type="checkbox" class="gamerhub-pick" value="<?php echo esc_attr($code); ?>" /></td>
    830                 <td><strong><?php echo esc_html($g['name']); ?></strong><br><code style="font-size:11px;color:#787c82;"><?php echo esc_html($code); ?></code></td>
    831                 <td><?php echo esc_html($g['region']); ?></td>
    832                 <td><?php echo $g['type'] === 'voucher' ? '🎟' : '🎮'; ?></td>
    833                 <td class="gamerhub-price gamerhub-price-cell" data-code="<?php echo esc_attr($code); ?>"><span class="gamerhub-price-val"><?php echo $priceCell($code); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns esc_html'd text or a trusted static dash. ?></span> <a href="#" class="gamerhub-price-refresh" data-code="<?php echo esc_attr($code); ?>" title="<?php esc_attr_e('Refresh this game’s price', 'gamerhub-for-woocommerce'); ?>">&#8635;</a></td>
     1259                <?php // Region, Type and Status used to be three columns of near-empty text ("—", one
     1260                      // emoji, one badge) costing ~200px between them. Folded in here so the money
     1261                      // columns fit on screen without a horizontal scrollbar; both Region and Type
     1262                      // still have their own dropdown filter above the table. ?>
     1263                <td class="gamerhub-game-cell">
     1264                    <strong><?php echo esc_html($g['name']); ?></strong><br />
     1265                    <code><?php echo esc_html($code); ?></code>
     1266                    <span class="gamerhub-game-meta">
     1267                        <span title="<?php echo esc_attr($g['type'] === 'voucher' ? __('Voucher', 'gamerhub-for-woocommerce') : __('Game top-up', 'gamerhub-for-woocommerce')); ?>"><?php echo $g['type'] === 'voucher' ? '🎟' : '🎮'; ?></span>
     1268                        <?php if ($g['region'] !== '' && $g['region'] !== '—') : ?>
     1269                            · <?php echo esc_html($g['region']); ?>
     1270                        <?php endif; ?>
     1271                        ·
     1272                        <?php if ($imported) : ?>
     1273                            <a href="<?php echo esc_url($edit); ?>"><span class="gamerhub-badge ok"><?php esc_html_e('✓ In my shop', 'gamerhub-for-woocommerce'); ?></span></a>
     1274                        <?php else : ?>
     1275                            <span style="color:#8c8f94;"><?php esc_html_e('not added', 'gamerhub-for-woocommerce'); ?></span>
     1276                        <?php endif; ?>
     1277                    </span>
     1278                </td>
     1279                <td class="gamerhub-mk-row-cell"><?php echo $mkCell($code, $imported ? (int) $map[$code] : 0); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper escapes every interpolated value. ?></td>
    8341280                <td>
    8351281                    <?php if ($imported) : ?>
    836                         <a href="<?php echo esc_url($edit); ?>"><span class="gamerhub-badge ok"><?php esc_html_e('✓ Imported', 'gamerhub-for-woocommerce'); ?></span></a>
    837                     <?php else : ?>
    838                         <span style="color:#8c8f94;"><?php esc_html_e('Not imported', 'gamerhub-for-woocommerce'); ?></span>
    839                     <?php endif; ?>
    840                 </td>
    841                 <td>
    842                     <?php if ($imported) : ?>
    843                         <label class="gamerhub-autosync-lbl" title="<?php esc_attr_e('Auto-refresh this product’s price & stock in the background', 'gamerhub-for-woocommerce'); ?>"><input type="checkbox" class="gamerhub-autosync-toggle" data-code="<?php echo esc_attr($code); ?>" <?php checked($autosyncOn); ?> /> <?php esc_html_e('On', 'gamerhub-for-woocommerce'); ?></label>
     1282                        <label class="gamerhub-autosync-lbl" title="<?php esc_attr_e('Ticked: GamerHub keeps this price and its sold-out status up to date by itself, and replaces any price or name you typed by hand. Not ticked: the price AND the stock stay frozen, even when GamerHub changes the cost or runs out.', 'gamerhub-for-woocommerce'); ?>"><input type="checkbox" class="gamerhub-autosync-toggle" data-code="<?php echo esc_attr($code); ?>" <?php checked($autosyncOn); ?> /> <?php esc_html_e('Keep updated', 'gamerhub-for-woocommerce'); ?></label>
    8441283                        <?php if ($syncedAt) : ?>
    8451284                            <br /><span class="gamerhub-synced-at" title="<?php echo esc_attr(date_i18n('Y-m-d H:i', $syncedAt)); ?>"><?php
    … …  
    8531292                </td>
    8541293                <td>
    855                     <button type="button" class="button gamerhub-expand" data-code="<?php echo esc_attr($code); ?>" aria-expanded="false" title="<?php esc_attr_e('Show denominations & prices', 'gamerhub-for-woocommerce'); ?>"><span class="gamerhub-caret" aria-hidden="true">&#9656;</span> <?php esc_html_e('Prices', 'gamerhub-for-woocommerce'); ?></button>
    856                     <button type="button" class="button gamerhub-import-one" data-code="<?php echo esc_attr($code); ?>"><?php echo $imported ? esc_html__('Re-sync', 'gamerhub-for-woocommerce') : esc_html__('Import', 'gamerhub-for-woocommerce'); ?></button>
     1294                    <button type="button" class="button gamerhub-expand" data-code="<?php echo esc_attr($code); ?>" aria-expanded="false" title="<?php esc_attr_e('Show this game’s top-up amounts, what you pay and what the buyer pays', 'gamerhub-for-woocommerce'); ?>"><span class="gamerhub-caret" aria-hidden="true">&#9656;</span> <?php esc_html_e('Prices', 'gamerhub-for-woocommerce'); ?></button>
     1295                    <button type="button" class="button gamerhub-import-one" data-code="<?php echo esc_attr($code); ?>"><?php echo $imported ? esc_html__('Update prices now', 'gamerhub-for-woocommerce') : esc_html__('Add to my shop', 'gamerhub-for-woocommerce'); ?></button>
    8571296                    <?php if ($imported) : ?>
    858                         <button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Remove', 'gamerhub-for-woocommerce'); ?></button>
     1297                        <button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Delete from shop', 'gamerhub-for-woocommerce'); ?></button>
    8591298                    <?php endif; ?>
    8601299                </td>
    8611300            </tr>
    862             <tr class="gamerhub-detail-row" data-code="<?php echo esc_attr($code); ?>" style="display:none;"><td colspan="8" class="gamerhub-detail-cell"></td></tr>
     1301            <tr class="gamerhub-detail-row" data-code="<?php echo esc_attr($code); ?>" style="display:none;"><td colspan="5" class="gamerhub-detail-cell"></td></tr>
    8631302            <?php
    8641303        }
    … …  
    8681307            if (isset($seen[$code])) {
    8691308                continue;
     1309            }
     1310            if ($only !== '' && $code !== $only) {
     1311                continue;   // single-row render must return exactly one row
    8701312            }
    8711313            $edit     = admin_url('post.php?post=' . (int) $pid . '&action=edit');
    … …  
    8741316            <tr data-code="<?php echo esc_attr($code); ?>" data-type="" data-region="—" data-imported="1" data-brand="" data-cat="" style="background:rgba(201,40,63,.05);">
    8751317                <td class="gamerhub-cb"></td>
    876                 <td><a href="<?php echo esc_url($edit); ?>"><strong><?php echo esc_html($code); ?></strong></a></td>
     1318                <td class="gamerhub-game-cell">
     1319                    <a href="<?php echo esc_url($edit); ?>"><strong><?php echo esc_html($code); ?></strong></a>
     1320                    <span class="gamerhub-game-meta"><span class="gamerhub-badge err"><?php esc_html_e('⚠ GamerHub stopped selling this', 'gamerhub-for-woocommerce'); ?></span></span>
     1321                    <br /><span class="description"><?php esc_html_e('Still in your shop. Orders for it will fail — delete it.', 'gamerhub-for-woocommerce'); ?></span>
     1322                </td>
    8771323                <td>—</td>
    878                 <td>—</td>
    879                 <td>—</td>
    880                 <td><span class="gamerhub-badge err"><?php esc_html_e('⚠ Delisted', 'gamerhub-for-woocommerce'); ?></span></td>
    8811324                <td><?php if ($syncedAt) { echo esc_html(sprintf(/* translators: %s: time-ago */ __('synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($syncedAt))); } else { echo '—'; } ?></td>
    882                 <td><button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Remove', 'gamerhub-for-woocommerce'); ?></button></td>
     1325                <td><button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Delete from shop', 'gamerhub-for-woocommerce'); ?></button></td>
    8831326            </tr>
    8841327            <?php
    … …  
    9001343        }
    9011344        $skus     = (array) ($r['json']['data']['skus'] ?? []);
    902         $markup   = (float) gamerhub_opt('markup', '0');
    9031345        $parentId = self::imported_parent_id($code);
    9041346        $vidBySku = [];
    … …  
    9111353            }
    9121354        }
     1355
     1356        // Costs are collected separately from retails and are NEVER converted: the Cost column is
     1357        // labelled with the ACCOUNT currency on purpose, so the owner can see the real GamerHub
     1358        // charge. It therefore stays available even when no exchange rate exists.
     1359        $costs   = [];
    9131360        $retails = [];
     1361        $priced  = true;
    9141362        foreach ($skus as $s) {
    9151363            if (empty($s['code']) || ! isset($s['price']) || $s['price'] === null || $s['price'] === '') {
    9161364                continue;
    9171365            }
    918             $cost      = (float) $s['price'];
    919             $sc        = (string) $s['code'];
    920             $retails[] = isset($vidBySku[$sc]) ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId) : round($cost * (1 + $markup / 100), 2);
    921         }
    922         return $retails ? ['min' => min($retails), 'max' => max($retails)] : null;
     1366            $cost    = (float) $s['price'];
     1367            $sc      = (string) $s['code'];
     1368            $costs[] = $cost;
     1369            if (! $priced) {
     1370                continue;
     1371            }
     1372            try {
     1373                $retails[] = isset($vidBySku[$sc])
     1374                    ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId)
     1375                    : self::preview_markup($code, $cost);
     1376            } catch (\Throwable $e) {
     1377                // No honest store-currency figure available → drop the retail range rather than show
     1378                // a number in the wrong currency. The cost range is still perfectly valid.
     1379                $retails = [];
     1380                $priced  = false;
     1381            }
     1382        }
     1383
     1384        if (! $costs) {
     1385            return null;
     1386        }
     1387        return [
     1388            'min'  => $retails ? min($retails) : null,
     1389            'max'  => $retails ? max($retails) : null,
     1390            'cmin' => min($costs),
     1391            'cmax' => max($costs),
     1392            'ccur' => self::account_currency(),
     1393        ];
     1394    }
     1395
     1396    /**
     1397     * The retail a NOT-yet-imported game would get: cost + whatever profit is parked for it.
     1398     *
     1399     * gamerhub_apply_markup() reads profit from post meta, which does not exist before import, so
     1400     * a pending profit has to be applied here. Same maths, same floor, same rounding — the preview
     1401     * must not be able to disagree with the price that is eventually written.
     1402     */
     1403    private static function preview_markup(string $code, float $cost): float
     1404    {
     1405        $pending = self::pending_markup($code);
     1406        if ($pending === null) {
     1407            return gamerhub_apply_markup($cost);
     1408        }
     1409        $c     = gamerhub_cost_to_store($cost);
     1410        $price = $pending['type'] === 'fixed' ? ($c + $pending['value']) : ($c * (1 + $pending['value'] / 100));
     1411        return gamerhub_round_price(max($price, $c));
     1412    }
     1413
     1414    /** Write (or drop) one game's cached ranges. $range comes from compute_price_range(). */
     1415    private static function cache_price_range(string $code, ?array $range): void
     1416    {
     1417        $cache = get_option('gamerhub_price_cache', []);
     1418        if (! is_array($cache)) {
     1419            $cache = [];
     1420        }
     1421        if ($range === null) {
     1422            unset($cache[$code]);
     1423        } else {
     1424            $cache[$code] = [
     1425                'min'  => $range['min'],
     1426                'max'  => $range['max'],
     1427                'cmin' => $range['cmin'] ?? null,
     1428                'cmax' => $range['cmax'] ?? null,
     1429                'ccur' => $range['ccur'] ?? '',
     1430                'cur'  => get_woocommerce_currency(),
     1431                'at'   => time(),
     1432            ];
     1433        }
     1434        update_option('gamerhub_price_cache', $cache, false);
     1435    }
     1436
     1437    /** Recompute + re-cache one game's ranges. Cheap: /products/{code} is itself cached. */
     1438    private static function refresh_price_cache(string $code): ?array
     1439    {
     1440        $range = self::compute_price_range($code);
     1441        self::cache_price_range($code, $range);
     1442        return $range;
    9231443    }
    9241444
    … …  
    9381458            wp_send_json_error(['message' => 'no code']);
    9391459        }
    940         delete_transient('gamerhub_c_' . md5('/products/' . rawurlencode($code))); // force a live fetch
    941         $range = self::compute_price_range($code);
    942         $cache = get_option('gamerhub_price_cache', []);
    943         if (! is_array($cache)) {
    944             $cache = [];
    945         }
    946         if ($range) {
    947             $cache[$code] = ['min' => $range['min'], 'max' => $range['max'], 'cur' => get_woocommerce_currency(), 'at' => time()];
    948         } else {
    949             unset($cache[$code]);
    950         }
    951         update_option('gamerhub_price_cache', $cache, false);
    952         wp_send_json_success(['code' => $code, 'price_min' => $range['min'] ?? null, 'price_max' => $range['max'] ?? null]);
     1460        delete_transient(self::cache_key('/products/' . rawurlencode($code))); // force a live fetch
     1461        $range = self::refresh_price_cache($code);
     1462        wp_send_json_success([
     1463            'code'          => $code,
     1464            'price_min'     => $range['min']  ?? null,
     1465            'price_max'     => $range['max']  ?? null,
     1466            'cost_min'      => $range['cmin'] ?? null,
     1467            'cost_max'      => $range['cmax'] ?? null,
     1468            'cost_cur'      => $range['ccur'] ?? '',
     1469            // The same cost in the shop's currency, so the table can show both lines. null when
     1470            // there is no usable rate — the account-currency figure above is still the truth.
     1471            'cost_min_conv' => isset($range['cmin']) ? self::cost_in_store_currency((float) $range['cmin']) : null,
     1472            'cost_max_conv' => isset($range['cmax']) ? self::cost_in_store_currency((float) $range['cmax']) : null,
     1473            'store_cur'     => get_woocommerce_currency(),
     1474        ]);
    9531475    }
    9541476
    … …  
    10011523            $sc   = (string) $s['code'];
    10021524            $cost = (isset($s['price']) && $s['price'] !== null && $s['price'] !== '') ? (float) $s['price'] : null;
     1525            $src  = $cost;   // the ACCOUNT-currency figure, shown as-is under its own currency label
    10031526            if ($cost === null) {
    10041527                $retail = null;
    1005             } elseif (isset($vidBySku[$sc])) {
    1006                 $retail = gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId); // real applied price
    10071528            } else {
    1008                 $retail = round($cost * (1 + $markup / 100), 2); // preview at global markup
     1529                // A throw here means "no honest store-currency figure". Show nothing rather than a
     1530                // number in the wrong currency — the panel renders an em dash for null. The
     1531                // account-currency cost in $src stays valid either way and is still shown.
     1532                try {
     1533                    $retail = isset($vidBySku[$sc])
     1534                        ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId)   // real applied price
     1535                        : self::preview_markup($code, $cost);                        // preview at the profit this game would get
     1536                } catch (\Throwable $e) {
     1537                    $retail = null;
     1538                    $cost   = null;   // never show a raw account-currency cost under the store label
     1539                }
    10091540            }
    10101541            $skus[] = [
    10111542                'code'     => $sc,
    10121543                'name'     => (string) ($s['name'] ?? $sc),
    1013                 'cost'     => $cost,
     1544                // Convert the cost too. The admin renders Cost and Retail side by side under ONE
     1545                // currency symbol (the STORE's), but the API quotes cost in the ACCOUNT currency —
     1546                // so sending it raw printed a USD figure labelled BDT, right next to a converted
     1547                // retail figure. Two different currencies in adjacent columns, no warning.
     1548                // null when this SKU has no sellable price — must stay null, not become 0.0
     1549                'cost'     => $cost === null ? null : self::cost_in_store_currency($cost),
     1550                // What GamerHub actually charges, in GamerHub's own currency, unconverted. Shown
     1551                // next to the converted figure so the owner can always see the real charge.
     1552                'cost_src' => $src,
    10141553                'retail'   => $retail,
    10151554                'stock'    => (string) ($s['stock_status'] ?? 'in_stock'),
    … …  
    10191558        }
    10201559
    1021         // Cache the retail price RANGE so the catalog list's Price column can show it WITHOUT importing.
    1022         // Stored in its own option so it survives a catalog re-sync; keyed by code, with the currency it
    1023         // was priced in (the list only shows it while that matches the store currency).
    1024         $retails   = array_values(array_filter(array_column($skus, 'retail'), static fn($v) => $v !== null));
    1025         $price_min = $retails ? min($retails) : null;
    1026         $price_max = $retails ? max($retails) : null;
    1027         $cache     = get_option('gamerhub_price_cache', []);
    1028         if (! is_array($cache)) {
    1029             $cache = [];
    1030         }
    1031         if ($price_min !== null) {
    1032             $cache[$code] = ['min' => $price_min, 'max' => $price_max, 'cur' => get_woocommerce_currency(), 'at' => time()];
    1033         } else {
    1034             unset($cache[$code]);
    1035         }
    1036         update_option('gamerhub_price_cache', $cache, false);
     1560        // Cache both RANGES so the catalog list's "You pay" / "Buyer pays" columns can show them
     1561        // WITHOUT importing. Stored in its own option so it survives a catalog re-sync; keyed by
     1562        // code, tagged with the currency each figure is in.
     1563        $retails = array_values(array_filter(array_column($skus, 'retail'), static fn($v) => $v !== null));
     1564        $costs   = array_values(array_filter(array_column($skus, 'cost_src'), static fn($v) => $v !== null));
     1565        self::cache_price_range($code, $costs ? [
     1566            'min'  => $retails ? min($retails) : null,
     1567            'max'  => $retails ? max($retails) : null,
     1568            'cmin' => min($costs),
     1569            'cmax' => max($costs),
     1570            'ccur' => self::account_currency(),
     1571        ] : null);
    10371572
    10381573        $input_form = [];
    … …  
    10661601        }
    10671602
     1603        // A not-yet-imported game has no product meta, so its profit editor is pre-filled from the
     1604        // pending store instead. Both end up in the same field, so the JS needs no special case.
     1605        if (! $parentId) {
     1606            $pend = self::pending_markup($code);
     1607            if ($pend !== null) {
     1608                $product_mk = ['value' => (string) $pend['value'], 'type' => $pend['type']];
     1609            }
     1610        }
     1611
    10681612        wp_send_json_success([
    10691613            'code'           => $code,
    10701614            'currency'       => get_woocommerce_currency(),
     1615            'cost_currency'  => self::account_currency(),
    10711616            'imported'       => (bool) $parentId,
    10721617            'product_markup' => $product_mk,
    … …  
    10751620            'input_form'     => $input_form,
    10761621            'field_map'      => $field_map,
    1077             'price_min'      => $price_min,
    1078             'price_max'      => $price_max,
     1622            'price_min'      => $retails ? min($retails) : null,
     1623            'price_max'      => $retails ? max($retails) : null,
    10791624        ]);
    10801625    }
    … …  
    11691714        }
    11701715
    1171         $retail = gamerhub_apply_markup((float) $sku['price'], 0, 0); // cost × default markup, floored at cost
     1716        // cost × default markup, floored at cost. Throws if a conversion is needed but no honest rate
     1717        // exists — refuse the product rather than create one at an account-currency price.
     1718        try {
     1719            $retail = gamerhub_apply_markup((float) $sku['price'], 0, 0);
     1720        } catch (\Throwable $e) {
     1721            wp_send_json_error(['message' => $e->getMessage()]);
     1722        }
    11721723
    11731724        // Auto-update price & stock (default ON): the product tracks GamerHub cost like a normal import,
    … …  
    13101861                continue;
    13111862            }
     1863            // A missing/stale exchange rate throws here, and that is deliberately allowed to
     1864            // propagate: stopping the whole reprice is safer than leaving half the variations
     1865            // priced in the old currency. Every caller wraps this.
    13121866            $retail = gamerhub_apply_markup($costBySku[$sc], (int) $vid, $parentId);
    13131867            $var    = wc_get_product($vid);
    … …  
    13401894        }
    13411895        $parentId = self::imported_parent_id($code);
     1896
     1897        // NOT IN THE SHOP YET — a game-level profit is still worth accepting: park it and apply it
     1898        // the moment the game is added. Nothing is priced here, so no currency guard is needed and
     1899        // no WooCommerce product is touched.
    13421900        if (! $parentId) {
    1343             wp_send_json_error(['message' => __('Import this product first, then set markup.', 'gamerhub-for-woocommerce')]);
    1344         }
     1901            if ($sku_code !== '') {
     1902                wp_send_json_error(['message' => __('Add this game to your shop first, then you can set a profit for one single amount.', 'gamerhub-for-woocommerce')]);
     1903            }
     1904            self::set_pending_markup($code, $mk_type, $mk_value);
     1905            wp_send_json_success([
     1906                'pending'  => true,
     1907                'skus'     => [],
     1908                'currency' => get_woocommerce_currency(),
     1909                'html'     => self::render_catalog_rows($code),
     1910                'message'  => $mk_value === ''
     1911                    ? __('Cleared — this game will use your Default profit % when you add it.', 'gamerhub-for-woocommerce')
     1912                    : __('Saved. It will be applied when you add this game to your shop.', 'gamerhub-for-woocommerce'),
     1913            ]);
     1914        }
     1915
    13451916        if ($err = self::currency_error()) {
    13461917            wp_send_json_error(['message' => $err]);
    … …  
    13641935            wp_send_json_error(['message' => $e->getMessage()]);
    13651936        }
    1366         wp_send_json_success(['skus' => $retail, 'currency' => get_woocommerce_currency()]);
     1937        // Send the refreshed row too, so the Profit cell's hint ("using your default" /
     1938        // "will apply when you add this game") moves with the edit.
     1939        // NB: no price re-compute here any more — the catalog table shows no prices, so recomputing
     1940        // the cached range on every save was pure waste. Prices come live from the Prices panel.
     1941        wp_send_json_success([
     1942            'skus'     => $retail,
     1943            'currency' => get_woocommerce_currency(),
     1944            'html'     => self::render_catalog_rows($code),
     1945        ]);
    13671946    }
    13681947
    … …  
    14131992        $btax       = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : '';
    14141993
    1415         $n = 0;
     1994        $n      = 0;
     1995        $failed = 0;   // profit saved but the price could not be rewritten — must not be reported as changed
    14161996        foreach ($codes as $code) {
    14171997            $pid = self::imported_parent_id($code);
    … …  
    14392019                } catch (\Throwable $e) {
    14402020                    gamerhub_log('bulk reprice failed', ['code' => $code, 'error' => $e->getMessage()]);
     2021                    $failed++;
     2022                    continue;   // the profit is stored, but no price moved — do not count it as changed
    14412023                }
    14422024            }
    14432025            $n++;
    14442026        }
    1445         wp_send_json_success(['updated' => $n, 'html' => self::render_catalog_rows()]);
     2027        wp_send_json_success([
     2028            'updated' => $n,
     2029            'failed'  => $failed,
     2030            // Bulk edit touches many rows, so this one really does re-render the whole table.
     2031            // ($code here is only the loop leftover — never pass it as a single-row filter.)
     2032            'html'    => self::render_catalog_rows(),
     2033        ]);
    14462034    }
    14472035
  • gamerhub-for-woocommerce/tags/1.2.0/readme.txt

    r3626288 r3630953  
    77WC requires at least: 7.1
    88WC tested up to: 10.9
    9 Stable tag: 1.1.0
     9Stable tag: 1.2.0
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1212
    13 Sell game top-ups & vouchers through GamerHub. Orders auto-fulfil on payment and sync status back via a signed webhook.
     13Sell game top-ups and vouchers in your WooCommerce shop. When a customer pays, GamerHub delivers automatically and the order completes on its own.
    1414
    1515== Description ==
    … …  
    1717Connects a WooCommerce store to the GamerHub merchant API and turns it into a fully automated game top-up / voucher shop.
    1818
    19 * **One-click catalog sync** - every active GamerHub product becomes a WooCommerce product (variable product with one variation per denomination), priced at your cost plus a markup you set.
    20 * **Your prices, your way** - set a default markup %, then fine-tune it per product or per denomination right in the catalog, with filters and bulk edit. Retail is never allowed below your GamerHub cost.
    21 * **Player ID / Server fields** on the product page, generated automatically from the game's schema, with an optional live "Verify ID" check before purchase. The fields use standard WooCommerce markup, so they inherit your theme's styling.
     19* **Add games in one click** - every game GamerHub sells becomes a WooCommerce product (one variation per top-up amount), priced at what you pay plus the profit % you set.
     20* **Your prices, your way** - set a default profit %, then fine-tune it for one game or one single top-up amount, with filters and a bulk edit. A price is never allowed to fall below what GamerHub charges you (see the currency note below).
     21* **Sell in your own currency** - if your shop sells in a different currency from the one GamerHub charges you in, the plugin converts every cost at an exchange rate, adds an optional safety buffer, then adds your profit. If it cannot get a trustworthy rate it refuses to write any price at all rather than guess.
     22* **Player ID / Server fields** on the product page, generated automatically from the game's own schema, with an optional live account check before purchase. The fields use standard WooCommerce markup, so they inherit your theme's styling.
    2223* **Automatic fulfilment** - when a WooCommerce order is paid, each unit is submitted to GamerHub server-side over an HMAC-signed request. One supplier order per unit, so every supplier and every quantity works.
    2324* **Signed status webhook** - the order flips to Completed once GamerHub confirms delivery; voucher codes are attached to the order and emailed to the customer.
    2425* **Works with your add-ons** - already render Player ID / Server with PPOM or another product add-on? Turn off the built-in fields and the plugin rebuilds the fulfilment data from the order automatically. If it can't be certain which field is which, it blocks the order instead of guessing - a top-up is never sent to the wrong account.
    25 * **Manual linking** - hand-build any product (your own name / price / image) and link it to a GamerHub denomination, or add a single denomination as a standalone product that keeps tracking cost & stock.
    26 * **Auto-sync (optional)** - refresh prices & stock in the background every 1, 3, 6, 12 or 24 hours, with a per-product on/off switch. Products that go inactive or out of stock upstream are marked out of stock automatically and restock themselves when they return.
     26* **Manual linking** - hand-build any product (your own name / price / image) and link it to a GamerHub top-up amount, or sell a single amount as its own standalone product that keeps tracking cost and stock.
     27* **Keep everything up to date (optional)** - refresh prices and stock in the background every 1, 3, 6, 12 or 24 hours, with a per-product on/off switch. Products that go inactive or out of stock upstream are marked out of stock automatically and restock themselves when they return.
    2728* **HPOS compatible.**
    2829
    … …  
    3334This plugin connects to the **GamerHub merchant API** (https://portal.gamerhubstore.shop) - the service it exists to integrate with. It communicates with that API in these situations:
    3435
    35 1. **Catalog sync / price refresh** - sends your API key (in signed request headers) and requests the product list and per-product prices. No customer data is sent.
     361. **Loading the game list / checking prices** - sends your API key (in signed request headers) and requests the game list and per-game prices. No customer data is sent.
    36372. **Order fulfilment** - when a WooCommerce order is paid, the plugin sends the product code, SKU code, a unique order reference (`out_trade_no`), the buyer-entered game fields (e.g. Player ID / Server), and your store's webhook callback URL, so GamerHub can fulfil the order and notify you of the result.
    37 3. **Verify ID (optional)** - if you enable the "Verify ID" button, the game fields the buyer types are sent to GamerHub to check the account exists before purchase.
    38 4. **Account & order status** - the plugin queries your GamerHub account balance/currency (connection test and before pricing) and polls an order's status by reference. These send only your API key and the order reference (`out_trade_no`); no customer data is sent.
     383. **Checking a game account (optional)** - if you enable the account-check button, the game fields the buyer types are sent to GamerHub to confirm the account exists before purchase.
     394. **Account status, balance and exchange rate** - the plugin queries your GamerHub account balance and currency (connection test, and before writing prices) and polls an order's status by reference. When your shop currency differs from your GamerHub account currency, this same request also asks GamerHub for the exchange rate between the two, so prices can be converted. These send only your API key, your store's currency code, and the order reference (`out_trade_no`); no customer data is sent.
    3940
    4041The buyer-entered game fields (such as a Player ID) are personal data that leaves your site and is sent to GamerHub as a necessary part of delivering the ordered top-up.
    … …  
    4344* Privacy Policy: https://portal.gamerhubstore.shop/privacy
    4445
    45 No data is sent to any other third party.
     46No data is sent to any other third party. The plugin does not call any exchange-rate service or any other API - the rate comes from your own GamerHub account, or you type it yourself.
    4647
    4748== Installation ==
    4849
    49 1. Install & activate the plugin (WooCommerce must be active first).
    50 2. Go to **GamerHub -> Settings** and paste your API Key, API Secret and Webhook Secret from portal.gamerhubstore.shop, set a retail markup %, and Save. The Webhook Secret must be identical to the one set on your GamerHub merchant profile, or status callbacks are rejected.
    51 3. Whitelist this server's IP on the API Key page in the portal ("Test connection" reports the exact IP to add).
    52 4. Click **Sync catalog**, then Import the products you want to sell.
    53 5. Place a small test order to confirm the full flow end-to-end.
     501. Install and activate the plugin. WooCommerce must already be active.
     512. In the WordPress menu on the left, click **GamerHub**. Paste your API Key, API Secret and Webhook Secret from portal.gamerhubstore.shop. The Webhook Secret must be exactly the same as the one in your GamerHub account - if it is not, GamerHub still delivers and still charges you, but your shop never marks the order Complete. Set your **Default profit %** - leave it at 0 and you sell at cost price and earn nothing. Press **Save settings**.
     523. Press **Test connection**. If it fails, the message shows this server's address. Open the API Key page in your GamerHub account, add that address to the allowed list, then test again. When it succeeds it also tells you which currency GamerHub charges you in - you need that for the next step.
     534. Still on the GamerHub page, check **Currency handling**. If your shop sells in a different currency from the one GamerHub charges you in, nothing will be imported until you choose **Convert**. See the currency question below.
     545. Press **Get the game list**, then press **Add to my shop** on each game you want to sell.
     556. Put some money in your GamerHub wallet, then buy one cheap item on your own shop to check the code or top-up really arrives.
    5456
    5557== Frequently Asked Questions ==
     58
     59= My shop and my GamerHub account use different currencies. What happens? =
     60GamerHub charges you in one currency (often USD). Your shop sells in another. A cost of 2.50 USD is not a price of 2.50 in your currency, so the plugin will not guess - by default it stops and imports nothing. That is deliberate: it protects you.
     61
     62On the GamerHub page, under **Currency handling**, pick one of three:
     63
     64* **Convert** - what almost every shop wants. GamerHub's cost is multiplied by an exchange rate, then your profit % is added. The rate comes from your GamerHub account and stays up to date on its own, or you can type your own. If the rate is missing or more than a day old, the plugin stops writing prices until it has a fresh one, so you are never priced from a stale rate.
     65* **Pause** - the default. Nothing is imported and no price changes while the two currencies are different.
     66* **No conversion** - the number is copied across with no maths at all, so a cost of 2.50 USD becomes a price of 2.50 in your currency. Only safe if you type every price by hand and turn "Keep price and stock up to date" off. **In this mode the below-cost protection cannot work**, because the two numbers are not in the same currency.
     67
     68There is also a **Rate buffer** (a small % added on top of the rate, 2% is a sensible start). Your prices stay as they are until the next sync, but exchange rates move daily - the buffer covers that drift so it does not come out of your profit.
     69
     70= Can I change my WooCommerce currency after adding games? =
     71Not safely. WooCommerce stores a price as a plain number and puts your currency label on it. If you switch currency, every imported price keeps its old number and only the label changes - so 11.75 in the old currency becomes 11.75 in the new one, and most of your shop is suddenly priced wrong. The plugin shows a red warning if it detects this. Fix it by adding your games again before you take any orders.
    5672
    5773= Does it need a GamerHub account? =
    … …  
    6177Only the game fields the product requires (e.g. Player ID, Server) - these come from GamerHub automatically. Vouchers require nothing; the buyer just pays and receives a code.
    6278
    63 = Can I set my own markup or prices? =
    64 Yes. Set a default markup % in Settings, then fine-tune it per product or per denomination right in **GamerHub -> Products** (inline, with filters and bulk edit). Markup is applied to a product's price, so a catalog item has to be imported first (or added as a standalone product) - that's the step that creates the WooCommerce product whose price the markup adjusts. Retail is never allowed below your GamerHub cost.
     79= Can I set my own profit or prices? =
     80Yes. Set a **Default profit %** on the GamerHub page. Every game in the list then has its own **Your profit** box right in the table — you can fill it in even before you add the game to your shop, and it is applied the moment you do. Press **Prices** on a game's row to see each top-up amount with what you pay and what the buyer pays side by side, and to give one single amount its own profit. Note the two profit types: **% on top of cost** multiplies, and **+ CUR on top of cost** adds that many units of your currency on top of the cost - it does not set the price to that amount. A price is never allowed to fall below what GamerHub charges you, as long as your shop currency matches your GamerHub currency or you use Convert.
    6581
    6682= How often do prices and stock refresh? =
    67 Turn on Auto-sync in Settings and pick an interval - every 1, 3, 6, 12 or 24 hours. Each imported product can also be excluded individually. If a product goes inactive or out of stock upstream it is automatically marked out of stock so it can't be sold while it can't be fulfilled, and it restocks itself on the next successful sync.
     83Turn on the interval on the GamerHub page - every 1, 3, 6, 12 or 24 hours. Each time it runs it **rewrites** the prices of the games already in your shop using your profit %, and also puts the GamerHub name back and puts the product back on sale - so a price or a name you typed by hand on one of those products will be replaced. To protect one game from this, untick **Keep updated** on its row. If a product goes inactive or out of stock upstream it is automatically marked out of stock so it can't be sold while it can't be fulfilled, and it restocks itself on the next successful sync.
    6884
    6985= I use PPOM or another product add-on for the Player ID fields. Does it work? =
    70 Yes. On a product that uses an add-on, tick "Hide GamerHub's built-in fields" in its GamerHub box to turn ours off and let your add-on render the Player ID / Server. When an order is paid the plugin rebuilds the fulfilment data from the order automatically - matching each GamerHub field by your saved mapping, its label, or a synonym. If it can't be certain which field is which, it blocks the order instead of guessing, so a top-up is never sent to the wrong account. **GamerHub -> Products** shows exactly which field each game needs, and for dropdowns (such as a Genshin server) the exact values to use. You can also map fields explicitly, per product or per variation.
     86Yes. On a product that uses an add-on, tick "Another plugin already asks for the Player ID" in its GamerHub box to turn ours off and let your add-on render the Player ID / Server. When an order is paid the plugin rebuilds the fulfilment data from the order automatically - matching each GamerHub field by your saved mapping, its label, or a synonym. If it can't be certain which field is which, it blocks the order instead of guessing, so a top-up is never sent to the wrong account. Note that with this on, the plugin no longer checks that the buyer filled the fields in - so place one test order before you go live. Pressing **Prices** on a game's row shows exactly which fields that game needs, and for dropdowns (such as a Genshin server) the exact values to use. You can also map fields explicitly, per product or per variation.
    7187
    7288= Will the player fields match my theme? =
    … …  
    8197== Requirements ==
    8298
    83 * A public HTTPS site (the status webhook is delivered to `/wp-json/gamerhub/v1/callback`).
     99* A public HTTPS site (the status webhook is delivered to `/wp-json/gamerhub/v1/callback`). On a local or test site GamerHub cannot reach it and orders stay in Processing.
    84100* Server clock synced (NTP) - requests are signed with a +/-5-minute window.
    85101* A GamerHub merchant account with sufficient balance.
    86102
    87103== Changelog ==
     104
     105= 1.2.0 =
     106* **Sell in your own currency.** If your shop's currency is not the one GamerHub charges you in, costs are now converted at a rate taken from your GamerHub account (or one you type), with an optional safety buffer and optional rounding. If no trustworthy rate is available the plugin refuses to write a price rather than guess, and rounding is always upward so it can never push a price below cost.
     107* See what you pay and what the buyer pays for every top-up amount, side by side and each labelled with its own currency, without adding anything to your shop - press Prices on any game.
     108* Set the profit for a game straight from the game list, including games you have not added yet: the value is remembered and applied the moment you add them.
     109* Give a hand-built or single-amount product its own profit %, instead of only the global default - so you can set a different margin without losing the automatic sold-out protection.
     110* The denomination picker on the product editor now shows what each amount costs you, in GamerHub's currency and in yours, so you can price against real numbers.
     111* Plain-language rewrite of every admin screen, aimed at shop owners rather than developers, and every admin string is now translatable.
     112* Fixed: with a rounding step set, a price could jump a whole step because of a floating-point rounding artefact (for example 50.00 plus 10% became 60 instead of 55).
     113* Fixed: choosing "no conversion" and then saving the settings page again silently reset it to "Pause", which stopped all imports and repricing with no explanation.
     114* Fixed: the per-game price refresh silently returned cached data instead of fetching fresh figures.
     115* Fixed: a bulk edit reported games as changed even when their prices could not be rewritten.
     116* Fixed: "Sync from GamerHub" on a fixed-price product showed a red error for what is normal, expected behaviour.
     117* Faster: the admin pages no longer run an unbounded product query on every screen, and the game list no longer runs one query per product.
    88118
    89119= 1.1.0 =
    … …  
    102132== Upgrade Notice ==
    103133
     134= 1.2.0 =
     135Sell in a currency other than your GamerHub account currency, see cost and retail side by side, and set a profit per game or per product. Includes a fix for a rounding bug that could overcharge. Nothing changes if your shop and GamerHub already use the same currency.
     136
    104137= 1.1.0 =
    105138Inline per-product/per-denomination markup, more auto-sync intervals, closed-loop stock, PPOM / add-on compatibility, and theme-matching fields. No action required after updating.
  • gamerhub-for-woocommerce/tags/1.2.0/uninstall.php

    r3617487 r3630953  
    1515        'gamerhub_settings',        // api_key, api_secret, webhook_secret, markup, auto_sync, auto_import
    1616        'gamerhub_catalog',         // cached catalog snapshot
     17        'gamerhub_price_cache',     // per-game cost/retail ranges shown in the catalog table
     18        'gamerhub_pending_markup',  // profit chosen for games not added to the shop yet
    1719        'gamerhub_last_autosync',   // last auto-sync timestamp
    1820        'gamerhub_autosync_offset', // batch cursor
  • gamerhub-for-woocommerce/trunk/assets/gamerhub-admin.css

    r3626288 r3630953  
    165165.gamerhub-adv-map > summary { cursor: pointer; color: #2271b1; font-size: 13px; }
    166166.gamerhub-adv-map .gamerhub-fieldmap-input { margin-top: 2px; }
     167
     168/* Catalog table: "You pay" (account currency) + inline "Your profit" editor. */
     169.gamerhub-mk-row-cell { white-space: nowrap; }
     170.gamerhub-mk-inline input[type="number"] { width: 68px; }
     171.gamerhub-mk-inline select { max-width: 78px; }
     172.gamerhub-mk-inline .button { margin-left: 2px; }
     173.gamerhub-mk-inline .gamerhub-row-mk-result { margin-left: 6px; font-size: 12px; }
     174.gamerhub-mk-inline .description { display: inline-block; margin-top: 2px; color: #787c82; }
     175
     176/* ── Catalog table width ──────────────────────────────────────────────────────
     177   Region / Type / Status are now badges inside the Game cell (they each have a
     178   dropdown filter of their own), and the two widest controls are tightened, so
     179   the money columns fit without a horizontal scrollbar. The wrapper still
     180   scrolls as a last resort on very narrow screens — the PAGE never does. */
     181#gamerhub-catalog { overflow-x: auto; }
     182#gamerhub-catalog table { min-width: 720px; }
     183.gamerhub-game-cell code { font-size: 11px; color: #787c82; }
     184.gamerhub-game-meta { display: block; margin-top: 2px; font-size: 11px; color: #787c82; }
     185.gamerhub-game-meta .gamerhub-badge { font-size: 10px; padding: 0 4px; }
     186.gamerhub-mk-inline input[type="number"] { width: 54px; }
     187.gamerhub-mk-inline select { max-width: 62px; padding-right: 18px; }
     188.gamerhub-mk-inline .button { padding: 0 6px; }
     189.gamerhub-autosync-lbl { font-size: 12px; }
     190.gamerhub-synced-at { font-size: 11px; color: #787c82; }
  • gamerhub-for-woocommerce/trunk/assets/gamerhub-admin.js

    r3626288 r3630953  
    1313            .replace(/'/g, '&#39;');
    1414    }
    15     function money(v) { return v == null ? '—' : (GAMERHUB_ADMIN.currency + ' ' + Number(v).toFixed(2)); }
     15    // Matches PHP's number_format($n, 2) exactly. The same cell is rendered server-side on page
     16    // load and client-side after ↻ / an inline save — without this, "1,872.69" silently became
     17    // "1872.69" the moment you touched it.
     18    function num2(v) {
     19        var parts = Number(v).toFixed(2).split('.');
     20        return parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + parts[1];
     21    }
     22    function money(v) { return v == null ? '—' : (GAMERHUB_ADMIN.currency + ' ' + num2(v)); }
    1623    function post(data) { return $.post(GAMERHUB_ADMIN.ajax, $.extend({ nonce: GAMERHUB_ADMIN.nonce_sync }, data)); }
    1724    function sym() { return GAMERHUB_ADMIN.symbol || GAMERHUB_ADMIN.currency; }
     25
     26    // Translated string by key, with the English text as a fallback. The fallback matters: a key that
     27    // is missing (old cached script, partial upgrade) must never render "undefined" on the money screen.
     28    function t(key, fallback) {
     29        var v = (GAMERHUB_ADMIN.i18n || {})[key];
     30        return (typeof v === 'string' && v !== '') ? v : fallback;
     31    }
     32    // Fills %s / %1$s / %2$s in a translated string, in order.
     33    function tf(key, fallback) {
     34        var args = Array.prototype.slice.call(arguments, 2), i = 0;
     35        return t(key, fallback).replace(/%(\d+\$)?s/g, function (m, pos) {
     36            return String(pos ? args[parseInt(pos, 10) - 1] : args[i++]);
     37        });
     38    }
    1839
    1940    $(function () {
    … …  
    2748                    if (r && r.success) {
    2849                        var d = r.data || {};
    29                         $tres.addClass('ok').text('✓ ' + (d.currency ? d.currency + ' ' : '') + (d.balance || ''));
     50                        var txt = '✓ ' + t('connectedBal', 'Connected. Your GamerHub balance:') + ' ' +
     51                            (d.currency ? d.currency + ' ' : '') + (d.balance || '');
     52                        if (d.currency) { txt += ' — ' + tf('chargedIn', 'GamerHub charges you in %s.', d.currency); }
     53                        $tres.addClass('ok').text(txt);
    3054                    } else {
    3155                        var e = (r && r.data) ? r.data : {};
    32                         var msg = e.message || 'Failed';
    33                         if (e.ip) { msg += ' — whitelist this IP in the portal: ' + e.ip; }
     56                        var msg = e.message || t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.');
     57                        if (e.ip) { msg += ' — ' + t('blockedIp', 'GamerHub is blocking this website. Sign in to GamerHub, open your API Key page, and add this address to the allowed list:') + ' ' + e.ip; }
    3458                        $tres.addClass('err').text('✗ ' + msg);
    3559                    }
    3660                })
    37                 .fail(function () { $tres.addClass('err').text('✗ Request failed'); })
     61                .fail(function () { $tres.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    3862                .always(function () { $test.prop('disabled', false); });
    3963        });
    … …  
    5478            return false;
    5579        }
     80        // Search the GAME NAME + CODE only, not the whole row. Every other cell carries text that
     81        // is identical on every row — currency codes, "Imported", the profit dropdown's "+BDT",
     82        // the hint sentences — so matching row text made searches like "bdt" or "add" return the
     83        // entire catalog. Cached per row; a re-render produces fresh elements with fresh data.
     84        function rowSearchText($tr) {
     85            var cached = $tr.data('ghSearch');
     86            if (cached == null) {
     87                // The game NAME only (the <strong>), plus the code from the row attribute. The Game
     88                // cell now also holds the region/type/status badges, and matching those would put
     89                // every row behind a search for "added" or "shop".
     90                cached = ($tr.children('td').eq(1).find('strong').first().text() + ' ' +
     91                          ($tr.attr('data-code') || '')).toLowerCase();
     92                $tr.data('ghSearch', cached);
     93            }
     94            return cached;
     95        }
     96
    5697        function applyCatFilter() {
    5798            var q = $.trim($search.val() || '').toLowerCase();
    … …  
    63104                if ($tr.hasClass('gamerhub-detail-row')) { return; }
    64105                total++;
    65                 var match = (q === '' || $tr.text().toLowerCase().indexOf(q) !== -1);
     106                var match = (q === '' || rowSearchText($tr).indexOf(q) !== -1);
    66107                if (match && fi !== '') { match = (String($tr.attr('data-imported')) === fi); }
    67108                if (match && ft !== '') { match = (String($tr.attr('data-type')) === ft); }
    … …  
    92133                    setBody(r.data.html);
    93134                    $cat.show();
    94                     $scr.addClass('ok').text('✓ ' + r.data.count + ' games');
     135                    $scr.addClass('ok').text('✓ ' + tf('gamesFound', '%s games found. Nothing has been added to your shop yet — tick the games you want, then press “Add to my shop”.', r.data.count));
    95136                    $('#gamerhub-catalog-time').text('· ' + GAMERHUB_ADMIN.i18n.justnow);
    96137                })
    97                 .fail(function () { $scr.addClass('err').text('✗ Request failed'); })
     138                .fail(function () { $scr.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    98139                .always(function () { $sc.prop('disabled', false); });
    99140        });
    100141
    101142
    102         // ── Per-row ↻ : refresh just this game's price (no expand, no import) ─────────
    103         $body.on('click', '.gamerhub-price-refresh', function (e) {
    104             e.preventDefault();
    105             var $lnk = $(this), code = $lnk.data('code');
    106             var $val = $lnk.closest('.gamerhub-price-cell').find('.gamerhub-price-val');
    107             var old = $val.text();
    108             $val.text('…');
    109             post({ action: 'gamerhub_price_one', code: code })
    110                 .done(function (r) {
    111                     if (!r || !r.success) { $val.text(old || '—'); return; }
    112                     var d = r.data;
    113                     if (d.price_min != null) {
    114                         var pmin = Number(d.price_min), pmax = Number(d.price_max);
    115                         $val.text(sym() + ' ' + pmin.toFixed(2) + (pmax > pmin ? ' – ' + pmax.toFixed(2) : ''));
    116                     } else { $val.text('—'); }
    117                 })
    118                 .fail(function () { $val.text(old || '—'); });
     143        // ── Inline profit editor, right in the table row ──────────────────────────────
     144        // Works whether or not the game is in the shop: for a game already added it writes the
     145        // product's profit and reprices immediately; for one that is not, the server parks the
     146        // value and applies it on import.
     147        $body.on('click', '.gamerhub-save-row-mk', function () {
     148            var $btn = $(this), code = $btn.data('code');
     149            var $wrap = $btn.closest('.gamerhub-mk-inline');
     150            var $res = $wrap.find('.gamerhub-row-mk-result');
     151            var val = $.trim($wrap.find('.gamerhub-row-mk-value').val() || '');
     152            var type = $wrap.find('.gamerhub-row-mk-type').val();
     153            $btn.prop('disabled', true);
     154            $res.removeClass('ok err').text(t('working', 'Working…'));
     155            post({ action: 'gamerhub_set_markup', code: code, sku_code: '', mk_type: type, mk_value: val })
     156                .done(function (r) {
     157                    if (!r || !r.success) {
     158                        $res.removeClass('ok').addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed'));
     159                        return;
     160                    }
     161                    // Re-rendering the whole body would close every open panel and lose focus, so
     162                    // swap in just this row's Profit + Buyer-pays cells from the fresh HTML.
     163                    if (r.data.html) {
     164                        var $fresh = $('<tbody>').html(r.data.html)
     165                            .find('tr[data-code="' + code + '"]').not('.gamerhub-detail-row').first();
     166                        if ($fresh.length) {
     167                            var $tr = $btn.closest('tr');
     168                            $tr.find('.gamerhub-mk-row-cell').html($fresh.find('.gamerhub-mk-row-cell').html());
     169                            // The replaced cell carries its own (empty) result span.
     170                            $tr.find('.gamerhub-row-mk-result').addClass('ok').text('✓');
     171                            // An open Prices panel now shows stale numbers — force a reload.
     172                            $('.gamerhub-detail-row[data-code="' + code + '"]').hide()
     173                                .find('.gamerhub-detail-cell').removeData('loaded').empty();
     174                            $tr.find('.gamerhub-expand').attr('aria-expanded', 'false')
     175                               .find('.gamerhub-caret').html('&#9656;');
     176                            return;
     177                        }
     178                    }
     179                    $res.removeClass('err').addClass('ok').text('✓ ' + (r.data.message || ''));
     180                })
     181                .fail(function () { $res.removeClass('ok').addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
     182                .always(function () { $btn.prop('disabled', false); });
    119183        });
    120184
    … …  
    137201                    var d = r.data, imported = d.imported;
    138202
    139                     // Fill the row's Price column with the live retail range — no import needed.
    140                     if (d.price_min != null) {
    141                         var pmin = Number(d.price_min), pmax = Number(d.price_max);
    142                         $btn.closest('tr').find('.gamerhub-price-cell .gamerhub-price-val')
    143                             .text(sym() + ' ' + pmin.toFixed(2) + (pmax > pmin ? ' – ' + pmax.toFixed(2) : ''));
    144                     }
    145 
    146                     function mkType(sel) {
    147                         return '<option value="percent"' + (sel === 'fixed' ? '' : ' selected') + '>%</option>' +
    148                             '<option value="fixed"' + (sel === 'fixed' ? ' selected' : '') + '>' + esc(sym()) + '</option>';
     203
     204                    // What GamerHub charges, in GamerHub's OWN currency, plus the converted figure
     205                    // underneath when the two differ. Each number always carries its own currency —
     206                    // an unlabelled number next to a labelled one is exactly the confusion this
     207                    // screen exists to prevent.
     208                    var acct = d.cost_currency || '';
     209                    function costCell(s) {
     210                        // No account currency (the /balance lookup failed) means we cannot label the
     211                        // number. An unlabelled money figure is worse than none on this screen.
     212                        if (s.cost_src == null || !acct) { return '—'; }
     213                        var srcTxt = acct + ' ' + num2(s.cost_src);
     214                        if (acct === GAMERHUB_ADMIN.currency) { return esc(srcTxt); }
     215                        if (s.cost == null) {
     216                            return esc(srcTxt) + '<br /><span class="description" style="font-size:11px;">'
     217                                 + esc(t('noRate', 'no exchange rate')) + '</span>';
     218                        }
     219                        return esc(srcTxt) + '<br /><span class="description" style="font-size:11px;">= '
     220                             + esc(money(s.cost)) + '</span>';
    149221                    }
    150222
    … …  
    154226                                '<input type="number" class="gamerhub-sku-mk-value" step="0.01" min="0" style="width:70px;" value="' + (s.mk_value != null ? esc(s.mk_value) : '') + '" placeholder="—" title="Blank = use the product / default markup" /> ' +
    155227                                '<select class="gamerhub-sku-mk-type">' + mkType(s.mk_type) + '</select> ' +
    156                                 '<button type="button" class="button button-primary gamerhub-save-sku-mk" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '">Save</button>' +
     228                                '<button type="button" class="button button-primary gamerhub-save-sku-mk" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '">' + esc(t('save', 'Save')) + '</button>' +
    157229                              '</td>'
    158                             : '<td><em style="color:#999">import first</em></td>';
     230                            : '<td><em style="color:#999">' + esc(t('addFirst', 'add this game to your shop first')) + '</em></td>';
    159231                        return '<tr data-sku="' + esc(s.code) + '"><td><strong>' + esc(s.name) + '</strong></td>' +
    160232                            '<td><code>' + esc(s.code) + '</code></td>' +
    161                             '<td style="text-align:right">' + money(s.cost) + '</td>' +
     233                            '<td style="text-align:right">' + costCell(s) + '</td>' +
    162234                            '<td style="text-align:right" class="gamerhub-retail-cell"><strong>' + money(s.retail) + '</strong></td>' +
    163                             '<td>' + (s.stock === 'out_of_stock' ? '<span class="gamerhub-badge err">Out</span>' : '<span class="gamerhub-badge ok">In stock</span>') + '</td>' +
     235                            '<td>' + (s.stock === 'out_of_stock'
     236                                ? '<span class="gamerhub-badge err">' + esc(t('soldOut', 'Sold out')) + '</span>'
     237                                : '<span class="gamerhub-badge ok">' + esc(t('available', 'Available')) + '</span>') + '</td>' +
    164238                            mkCell +
    165                             '<td><button type="button" class="button gamerhub-import-sku" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '" data-name="' + esc(gameName + ' — ' + s.name) + '" data-price="' + (s.retail != null ? s.retail : '') + '" title="Import just this denomination as its own product">Import as product</button></td></tr>';
     239                            '<td><button type="button" class="button gamerhub-import-sku" data-code="' + esc(code) + '" data-sku="' + esc(s.code) + '" data-name="' + esc(gameName + ' — ' + s.name) + '" data-price="' + (s.retail != null ? s.retail : '') + '" title="' + esc(t('sellAloneTip', 'Creates a SECOND, separate listing in your shop for this one amount only, with its own name and price. The game itself is not changed, and GamerHub never updates this new listing.')) + '">' + esc(t('sellAlone', 'Sell this amount on its own')) + '</button></td></tr>';
    166240                    }).join('');
    167241
    … …  
    170244                        var pm = d.product_markup || {};
    171245                        prodMk = '<div class="gamerhub-prod-mk">' +
    172                             '<strong>Markup for the whole product:</strong> ' +
     246                            '<strong>' + esc(t('gameProfit', 'Profit for this whole game')) + ':</strong> ' +
    173247                            '<input type="number" class="gamerhub-prod-mk-value" step="0.01" min="0" style="width:80px;" value="' + (pm.value != null ? esc(pm.value) : '') + '" placeholder="default" /> ' +
    174248                            '<select class="gamerhub-prod-mk-type">' + mkType(pm.type) + '</select> ' +
    175                             '<button type="button" class="button button-primary gamerhub-save-prod-mk" data-code="' + esc(code) + '">Apply to all denominations</button> ' +
    176                             '<span class="description">A single denomination’s markup below overrides this. Blank = use the default markup.</span></div>';
     249                            '<button type="button" class="button button-primary gamerhub-save-prod-mk" data-code="' + esc(code) + '">' + esc(t('saveGameProfit', 'Save profit for this game')) + '</button> ' +
     250                            '<span class="description">' + esc(t('gameProfitHint', 'Used for every amount below whose own profit box is empty. An amount that has its own number keeps that number. Leave this empty to use your Default profit %.')) + '</span></div>';
    177251                    }
    178252
    … …  
    184258                        var refItems = d.input_form.map(function (f) {
    185259                            var isSel = (f.type === 'select' && f.options && f.options.length);
    186                             var t = isSel ? 'dropdown' : (f.type || 'text');
    187                             var li = '<li><strong>' + esc(f.label) + '</strong> <span class="description">(' + t + (f.required ? ' · required' : '') + ')</span>';
     260                            var ftype = isSel ? 'dropdown' : (f.type || 'text');   // NB: not `t` — that is the i18n helper
     261                            var li = '<li><strong>' + esc(f.label) + '</strong> <span class="description">(' + ftype + (f.required ? ' · required' : '') + ')</span>';
    188262                            if (isSel) {
    189263                                li += '<div class="gamerhub-optvals"><span class="description">Your add-on dropdown must use these exact values:</span><br>' +
    … …  
    199273                            adv = '<details class="gamerhub-adv-map"><summary>' + esc('Advanced: manual field mapping') + '</summary>' +
    200274                                '<p class="description">Only if a field can’t auto-connect: type your add-on’s field name for each.</p>' + mrows +
    201                                 '<button type="button" class="button gamerhub-save-fieldmap" data-code="' + esc(code) + '">Save mapping</button> <span class="gamerhub-fieldmap-result gamerhub-result"></span></details>';
     275                                '<button type="button" class="button gamerhub-save-fieldmap" data-code="' + esc(code) + '">' + esc(t('saveMapping', 'Save')) + '</button> <span class="gamerhub-fieldmap-result gamerhub-result"></span></details>';
    202276                        }
    203277                        fieldsRef = '<div class="gamerhub-fields-ref">' +
    204                             '<strong>Buyer fields this game needs</strong>' +
     278                            '<strong>' + esc(t('buyerFields', 'Details the buyer must enter for this game')) + '</strong>' +
    205279                            '<ul class="gamerhub-fieldref-list">' + refItems + '</ul>' +
    206280                            '<p class="description">If you use a PPOM / add-on plugin: name your fields the same as above, and for a dropdown use the exact values shown — they connect automatically.</p>' +
    … …  
    213287                        '<div class="gamerhub-detail-inner">' + prodMk + fieldsRef +
    214288                        '<table class="gamerhub-sku-table"><thead><tr>' +
    215                         '<th>Denomination</th><th>SKU code</th><th style="text-align:right">Cost</th><th style="text-align:right">Retail</th><th>Stock</th>' +
    216                         '<th>' + (imported ? 'Markup' : '') + '</th><th></th>' +
     289                        '<th>' + esc(t('thAmount', 'Top-up amount')) + '</th>' +
     290                        '<th>' + esc(t('thCode', 'GamerHub code')) + '</th>' +
     291                        '<th style="text-align:right">' + esc(t('thYouPay', 'You pay')) + '</th>' +
     292                        '<th style="text-align:right">' + esc(t('thBuyerPays', 'Buyer pays')) + '</th>' +
     293                        '<th>' + esc(t('thStock', 'Stock')) + '</th>' +
     294                        '<th>' + (imported ? esc(t('thProfit', 'Your profit')) : '') + '</th><th></th>' +
    217295                        '</tr></thead><tbody>' + rows + '</tbody></table></div>'
    218296                    );
    219297                    $cell.data('loaded', true);
    220298                })
    221                 .fail(function () { $cell.html('<span class="gamerhub-result err">✗ Request failed</span>'); });
     299                .fail(function () { $cell.html('<span class="gamerhub-result err">✗ ' + esc(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')) + '</span>'); });
    222300        });
    223301
    … …  
    239317                    setTimeout(function () { $btn.text(restoreLabel); }, 1200);
    240318                })
    241                 .fail(function () { alert('Request failed'); $btn.text(restoreLabel); })
     319                .fail(function () { alert(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); $btn.text(restoreLabel); })
    242320                .always(function () { $btn.prop('disabled', false); });
    243321        }
    … …  
    248326        $body.on('click', '.gamerhub-save-prod-mk', function () {
    249327            var $btn = $(this), $wrap = $btn.closest('.gamerhub-prod-mk');
    250             saveMarkup($btn, $btn.data('code'), '', $wrap.find('.gamerhub-prod-mk-type').val(), $wrap.find('.gamerhub-prod-mk-value').val(), 'Apply to all');
     328            saveMarkup($btn, $btn.data('code'), '', $wrap.find('.gamerhub-prod-mk-type').val(), $wrap.find('.gamerhub-prod-mk-value').val(), t('saveGameProfit', 'Save profit for this game'));
    251329        });
    252330
    … …  
    266344                    $res.addClass('ok').text('✓ Saved');
    267345                })
    268                 .fail(function () { $res.addClass('err').text('✗ Request failed'); })
    269                 .always(function () { $btn.prop('disabled', false).text('Save mapping'); });
     346                .fail(function () { $res.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
     347                .always(function () { $btn.prop('disabled', false).text(t('saveMapping', 'Save')); });
    270348        });
    271349
    … …  
    298376                    setBody(r.data.html);
    299377                })
    300                 .fail(function () { $btn.prop('disabled', false); alert('Request failed'); });
     378                .fail(function () { $btn.prop('disabled', false); alert(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); });
    301379        });
    302380
    … …  
    318396            $('#gamerhub-modal-name').val($btn.data('name') || '');
    319397            $('#gamerhub-modal-price').val($btn.data('price') || '');
    320             $('.gamerhub-modal-sku').text('SKU code: ' + $btn.data('sku'));
     398            $('.gamerhub-modal-sku').text(t('thCode', 'GamerHub code') + ': ' + $btn.data('sku'));
    321399            $('#gamerhub-modal-result').removeClass('ok err').text('');
    322400            $('#gamerhub-modal-autoprice').prop('checked', true);
    … …  
    332410                name = $.trim($('#gamerhub-modal-name').val()),
    333411                price = $('#gamerhub-modal-price').val();
    334             if (!name) { $('#gamerhub-modal-result').addClass('err').text('Enter a product name'); return; }
     412            if (!name) { $('#gamerhub-modal-result').addClass('err').text(t('enterName', 'Enter a product name.')); return; }
    335413            $c.prop('disabled', true);
    336414            post({ action: 'gamerhub_add_promo', code: pendData.code, sku_code: pendData.sku, name: name, price: price, autoprice: $('#gamerhub-modal-autoprice').is(':checked') ? '1' : '0' })
    … …  
    343421                    }
    344422                })
    345                 .fail(function () { $('#gamerhub-modal-result').addClass('err').text('Request failed'); })
     423                .fail(function () { $('#gamerhub-modal-result').addClass('err').text(t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    346424                .always(function () { $c.prop('disabled', false); });
    347425        });
    … …  
    377455                autosync = $('#gamerhub-bulk-autosync').val() || '';
    378456            if (!mkv && !brand && (cat === '0' || cat === '') && !autosync) {
    379                 $res.removeClass('ok').addClass('err').text('Nothing to change'); return;
     457                $res.removeClass('ok').addClass('err').text(t('nothingToChange', 'Nothing to change — fill in at least one box.')); return;
    380458            }
    381459            $btn.prop('disabled', true);
    382             $res.removeClass('ok err').text(GAMERHUB_ADMIN.i18n.syncing);
     460            $res.removeClass('ok err').text(t('working', 'Working…'));
    383461            post({ action: 'gamerhub_bulk_edit', codes: codes, mk_value: mkv, mk_type: mkt, brand: brand, cat: cat, autosync: autosync })
    384462                .done(function (r) {
    385463                    if (!r || !r.success) { $res.addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed')); return; }
    386464                    setBody(r.data.html);
    387                     $res.removeClass('err').addClass('ok').text('✓ ' + r.data.updated + ' ' + GAMERHUB_ADMIN.i18n.done);
    388                 })
    389                 .fail(function () { $res.addClass('err').text('✗ Request failed'); })
     465                    var txt = '✓ ' + tf('bulkResult', 'Changed %1$s of %2$s — only games already in your shop can be changed.', r.data.updated, codes.length);
     466                    if (r.data.failed) {
     467                        // Profit saved but no price moved (usually a currency/rate problem) — never
     468                        // let those be counted as "changed".
     469                        txt += ' ' + tf('bulkFailed', '%s could not be repriced — check Currency handling.', r.data.failed);
     470                        $res.removeClass('ok').addClass('err').text('⚠ ' + txt);
     471                        return;
     472                    }
     473                    $res.removeClass('err').addClass('ok').text(txt);
     474                })
     475                .fail(function () { $res.addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    390476                .always(function () { $btn.prop('disabled', false); });
    391477        });
    … …  
    417503                        $imModal.hide(); setBody(r.data.html);
    418504                    })
    419                     .fail(function () { $('#gamerhub-im-result').removeClass('ok').addClass('err').text('✗ Request failed'); })
     505                    .fail(function () { $('#gamerhub-im-result').removeClass('ok').addClass('err').text('✗ ' + t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')); })
    420506                    .always(function () { $c.prop('disabled', false); });
    421507            } else {
    … …  
    461547
    462548        function syncFail(r) {
    463             $sres.addClass('err').text('✗ ' + ((r && r.data && r.data.message) ? r.data.message : 'Request failed'));
     549            $sres.addClass('err').text('✗ ' + ((r && r.data && r.data.message) ? r.data.message : t('reqFailed', 'Could not reach your site. Nothing was changed. Check your connection and try again.')));
    464550            $sync.prop('disabled', false);
    465551        }
  • gamerhub-for-woocommerce/trunk/assets/gamerhub-frontend.js

    r3626288 r3630953  
    3737                .done(function (r) {
    3838                    if (r && r.success) {
    39                         $res.removeClass('err').addClass('ok').text('✓ ' + (r.data.name || ''));
     39                        // The buyer must actually READ the name — a bare tick and a nickname prompts
     40                        // nobody, and a top-up sent to a mistyped ID cannot be recovered.
     41                        $res.removeClass('err').addClass('ok').text(
     42                            '✓ ' + (GAMERHUB_F.i18n.found || 'Account found:') + ' ' + (r.data.name || '') +
     43                            ' — ' + (GAMERHUB_F.i18n.confirmYou || 'please check this is you before you buy.')
     44                        );
    4045                    } else {
    41                         var m = (r && r.data && r.data.message) ? r.data.message : 'Invalid';
     46                        var m = (r && r.data && r.data.message) ? r.data.message : (GAMERHUB_F.i18n.notFound || 'We could not find that account.');
    4247                        $res.removeClass('ok').addClass('err').text('✗ ' + m);
    4348                    }
  • gamerhub-for-woocommerce/trunk/assets/gamerhub-product.js

    r3617487 r3630953  
    1919    }
    2020
     21    function pt(k, fallback) {
     22        var v = (GAMERHUB_P.i18n || {})[k];
     23        return (typeof v === 'string' && v !== '') ? v : fallback;
     24    }
     25    function num2(v) {
     26        var p = Number(v).toFixed(2).split('.');
     27        return p[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '.' + p[1];
     28    }
     29
     30    // What GamerHub charges for the selected amount, spelled out under the picker. Without it the
     31    // only number on this screen was a suggested retail, so anyone typing their own price was
     32    // guessing at their own margin.
     33    function renderDenomInfo($sel) {
     34        var $info = $sel.siblings('.gamerhub-denom-info');
     35        if (!$info.length) { return; }
     36        var skus = $sel.data('ghSkus') || [];
     37        var acct = $sel.data('ghAcct') || '';
     38        var code = $sel.val();
     39        var s = null;
     40        for (var i = 0; i < skus.length; i++) { if (skus[i].code === code) { s = skus[i]; break; } }
     41        if (!s || s.cost_src == null || !acct) { $info.empty(); return; }
     42
     43        var out = '<strong>' + esc(pt('youPay','You pay')) + '</strong> ' + esc(acct + ' ' + num2(s.cost_src));
     44        // s.cost is the same figure in the SHOP currency, and is null when the two currencies are
     45        // the same or no rate is available — never print an unconverted number under the shop label.
     46        if (s.cost != null) { out += ' <span style="color:#787c82;">(= ' + esc(GAMERHUB_P.cur + ' ' + num2(s.cost)) + ')</span>'; }
     47        if (s.retail != null) {
     48            out += ' &nbsp;·&nbsp; <strong>' + esc(pt('autoPrice','Auto price')) + '</strong> ' +
     49                   esc(GAMERHUB_P.cur + ' ' + num2(s.retail));
     50        }
     51        // Inline styles on purpose: the product editor loads no GamerHub stylesheet (see
     52        // Gamerhub_Product_Meta::assets), so a class here would render unstyled.
     53        $info.html('<span class="gamerhub-denom-cost" style="display:inline-block;margin-top:4px;font-size:12px;color:#1d2327;">' + out + '</span>');
     54    }
     55
    2156    function loadDenoms($sel, code, current, $preview) {
    2257        if (!code) {
    2358            $sel.html('<option value="">— choose a game first —</option>');
     59            $sel.removeData('ghSkus').siblings('.gamerhub-denom-info').empty();
    2460            if ($preview) { renderPreview($preview, []); }
    2561            return;
    … …  
    2965            .done(function (r) {
    3066                if (r && r.success) {
     67                    var acct = r.data.cost_currency || '';
    3168                    var opts = '<option value="">— choose —</option>';
    3269                    (r.data.skus || []).forEach(function (s) {
    33                         var price = (s.retail != null) ? ' — ' + GAMERHUB_P.cur + ' ' + Number(s.retail).toFixed(2) : '';
     70                        // Lead with the COST — that is the number you price against — and give it in
     71                        // BOTH currencies here as well, not only in the line under the picker. An
     72                        // <option> cannot be styled or wrapped and gets truncated to the select's
     73                        // width once closed, which is exactly why that second copy exists; but the
     74                        // open list still has to be complete enough to compare amounts in.
     75                        var cost = '';
     76                        if (s.cost_src != null && acct) {
     77                            cost = '  —  ' + pt('youPay', 'You pay') + ' ' + acct + ' ' + num2(s.cost_src);
     78                            if (s.cost != null) { cost += ' = ' + GAMERHUB_P.cur + ' ' + num2(s.cost); }
     79                        }
     80                        var price = (s.retail != null)
     81                            ? '  —  ' + pt('sells', 'Sells') + ' ' + GAMERHUB_P.cur + ' ' + num2(s.retail) : '';
    3482                        var sel = (s.code === current) ? ' selected' : '';
    35                         opts += '<option value="' + esc(s.code) + '"' + sel + '>' + esc(s.name) + price + '  —  ' + esc(s.code) + '</option>';
     83                        opts += '<option value="' + esc(s.code) + '"' + sel + '>' + esc(s.name) + cost + price + '  —  ' + esc(s.code) + '</option>';
    3684                    });
    37                     $sel.html(opts);
     85                    $sel.html(opts).data('ghSkus', r.data.skus || []).data('ghAcct', acct);
     86                    renderDenomInfo($sel);
    3887                    if ($preview) { renderPreview($preview, r.data.input_form || []); }
    3988                } else {
    … …  
    5099        $row.find('.gamerhub-var-pcode').val(game);
    51100        loadDenoms($row.find('.gamerhub-var-denom'), game, '');
     101    });
     102    $(document).on('change', '.gamerhub-var-denom, #gamerhub_denom', function () {
     103        renderDenomInfo($(this));
    52104    });
    53105    $(document).on('change', '.gamerhub-var-denom', function () {
  • gamerhub-for-woocommerce/trunk/gamerhub-for-woocommerce.php

    r3626288 r3630953  
    44 * Plugin URI:        https://portal.gamerhubstore.shop
    55 * Description:       Sell game top-ups & vouchers through GamerHub. Auto-fulfills WooCommerce orders on payment and syncs status back via a signed webhook.
    6  * Version:           1.1.0
     6 * Version:           1.2.0
    77 * Author:            GamerHub
    88 * License:           GPL-2.0-or-later
    … …  
    2020}
    2121
    22 define('GAMERHUB_VERSION', '1.1.0');
     22define('GAMERHUB_VERSION', '1.2.0');
    2323define('GAMERHUB_FILE', __FILE__);
    2424define('GAMERHUB_DIR', plugin_dir_path(__FILE__));
    … …  
    7070
    7171/**
     72 * How this store handles a store-currency ≠ GamerHub-account-currency mismatch.
     73 *   'block'   — refuse to import/price at all (DEFAULT, and the only mode that cannot lose money)
     74 *   'convert' — multiply every GamerHub cost by a conversion rate the owner supplies
     75 *   'ignore'  — proceed with the raw number, owner prices manually (DANGEROUS with auto-price on)
     76 *
     77 * Anything unrecognised falls back to 'block': an unreadable setting must fail CLOSED.
     78 */
     79function gamerhub_currency_mode(): string
     80{
     81    $m = (string) gamerhub_opt('currency_mode', 'block');
     82    return in_array($m, ['block', 'convert', 'ignore'], true) ? $m : 'block';
     83}
     84
     85/** Where the conversion rate comes from: 'auto' (GamerHub) or 'manual' (owner-typed). */
     86function gamerhub_fx_source(): string
     87{
     88    $s = (string) gamerhub_opt('fx_source', 'auto');
     89    return in_array($s, ['auto', 'manual'], true) ? $s : 'auto';
     90}
     91
     92/** The owner-supplied rate: 1 unit of the GamerHub ACCOUNT currency = N units of the STORE currency. */
     93function gamerhub_fx_rate(): float
     94{
     95    return (float) gamerhub_opt('fx_rate', '0');
     96}
     97
     98/**
     99 * Safety margin added on top of the raw rate, in percent.
     100 *
     101 * Standard practice for every WooCommerce multi-currency plugin, and for good reason: a rate is a
     102 * snapshot, but the price it produces sits in the catalog until the next sync. Without a buffer any
     103 * adverse move between syncs comes straight out of the markup.
     104 */
     105function gamerhub_fx_buffer(): float
     106{
     107    return max(0.0, (float) gamerhub_opt('fx_buffer', '2'));
     108}
     109
     110/** Round finished prices UP to this step (0 = off). Up only — rounding down would eat the margin. */
     111function gamerhub_price_rounding(): float
     112{
     113    return max(0.0, (float) gamerhub_opt('price_rounding', '0'));
     114}
     115
     116/** How old an automatic rate may be before we refuse to price with it. */
     117function gamerhub_fx_max_age(): int
     118{
     119    return DAY_IN_SECONDS;
     120}
     121
     122/**
     123 * The rate actually used to turn a GamerHub cost into a store-currency cost, buffer included.
     124 *
     125 * Returns NULL when no usable rate exists — a missing manual rate, an unreachable server, or a
     126 * server rate older than gamerhub_fx_max_age(). NULL means "do not price": callers must block
     127 * rather than fall back to a guess or a stale number.
     128 */
     129function gamerhub_effective_fx_rate(): ?float
     130{
     131    // RESOLVED ONCE PER REQUEST. A long sync writes hundreds of prices in one request while the
     132    // /balance response is only cached for 5 minutes; without this, the rate could silently vanish
     133    // half way through a batch and later SKUs would be priced differently from earlier ones.
     134    static $memo = null;
     135    static $done = false;
     136
     137    // Any settings write invalidates the memo, so a request that changes the configuration and then
     138    // prices in the same pass cannot use the previous answer.
     139    $stamp = (string) gamerhub_currency_mode() . '|' . gamerhub_fx_source() . '|'
     140        . gamerhub_fx_rate() . '|' . gamerhub_fx_buffer() . '|' . gamerhub_fx_rate_pair()
     141        . '|' . (function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : '');
     142    static $stampSeen = null;
     143    if ($done && $stampSeen === $stamp) {
     144        return $memo;
     145    }
     146
     147    $done      = true;
     148    $stampSeen = $stamp;
     149    $memo      = null;
     150
     151    if (gamerhub_currency_mode() !== 'convert') {
     152        return null;
     153    }
     154
     155    $raw = null;
     156
     157    if (gamerhub_fx_source() === 'manual') {
     158        $manual = gamerhub_fx_rate();
     159        // A manual rate is only meaningful for the pair it was typed for. If the shop has since
     160        // changed its currency, that number now means something else entirely — refuse it.
     161        $pair = gamerhub_fx_rate_pair();
     162        if ($manual > 0 && ($pair === '' || $pair === gamerhub_current_pair())) {
     163            $raw = $manual;
     164        }
     165    } elseif (class_exists('Gamerhub_Sync')) {
     166        $server = Gamerhub_Sync::server_fx_rate();          // ['value'=>float,'age'=>int] | null
     167        if ($server && $server['value'] > 0 && $server['age'] <= gamerhub_fx_max_age()) {
     168            $raw = $server['value'];
     169        }
     170    }
     171
     172    if ($raw === null || $raw <= 0) {
     173        return null;
     174    }
     175
     176    $memo = $raw * (1 + gamerhub_fx_buffer() / 100);
     177    return $memo;
     178}
     179
     180/** The "ACCOUNT>STORE" pair a manual rate was entered for ('' when never recorded). */
     181function gamerhub_fx_rate_pair(): string
     182{
     183    return strtoupper((string) gamerhub_opt('fx_rate_pair', ''));
     184}
     185
     186/** The pair this store needs right now, e.g. "USD>BDT" — '' when it cannot be determined. */
     187function gamerhub_current_pair(): string
     188{
     189    if (! class_exists('Gamerhub_Sync')) {
     190        return '';
     191    }
     192    $mis = Gamerhub_Sync::currency_mismatch();
     193    if (! $mis) {
     194        return '';
     195    }
     196    return strtoupper($mis['api'] . '>' . $mis['wc']);
     197}
     198
     199/**
     200 * Round a finished price UP to the configured step (e.g. 1 → 1247.83 becomes 1248).
     201 * Always upward: a converted price like ৳1,247.83 is unsellable, and rounding down would quietly
     202 * cut into the markup.
     203 */
     204function gamerhub_round_price(float $price): float
     205{
     206    $step = gamerhub_price_rounding();
     207    if ($step <= 0) {
     208        return round($price, 2);   // default: untouched behaviour
     209    }
     210
     211    // Quantise the quotient before ceil(). A markup produces values like 50.00 * 1.10 =
     212    // 55.00000000000001 in IEEE754; feeding that straight into ceil() jumps a whole step
     213    // (55.00 → 60.00 at step 5) and silently overcharges by up to ~9%. Rounding the quotient to 9
     214    // decimals moves the price by at most 5e-8, and only when it is already within that of an exact
     215    // multiple — where ceil() should be a no-op anyway — so it cannot open a below-cost hole.
     216    return round(ceil(round($price / $step, 9)) * $step, 2);
     217}
     218
     219/**
     220 * Convert a GamerHub cost (always in the ACCOUNT currency) into the STORE currency.
     221 *
     222 * This is the ONE place the conversion happens. Every price the plugin ever writes goes through
     223 * gamerhub_apply_markup() below, which calls this first — so a single correct implementation here
     224 * makes catalog sync, single import, promo products, markup edits, bulk edits, the hand-link
     225 * auto-price and the Price column all consistent, with no per-path FX code.
     226 *
     227 * TRULY FAIL-CLOSED — it THROWS rather than returning an unconverted number.
     228 *
     229 * It used to return the cost unchanged when no rate was available, on the assumption that
     230 * currency_error() would already have blocked the request. That assumption is wrong in both
     231 * directions and it is the difference between "no sale" and "sold at 1/122 of cost":
     232 *   - the gate runs ONCE per request, but a catalog sync writes hundreds of prices in a loop
     233 *     afterwards (and ajax_batch has no gate of its own at all);
     234 *   - the same outage that makes the rate unavailable also makes currency_mismatch() return null,
     235 *     so the gate cannot even see the problem.
     236 * A throw stops that product cleanly and surfaces an error; a wrong number sells silently.
     237 *
     238 * It also converts ONLY across a CONFIRMED mismatch. Gating on currency_mode alone meant a store
     239 * whose currencies actually match — but which still had 'convert' selected and an old rate saved —
     240 * multiplied every price by that leftover rate.
     241 *
     242 * @throws RuntimeException when conversion is required but cannot be done honestly.
     243 */
     244function gamerhub_cost_to_store(float $cost): float
     245{
     246    if (gamerhub_currency_mode() !== 'convert') {
     247        return $cost;   // 'block' never reaches a price write; 'ignore' means "use the raw number"
     248    }
     249
     250    // Only convert across a pair we can positively confirm differs. currency_mismatch() returns null
     251    // for BOTH "identical" and "cannot reach the API", so ask for the pair explicitly.
     252    if (gamerhub_current_pair() === '') {
     253        return $cost;   // currencies match (nothing to convert) or unknown — see the guard below
     254    }
     255
     256    $rate = gamerhub_effective_fx_rate();
     257    if ($rate === null || $rate <= 0) {
     258        throw new RuntimeException(
     259            'GamerHub: no usable exchange rate, so no price was written. '
     260            . 'Open the GamerHub menu in WordPress and check "Currency handling".'
     261        );
     262    }
     263
     264    return round($cost * $rate, 6);
     265}
     266
     267/**
    72268 * Retail price for a supplier cost, applying the effective markup.
    73269 * Fallback: per-SKU (variation) override → per-product override → global % (settings).
    74270 * Never returns below cost — a markup is a mark-UP (money-safety backstop).
     271 *
     272 * The incoming $cost is in the GamerHub ACCOUNT currency and is converted to the STORE currency
     273 * FIRST, so the "never below cost" floor below compares like with like.
    75274 */
    76275function gamerhub_apply_markup(float $cost, int $variation_id = 0, int $product_id = 0): float
    77276{
     277    $cost = gamerhub_cost_to_store($cost);   // account currency → store currency (no-op when equal)
     278
    78279    $mk = gamerhub_markup_meta($variation_id);
    79280    if ($mk === null) {
    … …  
    88289        : ($cost * (1 + $mk['value'] / 100));
    89290
    90     return round(max($price, $cost), 2);
     291    // Floor at cost FIRST (a markup is a mark-UP), then round UP — so rounding can only ever move
     292    // the price further above cost, never below it.
     293    return gamerhub_round_price(max($price, $cost));
    91294}
    92295
  • gamerhub-for-woocommerce/trunk/includes/class-gamerhub-checkout.php

    r3626288 r3630953  
    239239            echo '<button type="button" class="button gamerhub-verify-btn" data-product="'
    240240                . esc_attr($verify_code) . '">'
    241                 . esc_html__('Verify ID', 'gamerhub-for-woocommerce') . '</button> ';
     241                . esc_html__('Check my game account', 'gamerhub-for-woocommerce') . '</button> ';
    242242            echo '<span class="gamerhub-verify-result" aria-live="polite"></span>';
    243243            echo '</p>';
    … …  
    363363            'nonce' => wp_create_nonce('gamerhub_verify'),
    364364            'i18n'  => [
    365                 'checking' => __('Checking…', 'gamerhub-for-woocommerce'),
    366                 'enterId'  => __('Enter the Player ID first.', 'gamerhub-for-woocommerce'),
     365                'checking'   => __('Checking…', 'gamerhub-for-woocommerce'),
     366                'enterId'    => __('Please fill in your details above first.', 'gamerhub-for-woocommerce'),
     367                'found'      => __('Account found:', 'gamerhub-for-woocommerce'),
     368                'confirmYou' => __('please check this is you before you buy.', 'gamerhub-for-woocommerce'),
     369                'notFound'   => __('We could not find that account. Please check what you typed and try again.', 'gamerhub-for-woocommerce'),
    367370            ],
    368371        ]);
    … …  
    403406
    404407        if ($product_code === '' || empty($payload)) {
    405             wp_send_json_error(['message' => __('Enter the Player ID first.', 'gamerhub-for-woocommerce')]);
     408            wp_send_json_error(['message' => __('Please fill in your details above first.', 'gamerhub-for-woocommerce')]);
    406409        }
    407410
    … …  
    422425
    423426        wp_send_json_error([
    424             'message' => (string) ($json['message'] ?? __('Player ID / Server Incorrect', 'gamerhub-for-woocommerce')),
     427            'message' => (string) ($json['message'] ?? __('We could not find that account. Please check what you typed and try again.', 'gamerhub-for-woocommerce')),
    425428        ]);
    426429    }
  • gamerhub-for-woocommerce/trunk/includes/class-gamerhub-orders.php

    r3626288 r3630953  
    115115                    $item->save();
    116116                    $detail = ! empty($missing) ? (': ' . implode(', ', $missing)) : '';
    117                     $order->add_order_note(sprintf('GamerHub: "%s" was NOT submitted — player details are missing%s. Contact the customer or refund.', $item->get_name(), $detail));
     117                    $order->add_order_note(sprintf(
     118                    /* translators: 1: product name, 2: extra detail about which field is missing */
     119                    __('GamerHub did not send "%1$s" because the player details are missing%2$s. Nothing was taken from your GamerHub balance. The customer has already paid — ask them for the missing details, or refund the order.', 'gamerhub-for-woocommerce'),
     120                    $item->get_name(),
     121                    $detail
     122                ));
    118123                    $touched = true;
    119124                    continue;
  • gamerhub-for-woocommerce/trunk/includes/class-gamerhub-product-meta.php

    r3626288 r3630953  
    6262                <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option>
    6363            </select>
     64            <span class="gamerhub-denom-info"></span>
    6465        </div>
    6566        <div class="form-row form-row-first">
    … …  
    7172            <input type="text" class="gamerhub-var-scode" name="gamerhub_var_scode[<?php echo esc_attr($loop); ?>]" value="<?php echo esc_attr($scode); ?>" placeholder="freefire-mysg-110" />
    7273        </div>
    73         <p class="form-row form-row-full description" style="margin:4px 0 0;"><?php esc_html_e('Price / markup is managed under GamerHub → Products (expand the game).', 'gamerhub-for-woocommerce'); ?></p>
     74        <p class="form-row form-row-full description" style="margin:4px 0 0;"><?php esc_html_e('To change the price or the profit %, open the GamerHub menu in WordPress and press Prices on this game’s row.', 'gamerhub-for-woocommerce'); ?></p>
    7475        <?php
    7576        // Buyer-field reference + optional PPOM / add-on mapping for THIS variation's linked game.
    … …  
    256257                    <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option>
    257258                </select>
     259                <span class="gamerhub-denom-info"></span>
    258260            </p>
    259261            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    … …  
    272274                <?php echo self::render_form_preview($form); // phpcs:ignore WordPress.Security.EscapeOutput ?>
    273275            </div>
    274             <?php $ap_checked = ($pcode === '') ? true : (get_post_meta($post->ID, '_gamerhub_autoprice', true) === '1'); ?>
     276            <?php
     277            $ap_checked = ($pcode === '') ? true : (get_post_meta($post->ID, '_gamerhub_autoprice', true) === '1');
     278            $mk_val     = (string) get_post_meta($post->ID, '_gamerhub_markup_value', true);
     279            $mk_type    = get_post_meta($post->ID, '_gamerhub_markup_type', true) === 'fixed' ? 'fixed' : 'percent';
     280            $mk_default = (string) gamerhub_opt('markup', '0');
     281            ?>
    275282            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    276                 <label><input type="checkbox" name="gamerhub_autoprice" value="1" <?php checked($ap_checked); ?> /> <strong><?php esc_html_e('Auto-update price & stock from GamerHub', 'gamerhub-for-woocommerce'); ?></strong></label>
    277                 <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: the price follows GamerHub cost × your Default markup and stock stays in sync automatically. Off: set your own fixed price in Product data → General → Regular price.', 'gamerhub-for-woocommerce'); ?></span>
    278             </p>
     283                <label><input type="checkbox" name="gamerhub_autoprice" id="gamerhub_autoprice" value="1" <?php checked($ap_checked); ?> /> <strong><?php esc_html_e('Keep price and stock up to date from GamerHub (recommended)', 'gamerhub-for-woocommerce'); ?></strong></label>
     284                <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: GamerHub sets the price for you — what it costs you, plus the profit below — and marks it sold out when they run out. Careful: switching this on replaces any price you typed, as soon as you save. Off: your own price stays frozen, but so does the stock — if GamerHub runs out, your shop keeps taking orders you cannot deliver. Type your price in Product data → General → Regular price.', 'gamerhub-for-woocommerce'); ?></span>
     285            </p>
     286            <?php // Without this field a hand-linked product could only ever use the global default:
     287                  // the catalog table's inline editor reaches sync-owned games only, and turning
     288                  // auto-pricing off just to change one margin also freezes the sold-out protection. ?>
     289            <p id="gamerhub-mk-row" style="margin-top:8px;<?php echo $ap_checked ? '' : 'display:none;'; ?>">
     290                <label for="gamerhub_markup_value"><strong><?php esc_html_e('Profit for this product', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     291                <input type="number" name="gamerhub_markup_value" id="gamerhub_markup_value" step="0.01" min="0" style="width:90px;" value="<?php echo esc_attr($mk_val); ?>" placeholder="<?php echo esc_attr($mk_default); ?>" />
     292                <select name="gamerhub_markup_type" id="gamerhub_markup_type">
     293                    <option value="percent" <?php selected($mk_type, 'percent'); ?>><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     294                    <option value="fixed" <?php selected($mk_type, 'fixed'); ?>><?php
     295                        /* translators: %s: store currency code, e.g. MYR */
     296                        printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     297                    ?></option>
     298                </select>
     299                <span class="description" style="display:block;margin-top:2px;"><?php
     300                    printf(
     301                        /* translators: %s: the Default profit % from the GamerHub settings page */
     302                        esc_html__('Leave empty to use your Default profit %%, which is %s%%. The price is rewritten as soon as you save.', 'gamerhub-for-woocommerce'),
     303                        esc_html($mk_default)
     304                    );
     305                ?></span>
     306            </p>
     307            <script>
     308            (function () {
     309                var cb = document.getElementById('gamerhub_autoprice'),
     310                    row = document.getElementById('gamerhub-mk-row');
     311                if (cb && row) {
     312                    cb.addEventListener('change', function () { row.style.display = cb.checked ? '' : 'none'; });
     313                }
     314            }());
     315            </script>
    279316            <?php if (! empty($form)) :
    280317                $fmap = json_decode((string) get_post_meta($post->ID, '_gamerhub_field_map', true) ?: '[]', true);
    … …  
    337374        if ($gh_show_hide) : ?>
    338375            <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;">
    339                 <label><input type="checkbox" name="gamerhub_hide_fields" value="1" <?php checked(get_post_meta($post->ID, '_gamerhub_hide_fields', true) === '1'); ?> /> <strong><?php esc_html_e('Use my own fields (PPOM / add-on) — hide GamerHub’s built-in fields', 'gamerhub-for-woocommerce'); ?></strong></label>
    340                 <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('By default GamerHub shows the buyer fields itself (nothing to set up). Turn this on only if a PPOM / product-addons plugin already shows the Player ID / Server fields — it removes our duplicate fields. We still auto-capture the buyer’s values and fulfil.', 'gamerhub-for-woocommerce'); ?></span>
     376                <label><input type="checkbox" name="gamerhub_hide_fields" value="1" <?php checked(get_post_meta($post->ID, '_gamerhub_hide_fields', true) === '1'); ?> /> <strong><?php esc_html_e('Another plugin already asks for the Player ID — hide GamerHub’s own fields', 'gamerhub-for-woocommerce'); ?></strong></label>
     377                <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('Leave this OFF unless you are sure. GamerHub already shows the Player ID / Server boxes for you and nothing needs setting up. Turn it on only if a plugin such as PPOM or Product Add-Ons already shows those boxes and buyers see them twice. Once it is on, GamerHub stops checking that the buyer filled them in — if the details end up missing, the customer can pay and the order will NOT be delivered. Place one test order before you go live.', 'gamerhub-for-woocommerce'); ?></span>
    341378            </p>
    342379        <?php endif; ?>
    … …  
    442479            if (! empty($_POST['gamerhub_autoprice'])) {
    443480                update_post_meta($post_id, '_gamerhub_autoprice', '1');
     481
     482                // This product's own profit. Written BEFORE the price below, because
     483                // gamerhub_apply_markup($cost, 0, $post_id) reads it straight back out of meta —
     484                // set it after and the first save would still price at the global default.
     485                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verifies on woocommerce_process_product_meta.
     486                $mk_in = isset($_POST['gamerhub_markup_value']) ? sanitize_text_field(wp_unslash($_POST['gamerhub_markup_value'])) : '';
     487                if ($mk_in === '') {
     488                    delete_post_meta($post_id, '_gamerhub_markup_value');
     489                    delete_post_meta($post_id, '_gamerhub_markup_type');
     490                } else {
     491                    // phpcs:ignore WordPress.Security.NonceVerification.Missing -- as above.
     492                    $mk_t = (isset($_POST['gamerhub_markup_type']) && $_POST['gamerhub_markup_type'] === 'fixed') ? 'fixed' : 'percent';
     493                    update_post_meta($post_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_in));
     494                    update_post_meta($post_id, '_gamerhub_markup_type', $mk_t);
     495                }
    444496
    445497                // Money safety: the SKU price is in the GamerHub ACCOUNT currency and we do no FX. On a
    … …  
    455507                            $wc = wc_get_product($post_id);
    456508                            if ($wc) {
    457                                 $wc->set_regular_price((string) gamerhub_apply_markup((float) $s['price'], 0, $post_id));
    458                                 $wc->set_price((string) gamerhub_apply_markup((float) $s['price'], 0, $post_id));
    459                                 $wc->set_stock_status(($s['stock_status'] ?? 'in_stock') !== 'out_of_stock' ? 'instock' : 'outofstock');
    460                                 $wc->save();
     509                                // gamerhub_apply_markup() throws when a conversion is required but no
     510                                // honest rate exists. Never let that escape a post save — it would
     511                                // white-screen the product editor. Leave the price alone and tell the
     512                                // owner why instead.
     513                                try {
     514                                    $price = gamerhub_apply_markup((float) $s['price'], 0, $post_id);
     515                                    $wc->set_regular_price((string) $price);
     516                                    $wc->set_price((string) $price);
     517                                    $wc->set_stock_status(($s['stock_status'] ?? 'in_stock') !== 'out_of_stock' ? 'instock' : 'outofstock');
     518                                    $wc->save();
     519                                } catch (\Throwable $e) {
     520                                    set_transient('gamerhub_meta_notice_' . get_current_user_id(), $e->getMessage(), MINUTE_IN_SECONDS);
     521                                }
    461522                            }
    462523                            break;
    … …  
    487548            'nonce' => wp_create_nonce('gamerhub_sync'),
    488549            'cur'   => get_woocommerce_currency(),
     550            'i18n'  => [
     551                'youPay'    => __('You pay', 'gamerhub-for-woocommerce'),
     552                'autoPrice' => __('Auto price', 'gamerhub-for-woocommerce'),
     553                'sells'     => __('Sells', 'gamerhub-for-woocommerce'),
     554            ],
    489555        ]);
    490556    }
  • gamerhub-for-woocommerce/trunk/includes/class-gamerhub-settings.php

    r3626288 r3630953  
    4545            'nonce_sync' => wp_create_nonce('gamerhub_sync'),
    4646            'currency'   => get_woocommerce_currency(),
    47             'symbol'     => get_woocommerce_currency_symbol(),
     47            // Deliberately the CODE, not get_woocommerce_currency_symbol(). Everything on the
     48            // GamerHub admin screens must name the currency the same way: a symbol is ambiguous
     49            // ("$" = USD, SGD, CAD…) and mixing "৳ 98.00" with "BDT 98.00" on one screen is exactly
     50            // the currency confusion these screens exist to prevent.
     51            'symbol'     => get_woocommerce_currency(),
    4852            'i18n'       => [
    4953                'testing'       => __('Testing…', 'gamerhub-for-woocommerce'),
    5054                'loading'       => __('Loading…', 'gamerhub-for-woocommerce'),
    51                 'syncing'       => __('Syncing…', 'gamerhub-for-woocommerce'),
     55                'syncing'       => __('Getting the list…', 'gamerhub-for-woocommerce'),
     56                'working'       => __('Working…', 'gamerhub-for-woocommerce'),
    5257                'done'          => __('Done', 'gamerhub-for-woocommerce'),
    5358                'none'          => __('Tick at least one game.', 'gamerhub-for-woocommerce'),
    54                 'justnow'       => __('catalog synced just now', 'gamerhub-for-woocommerce'),
     59                'justnow'       => __('game list updated just now', 'gamerhub-for-woocommerce'),
    5560                'created'       => __('Created — edit', 'gamerhub-for-woocommerce'),
    56                 'importing'     => __('Importing', 'gamerhub-for-woocommerce'),
    57                 'selected'      => __('selected', 'gamerhub-for-woocommerce'),
    58                 'confirmRemove' => __('Remove the WooCommerce product(s) for the selected game(s)?', 'gamerhub-for-woocommerce'),
     61                'importing'     => __('Adding', 'gamerhub-for-woocommerce'),
     62                'selected'      => __('ticked', 'gamerhub-for-woocommerce'),
     63                'confirmRemove' => __('Delete these products from your shop for good? The WooCommerce products, their photos and any text you wrote are deleted. They do NOT go to the Trash and cannot be brought back. Your GamerHub account is not touched — you can add them again later.', 'gamerhub-for-woocommerce'),
     64
     65                // Everything below used to be hard-coded English inside gamerhub-admin.js, which meant
     66                // the one screen where money is set could never be translated. Read via t() over there.
     67                'thAmount'      => __('Top-up amount', 'gamerhub-for-woocommerce'),
     68                'thCode'        => __('GamerHub code', 'gamerhub-for-woocommerce'),
     69                'thYouPay'      => __('You pay', 'gamerhub-for-woocommerce'),
     70                'thBuyerPays'   => __('Buyer pays', 'gamerhub-for-woocommerce'),
     71                'thStock'       => __('Stock', 'gamerhub-for-woocommerce'),
     72                'noRate'        => __('no exchange rate', 'gamerhub-for-woocommerce'),
     73                'thProfit'      => __('Your profit', 'gamerhub-for-woocommerce'),
     74                'soldOut'       => __('Sold out', 'gamerhub-for-woocommerce'),
     75                'available'     => __('Available', 'gamerhub-for-woocommerce'),
     76                'pctOnCost'     => __('% on top of cost', 'gamerhub-for-woocommerce'),
     77                /* translators: %s: store currency code, e.g. MYR */
     78                'curOnCost'     => sprintf(__('+ %s on top of cost', 'gamerhub-for-woocommerce'), get_woocommerce_currency()),
     79                'save'          => __('Save', 'gamerhub-for-woocommerce'),
     80                'addFirst'      => __('add this game to your shop first', 'gamerhub-for-woocommerce'),
     81                'sellAlone'     => __('Sell this amount on its own', 'gamerhub-for-woocommerce'),
     82                'sellAloneTip'  => __('Creates a SECOND, separate listing in your shop for this one amount only, with its own name and price. The game itself is not changed, and GamerHub never updates this new listing.', 'gamerhub-for-woocommerce'),
     83                'gameProfit'    => __('Profit for this whole game', 'gamerhub-for-woocommerce'),
     84                'saveGameProfit' => __('Save profit for this game', 'gamerhub-for-woocommerce'),
     85                'gameProfitHint' => __('Used for every amount below whose own profit box is empty. An amount that has its own number keeps that number. Leave this empty to use your Default profit %.', 'gamerhub-for-woocommerce'),
     86                'nothingToChange' => __('Nothing to change — fill in at least one box.', 'gamerhub-for-woocommerce'),
     87                'reqFailed'     => __('Could not reach your site. Nothing was changed. Check your connection and try again.', 'gamerhub-for-woocommerce'),
     88                'enterName'     => __('Enter a product name.', 'gamerhub-for-woocommerce'),
     89                'buyerFields'   => __('Details the buyer must enter for this game', 'gamerhub-for-woocommerce'),
     90                'saveMapping'   => __('Save', 'gamerhub-for-woocommerce'),
     91                'connectedBal'  => __('Connected. Your GamerHub balance:', 'gamerhub-for-woocommerce'),
     92                /* translators: %s: GamerHub account currency code, e.g. USD */
     93                'chargedIn'     => __('GamerHub charges you in %s.', 'gamerhub-for-woocommerce'),
     94                'blockedIp'     => __('GamerHub is blocking this website. Sign in to GamerHub, open your API Key page, and add this address to the allowed list:', 'gamerhub-for-woocommerce'),
     95                /* translators: %s: number of games */
     96                'gamesFound'    => __('%s games found. Nothing has been added to your shop yet — tick the games you want, then press “Add to my shop”.', 'gamerhub-for-woocommerce'),
     97                /* translators: 1: number changed, 2: number ticked */
     98                'bulkResult'    => __('Changed %1$s of %2$s — only games already in your shop can be changed.', 'gamerhub-for-woocommerce'),
     99                /* translators: %s: number of games whose price could not be rewritten */
     100                'bulkFailed'    => __('%s could not be repriced — check Currency handling.', 'gamerhub-for-woocommerce'),
    59101            ],
    60102        ]);
    … …  
    80122        if (! in_array($auto_sync, ['off', '1', '3', '6', '12', '24'], true)) {
    81123            $auto_sync = 'off';
     124        }
     125
     126        // How to handle a store-currency ≠ GamerHub-currency mismatch. Anything unrecognised falls
     127        // back to 'block' — an unreadable value must fail CLOSED, never open.
     128        $currency_mode = isset($_POST['currency_mode']) ? sanitize_text_field(wp_unslash($_POST['currency_mode'])) : 'block';
     129        if (! in_array($currency_mode, ['block', 'convert', 'ignore'], true)) {
     130            $currency_mode = 'block';
     131        }
     132        $fx_rate = isset($_POST['fx_rate']) ? (float) $_POST['fx_rate'] : 0;
     133        if ($fx_rate < 0) {
     134            $fx_rate = 0;
     135        }
     136        $fx_source = isset($_POST['fx_source']) ? sanitize_text_field(wp_unslash($_POST['fx_source'])) : 'auto';
     137        if (! in_array($fx_source, ['auto', 'manual'], true)) {
     138            $fx_source = 'auto';
     139        }
     140        $fx_buffer = isset($_POST['fx_buffer']) ? (float) $_POST['fx_buffer'] : 2;
     141        if ($fx_buffer < 0) {
     142            $fx_buffer = 0;
     143        }
     144        $price_rounding = isset($_POST['price_rounding']) ? (float) $_POST['price_rounding'] : 0;
     145        if ($price_rounding < 0) {
     146            $price_rounding = 0;
     147        }
     148        // "Ignore" is genuinely dangerous with auto-price on, so it requires an explicit tick every
     149        // time it is saved. Without it we silently fall back to blocking.
     150        if ($currency_mode === 'ignore' && empty($_POST['currency_ack'])) {
     151            $currency_mode = 'block';
    82152        }
    83153
    … …  
    96166            'auto_sync'      => $auto_sync,
    97167            'auto_import'    => ! empty($_POST['auto_import']) ? '1' : '0',
     168            'currency_mode'  => $currency_mode,
     169            'fx_source'      => $fx_source,
     170            'fx_rate'        => (string) $fx_rate,
     171            // Bind the manual rate to the pair it was typed for. "1 USD = 4.7 MYR" is meaningless
     172            // once the shop switches to BDT, so record the pair and refuse the rate if it changes.
     173            'fx_rate_pair'   => $fx_rate > 0
     174                ? (function_exists('gamerhub_current_pair') ? gamerhub_current_pair() : '')
     175                : '',
     176            'fx_buffer'      => (string) $fx_buffer,
     177            'price_rounding' => (string) $price_rounding,
     178            // Never rewrite from the form — it records what the SYNC last priced in.
     179            'priced_in_currency' => (string) ($existing['priced_in_currency'] ?? ''),
    98180        ];
    99181
    … …  
    153235        }
    154236        $auto_import    = gamerhub_opt('auto_import', '0');
     237        $currency_mode  = gamerhub_currency_mode();
     238        $fx_rate        = gamerhub_opt('fx_rate', '0');
     239        $fx_source      = gamerhub_fx_source();
     240        $fx_buffer      = gamerhub_opt('fx_buffer', '2');
     241        $price_rounding = gamerhub_opt('price_rounding', '0');
    155242        $callback_url   = rest_url('gamerhub/v1/callback');
    156243        $configured     = $api_key !== '' && $api_secret !== '';
    … …  
    178265                <div class="gamerhub-stat">
    179266                    <div class="gamerhub-stat-n"><?php echo esc_html($configured ? '✓' : '—'); ?></div>
    180                     <div class="gamerhub-stat-l"><?php esc_html_e('Connection', 'gamerhub-for-woocommerce'); ?></div>
     267                    <div class="gamerhub-stat-l"><?php esc_html_e('Keys saved (test below)', 'gamerhub-for-woocommerce'); ?></div>
    181268                </div>
    182269                <div class="gamerhub-stat">
    … …  
    225312                        <td>
    226313                            <input name="webhook_secret" id="gamerhub_webhook_secret" type="password" class="regular-text" value="" autocomplete="new-password" placeholder="<?php echo esc_attr($webhook_secret !== '' ? __('•••••••• saved — leave blank to keep', 'gamerhub-for-woocommerce') : __('Not set', 'gamerhub-for-woocommerce')); ?>" />
    227                             <p class="description"><?php esc_html_e('Verifies status callbacks. Must match the Webhook Secret set in the portal. Stored server-side only — leave blank to keep the saved value.', 'gamerhub-for-woocommerce'); ?></p>
     314                            <p class="description"><?php esc_html_e('GamerHub uses this to prove that the delivery messages it sends back to your shop are genuine. It must be EXACTLY the same as the Webhook Secret in your GamerHub account. If the two do not match, GamerHub still delivers and still charges your balance, but your shop never marks the order Complete and the customer never gets their code. We save it but never show it again — leave the box empty to keep the one you already saved.', 'gamerhub-for-woocommerce'); ?></p>
    228315                        </td>
    229316                    </tr>
    230317                    <tr>
    231                         <th scope="row"><label for="gamerhub_markup"><?php esc_html_e('Default markup %', 'gamerhub-for-woocommerce'); ?></label></th>
     318                        <th scope="row"><label for="gamerhub_markup"><?php esc_html_e('Default profit %', 'gamerhub-for-woocommerce'); ?></label></th>
    232319                        <td>
    233320                            <input name="markup" id="gamerhub_markup" type="number" step="0.01" min="0" class="small-text" value="<?php echo esc_attr($markup); ?>" /> %
    234                             <p class="description"><?php esc_html_e('Your profit added on top of cost, used when a product has no markup of its own. e.g. 15 = cost × 1.15. You can override it per product / per denomination under GamerHub → Products.', 'gamerhub-for-woocommerce'); ?></p>
     321                            <p class="description"><?php esc_html_e('How much you add on top of what GamerHub charges you. Example: type 15 and an item that costs you 10.00 is sold at 11.50. Set this before you add any game to your shop. You can give one game, or one single top-up amount, its own profit % further down this page — press Prices on that game’s row.', 'gamerhub-for-woocommerce'); ?></p>
     322                            <?php if ((float) $markup <= 0) : ?>
     323                                <p class="description" style="color:#b32d2e;font-weight:600;margin-top:4px;">
     324                                    <?php esc_html_e('⚠ This is 0, so every game you add is sold at exactly what you pay GamerHub and you earn nothing.', 'gamerhub-for-woocommerce'); ?>
     325                                </p>
     326                            <?php endif; ?>
    235327                        </td>
    236328                    </tr>
    … …  
    246338                                <option value="24"  <?php selected($auto_sync, '24'); ?>><?php esc_html_e('Every 24 hours', 'gamerhub-for-woocommerce'); ?></option>
    247339                            </select>
    248                             <p class="description"><?php esc_html_e('Automatically refreshes prices & stock of your imported products in the background — no manual sync needed.', 'gamerhub-for-woocommerce'); ?></p>
     340                            <p class="description" style="max-width:640px;"><?php esc_html_e('How often the plugin checks GamerHub on its own. Each time, it REWRITES the prices of the games already in your shop using your profit %, marks sold-out items sold out, and adds any new top-up amounts. It also puts the GamerHub name back and puts the product back on sale — so a price or a name you typed by hand on one of these products will be replaced. To protect one game from this, untick “Keep updated” on its row further down.', 'gamerhub-for-woocommerce'); ?></p>
    249341                            <label style="display:inline-block;margin-top:8px;">
    250342                                <input type="checkbox" name="auto_import" value="1" <?php checked($auto_import, '1'); ?> />
    251                                 <?php esc_html_e('Also auto-import newly listed games', 'gamerhub-for-woocommerce'); ?>
     343                                <?php esc_html_e('Also add brand-new games to my shop automatically', 'gamerhub-for-woocommerce'); ?>
    252344                            </label>
     345                            <p class="description" style="margin-top:4px;max-width:640px;">
     346                                <?php esc_html_e('You do NOT need this box to keep your existing games up to date — that already happens above.', 'gamerhub-for-woocommerce'); ?><br />
     347                                <?php esc_html_e('This box only works while the box above is not set to Off. Tick it and every new game GamerHub starts selling appears in your shop by itself, on sale straight away, priced with your Default profit % — you are not asked first, and it can be hundreds of products.', 'gamerhub-for-woocommerce'); ?>
     348                            </p>
     349                        </td>
     350                    </tr>
     351                    <tr>
     352                        <th scope="row"><label for="gamerhub_currency_mode"><?php esc_html_e('Currency handling', 'gamerhub-for-woocommerce'); ?></label></th>
     353                        <td>
     354                            <?php
     355                            $mismatch = class_exists('Gamerhub_Sync') ? Gamerhub_Sync::currency_mismatch() : null;
     356                            if ($mismatch) {
     357                                printf(
     358                                    '<p class="notice notice-warning" style="padding:8px 12px;margin:0 0 10px;">%s</p>',
     359                                    esc_html(sprintf(
     360                                        /* translators: 1: store currency, 2: GamerHub currency */
     361                                        __('Your shop sells in %1$s, but GamerHub charges you in %2$s. A %2$s cost cannot be used as a %1$s price directly — choose what should happen below.', 'gamerhub-for-woocommerce'),
     362                                        $mismatch['wc'],
     363                                        $mismatch['api']
     364                                    ))
     365                                );
     366                            }
     367                            ?>
     368                            <?php
     369                            // Name the real currencies in the options — "convert with my rate" tells a
     370                            // shop owner nothing, "Convert USD prices into BDT" tells them everything.
     371                            $cWc  = $mismatch ? $mismatch['wc']  : __('your currency', 'gamerhub-for-woocommerce');
     372                            $cApi = $mismatch ? $mismatch['api'] : __('the account currency', 'gamerhub-for-woocommerce');
     373                            ?>
     374                            <select name="currency_mode" id="gamerhub_currency_mode">
     375                                <option value="convert" <?php selected($currency_mode, 'convert'); ?>><?php
     376                                    /* translators: 1: GamerHub account currency, 2: store currency */
     377                                    printf(esc_html__('Convert %1$s prices into %2$s', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     378                                ?></option>
     379                                <option value="block"   <?php selected($currency_mode, 'block'); ?>><?php esc_html_e('Pause — do not import or reprice', 'gamerhub-for-woocommerce'); ?></option>
     380                                <option value="ignore"  <?php selected($currency_mode, 'ignore'); ?>><?php
     381                                    /* translators: 1: GamerHub account currency, 2: store currency */
     382                                    printf(esc_html__('Use the %1$s number as the %2$s price (no conversion)', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     383                                ?></option>
     384                            </select>
     385
     386                            <ul style="margin:8px 0 0 2px;list-style:disc;padding-left:18px;font-size:13px;color:#50575e;max-width:640px;">
     387                                <li><strong><?php printf(esc_html__('Convert', 'gamerhub-for-woocommerce')); ?></strong> — <?php
     388                                    /* translators: 1: GamerHub account currency, 2: store currency */
     389                                    printf(esc_html__('each %1$s cost is multiplied by the rate below, then your markup is added. Prices stay correct and you keep selling in %2$s. This is what most shops want.', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     390                                ?></li>
     391                                <li><strong><?php esc_html_e('Pause', 'gamerhub-for-woocommerce'); ?></strong> — <?php esc_html_e('nothing is imported and no price is changed while the two currencies are different. This is the safe default. To start selling, choose Convert above. Do not change your shop currency instead — WooCommerce keeps your saved price numbers and only swaps the label, so your whole shop would suddenly be priced wrong.', 'gamerhub-for-woocommerce'); ?></li>
     392                                <li><strong><?php esc_html_e('No conversion', 'gamerhub-for-woocommerce'); ?></strong> — <?php
     393                                    /* translators: 1: GamerHub account currency, 2: store currency */
     394                                    printf(esc_html__('the number is copied across unchanged, so a cost of 2.50 %1$s becomes a price of 2.50 %2$s. Only safe if you type every price yourself and leave auto-pricing off.', 'gamerhub-for-woocommerce'), esc_html($cApi), esc_html($cWc));
     395                                ?></li>
     396                            </ul>
     397
     398                            <p style="margin-top:12px;">
     399                                <label><strong><?php esc_html_e('Rate source', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     400                                <label style="margin-right:14px;">
     401                                    <input type="radio" name="fx_source" value="auto" <?php checked($fx_source, 'auto'); ?> />
     402                                    <?php esc_html_e('Use GamerHub’s rate — recommended, it updates itself', 'gamerhub-for-woocommerce'); ?>
     403                                </label><br />
     404                                <label>
     405                                    <input type="radio" name="fx_source" value="manual" <?php checked($fx_source, 'manual'); ?> />
     406                                    <?php esc_html_e('Type my own rate below', 'gamerhub-for-woocommerce'); ?>
     407                                </label>
     408                            </p>
     409                            <p class="description" style="max-width:640px;margin-top:-4px;">
     410                                <?php esc_html_e('If GamerHub has no rate, or its rate is more than a day old, the plugin stops importing and stops changing prices until it has a fresh one — so you are never priced from a stale rate.', 'gamerhub-for-woocommerce'); ?>
     411                            </p>
     412
     413                            <?php
     414                            // Show the live automatic rate so the owner can see exactly what is used.
     415                            if ($mismatch && class_exists('Gamerhub_Sync')) {
     416                                $srv = Gamerhub_Sync::server_fx_rate();
     417                                if ($srv && $srv['value'] > 0) {
     418                                    $fresh = $srv['age'] <= DAY_IN_SECONDS;
     419                                    printf(
     420                                        '<p class="description" style="margin:4px 0 0;">%s <strong>1 %s = %s %s</strong> — %s</p>',
     421                                        esc_html__('GamerHub rate:', 'gamerhub-for-woocommerce'),
     422                                        esc_html($mismatch['api']),
     423                                        esc_html(number_format($srv['value'], 4)),
     424                                        esc_html($mismatch['wc']),
     425                                        $fresh
     426                                            ? esc_html(sprintf(
     427                                                /* translators: %s: human-readable age, e.g. "8 mins" */
     428                                                __('updated %s ago', 'gamerhub-for-woocommerce'),
     429                                                human_time_diff(time() - $srv['age'])
     430                                            ))
     431                                            : '<span style="color:#b32d2e;">' . esc_html__('OUT OF DATE — automatic pricing is paused', 'gamerhub-for-woocommerce') . '</span>'
     432                                    );
     433                                } else {
     434                                    printf(
     435                                        '<p class="description" style="margin:4px 0 0;color:#b32d2e;">%s</p>',
     436                                        esc_html__('GamerHub has no rate for this pair right now — use a manual rate, or imports stay paused.', 'gamerhub-for-woocommerce')
     437                                    );
     438                                }
     439                            }
     440                            ?>
     441
     442                            <p style="margin-top:10px;">
     443                                <label for="gamerhub_fx_rate"><strong><?php esc_html_e('My own exchange rate', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     444                                <?php // The direction must be visible even when /balance is unreachable and we
     445                                      // cannot name the two currencies — typing it backwards is a catastrophe. ?>
     446                                <span class="description"><?php
     447                                    printf(
     448                                        /* translators: %s: the GamerHub account currency code, e.g. USD */
     449                                        esc_html__('1 %s =', 'gamerhub-for-woocommerce'),
     450                                        esc_html($cApi)
     451                                    );
     452                                ?></span>
     453                                <input name="fx_rate" id="gamerhub_fx_rate" type="number" step="0.0001" min="0" class="small-text" value="<?php echo esc_attr($fx_rate); ?>" />
     454                                <span class="description"><?php echo esc_html($cWc); ?></span>
     455                            </p>
     456                            <p class="description" style="max-width:640px;">
     457                                <?php esc_html_e('How many units of YOUR shop currency equal 1 unit of the GamerHub currency. Example: 1 USD = 4.70 MYR, so type 4.70 — not 0.21. Only used when you pick “Type my own rate” above. If you pick it and leave this at 0, nothing will be imported.', 'gamerhub-for-woocommerce'); ?>
     458                            </p>
     459
     460                            <p style="margin-top:10px;">
     461                                <label for="gamerhub_fx_buffer"><strong><?php esc_html_e('Rate buffer', 'gamerhub-for-woocommerce'); ?></strong></label>
     462                                <input name="fx_buffer" id="gamerhub_fx_buffer" type="number" step="0.1" min="0" class="small-text" value="<?php echo esc_attr($fx_buffer); ?>" /> %
     463                                <span class="description"><?php esc_html_e('added on top of the rate', 'gamerhub-for-woocommerce'); ?></span>
     464                            </p>
     465                            <p class="description">
     466                                <?php esc_html_e('Your prices stay as they are until the next sync, but exchange rates move every day. This small extra covers that drift — without it, a rate moving the wrong way comes straight out of your profit. 2% is a sensible starting point.', 'gamerhub-for-woocommerce'); ?>
     467                            </p>
     468
     469                            <p style="margin-top:10px;">
     470                                <label for="gamerhub_price_rounding"><strong><?php esc_html_e('Round prices up to', 'gamerhub-for-woocommerce'); ?></strong></label>
     471                                <select name="price_rounding" id="gamerhub_price_rounding">
     472                                    <option value="0"    <?php selected((float) $price_rounding, 0.0); ?>><?php esc_html_e('Off (exact price)', 'gamerhub-for-woocommerce'); ?></option>
     473                                    <option value="0.5"  <?php selected((float) $price_rounding, 0.5); ?>>0.50</option>
     474                                    <option value="1"    <?php selected((float) $price_rounding, 1.0); ?>>1</option>
     475                                    <option value="5"    <?php selected((float) $price_rounding, 5.0); ?>>5</option>
     476                                    <option value="10"   <?php selected((float) $price_rounding, 10.0); ?>>10</option>
     477                                    <option value="100"  <?php selected((float) $price_rounding, 100.0); ?>>100</option>
     478                                </select>
     479                                <span class="description"><?php esc_html_e('with 1: 1,247.83 becomes 1,248 — with 100: it becomes 1,300', 'gamerhub-for-woocommerce'); ?></span>
     480                            </p>
     481                            <p class="description" style="max-width:640px;">
     482                                <?php esc_html_e('This tidies up EVERY price the plugin writes, not only converted ones. Rounding is always upward, never down, so it can never push a price below what you paid. Leave it on “Off (exact price)” unless you want round numbers.', 'gamerhub-for-woocommerce'); ?>
     483                            </p>
     484
     485                            <label style="display:inline-block;margin-top:6px;">
     486                                <?php // Pre-ticked once the mode is already saved, otherwise saving any other
     487                                      // field on this page would silently drop the shop back to Pause. ?>
     488                                <input type="checkbox" name="currency_ack" value="1" <?php checked($currency_mode, 'ignore'); ?> />
     489                                <?php
     490                                printf(
     491                                    /* translators: 1: GamerHub account currency, 2: WooCommerce store currency */
     492                                    esc_html__('I understand: with “no conversion” the GamerHub cost number is copied straight into my shop price, with no exchange rate. A cost of 2.50 %1$s becomes a price of 2.50 %2$s. Products that price themselves can then sell for less than I pay.', 'gamerhub-for-woocommerce'),
     493                                    esc_html($cApi),
     494                                    esc_html($cWc)
     495                                );
     496                                ?>
     497                            </label>
     498                            <p class="description">
     499                                <?php esc_html_e('This box must stay ticked while “no conversion” is chosen. If you untick it and save, the setting goes back to Pause and nothing will be imported or repriced. Only choose “no conversion” if you type every price by hand.', 'gamerhub-for-woocommerce'); ?>
     500                            </p>
    253501                        </td>
    254502                    </tr>
    … …  
    260508            <div class="gamerhub-card">
    261509                <h2><?php esc_html_e('2 · Test the connection', 'gamerhub-for-woocommerce'); ?></h2>
    262                 <p class="description"><?php esc_html_e('Confirms your credentials and that this server\'s IP is whitelisted in the portal.', 'gamerhub-for-woocommerce'); ?></p>
     510                <p class="description" style="max-width:640px;"><?php esc_html_e('Checks that your keys work and that GamerHub accepts requests from this website. It also shows your GamerHub balance and which currency GamerHub charges you in — that is the answer you need for Currency handling above. If it fails, the message shows the exact address you must add to the API Key page in your GamerHub account.', 'gamerhub-for-woocommerce'); ?></p>
    263511                <p>
    264512                    <button type="button" class="button" id="gamerhub-test-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Test connection', 'gamerhub-for-woocommerce'); ?></button>
    … …  
    269517            <div class="gamerhub-card">
    270518                <h2><?php esc_html_e('3 · Products', 'gamerhub-for-woocommerce'); ?></h2>
    271                 <p class="description"><?php esc_html_e('Your catalog is saved here — no need to reload each visit. Click a game name to see its SKU prices, use the row buttons to Import / Re-sync / Remove, or tick several and act in bulk.', 'gamerhub-for-woocommerce'); ?></p>
     519                <p class="description" style="max-width:760px;"><?php esc_html_e('This is the list of games GamerHub can sell for you, saved here so it loads instantly each visit. Press Prices on any row to see that game’s top-up amounts, what you pay and what the buyer pays. Press “Add to my shop” to start selling one. To handle many games at once, tick their boxes and use the buttons above the list.', 'gamerhub-for-woocommerce'); ?></p>
    272520                <p>
    273                     <button type="button" class="button" id="gamerhub-synccat-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Sync catalog', 'gamerhub-for-woocommerce'); ?></button>
     521                    <button type="button" class="button" id="gamerhub-synccat-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Get the game list', 'gamerhub-for-woocommerce'); ?></button>
    274522                    <span id="gamerhub-synccat-result" class="gamerhub-result"></span>
    275523                    <span class="gamerhub-last-sync" id="gamerhub-catalog-time">
    … …  
    279527                            echo esc_html(sprintf(__('· catalog synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($catalog_at)));
    280528                        } else {
    281                             esc_html_e('· not synced yet — click Sync catalog', 'gamerhub-for-woocommerce');
     529                            esc_html_e('· no games loaded yet — press “Get the game list”', 'gamerhub-for-woocommerce');
    282530                        }
    283531                        ?>
    … …  
    296544                        <button type="button" class="button button-primary" id="gamerhub-sync-btn"><?php esc_html_e('Import selected', 'gamerhub-for-woocommerce'); ?></button>
    297545                        <button type="button" class="button" id="gamerhub-bulk-btn"><?php esc_html_e('Bulk edit', 'gamerhub-for-woocommerce'); ?></button>
    298                         <button type="button" class="button" id="gamerhub-remove-btn"><?php esc_html_e('Remove selected', 'gamerhub-for-woocommerce'); ?></button>
     546                        <button type="button" class="button" id="gamerhub-remove-btn"><?php esc_html_e('Delete ticked from shop', 'gamerhub-for-woocommerce'); ?></button>
    299547                        <span id="gamerhub-sync-result" class="gamerhub-result"></span>
    300548                        <input type="search" id="gamerhub-cat-search" class="gamerhub-cat-search" placeholder="<?php esc_attr_e('Search games…', 'gamerhub-for-woocommerce'); ?>" autocomplete="off" style="margin-left:auto;min-width:220px;" />
    … …  
    359607
    360608                    <div id="gamerhub-bulk-panel" class="gamerhub-bulk-panel" style="display:none;">
    361                         <strong><?php esc_html_e('Bulk edit selected:', 'gamerhub-for-woocommerce'); ?></strong>
    362                         <label><?php esc_html_e('Markup:', 'gamerhub-for-woocommerce'); ?>
     609                        <strong><?php esc_html_e('Change all ticked games at once:', 'gamerhub-for-woocommerce'); ?></strong>
     610                        <span class="description" style="display:block;margin:2px 0 6px;"><?php esc_html_e('Changing the profit here changes the prices in your shop straight away. There is no undo. Games that are not in your shop yet are skipped.', 'gamerhub-for-woocommerce'); ?></span>
     611                        <label><?php esc_html_e('Profit:', 'gamerhub-for-woocommerce'); ?>
    363612                            <input type="number" id="gamerhub-bulk-mk-value" step="0.01" min="0" style="width:72px;" placeholder="<?php esc_attr_e('no change', 'gamerhub-for-woocommerce'); ?>" />
    364613                            <select id="gamerhub-bulk-mk-type">
    365                                 <option value="percent">%</option>
    366                                 <option value="fixed"><?php echo esc_html(get_woocommerce_currency_symbol()); ?></option>
     614                                <option value="percent"><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     615                                <option value="fixed"><?php
     616                                    /* translators: %s: store currency code, e.g. MYR */
     617                                    printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     618                                ?></option>
    367619                            </select>
    368620                        </label>
    … …  
    401653                                <th class="gamerhub-cb"></th>
    402654                                <th><?php esc_html_e('Game', 'gamerhub-for-woocommerce'); ?></th>
    403                                 <th><?php esc_html_e('Region', 'gamerhub-for-woocommerce'); ?></th>
    404                                 <th><?php esc_html_e('Type', 'gamerhub-for-woocommerce'); ?></th>
    405                                 <th><?php esc_html_e('Price', 'gamerhub-for-woocommerce'); ?></th>
    406                                 <th><?php esc_html_e('Status', 'gamerhub-for-woocommerce'); ?></th>
     655                                <?php // No price columns here on purpose: a whole game spans every one of its
     656                                      // amounts, so the range (USD 0.04 – 483.06) answers no question anyone
     657                                      // actually has. Press Prices on a row for the exact per-amount figures. ?>
     658                                <th title="<?php esc_attr_e('What you add on top. Set it here even before you add the game to your shop — it will be applied when you do.', 'gamerhub-for-woocommerce'); ?>"><?php esc_html_e('Your profit', 'gamerhub-for-woocommerce'); ?></th>
    407659                                <th><?php esc_html_e('Auto-sync', 'gamerhub-for-woocommerce'); ?></th>
    408660                                <th><?php esc_html_e('Actions', 'gamerhub-for-woocommerce'); ?></th>
    … …  
    416668            <div class="gamerhub-card">
    417669                <h2><?php esc_html_e('Your webhook URL', 'gamerhub-for-woocommerce'); ?></h2>
    418                 <p class="description"><?php esc_html_e('The plugin attaches this to every order automatically — nothing to configure in the portal. It must be reachable over public HTTPS.', 'gamerhub-for-woocommerce'); ?></p>
     670                <p class="description" style="max-width:760px;"><?php esc_html_e('This is the address GamerHub sends delivery confirmations to. The plugin sends it with every order, so there is nothing for you to set up here. It only works if your site is live on a working https:// address — on a local or test site GamerHub cannot reach it and your orders stay in Processing forever. (The Webhook Secret in section 1 is a different thing — that one you do copy from your GamerHub account.)', 'gamerhub-for-woocommerce'); ?></p>
    419671                <p><code class="gamerhub-copy"><?php echo esc_html($callback_url); ?></code></p>
    420672            </div>
    … …  
    422674            <div id="gamerhub-modal" class="gamerhub-modal" style="display:none;">
    423675                <div class="gamerhub-modal-box">
    424                     <h3><?php esc_html_e('Import as product', 'gamerhub-for-woocommerce'); ?></h3>
     676                    <h3><?php esc_html_e('Sell this one amount on its own', 'gamerhub-for-woocommerce'); ?></h3>
     677                    <p class="description" style="max-width:520px;"><?php esc_html_e('This creates a SECOND, separate listing in your shop for this single amount, with its own name and price. The game itself is not changed, and this new listing is never touched again by GamerHub — you keep it up to date yourself.', 'gamerhub-for-woocommerce'); ?></p>
    425678                    <p class="gamerhub-modal-sku"></p>
    426679                    <p>
    … …  
    429682                    </p>
    430683                    <p>
    431                         <label class="gamerhub-modal-autoprice-lbl"><input type="checkbox" id="gamerhub-modal-autoprice" checked /> <strong><?php esc_html_e('Auto-update price & stock from GamerHub', 'gamerhub-for-woocommerce'); ?></strong></label>
    432                         <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('On: the price follows GamerHub cost × your default markup and stock stays in sync (the Price below is ignored). Off: a fixed price you set (good for promos).', 'gamerhub-for-woocommerce'); ?></span>
     684                        <label class="gamerhub-modal-autoprice-lbl"><input type="checkbox" id="gamerhub-modal-autoprice" checked /> <strong><?php esc_html_e('Keep price and stock up to date from GamerHub (recommended)', 'gamerhub-for-woocommerce'); ?></strong></label>
     685                        <span class="description" style="display:block;margin-top:2px;"><?php esc_html_e('Ticked: GamerHub sets the price — what it costs you, plus your profit % — and marks it sold out when they run out. The Price box below is not used. Not ticked: your own price, frozen. If GamerHub raises the cost you sell at a loss, and if they run out your shop keeps taking orders you cannot deliver.', 'gamerhub-for-woocommerce'); ?></span>
    433686                    </p>
    434687                    <p>
    … …  
    468721                    <?php endif; ?>
    469722                    <p>
    470                         <label for="gamerhub-im-mk-value"><strong><?php esc_html_e('Markup', 'gamerhub-for-woocommerce'); ?></strong></label><br />
    471                         <input type="number" id="gamerhub-im-mk-value" step="0.01" min="0" style="width:90px;" placeholder="<?php esc_attr_e('global', 'gamerhub-for-woocommerce'); ?>" />
     723                        <label for="gamerhub-im-mk-value"><strong><?php esc_html_e('Profit', 'gamerhub-for-woocommerce'); ?></strong></label><br />
     724                        <input type="number" id="gamerhub-im-mk-value" step="0.01" min="0" style="width:90px;" placeholder="<?php esc_attr_e('use default', 'gamerhub-for-woocommerce'); ?>" />
    472725                        <select id="gamerhub-im-mk-type">
    473                             <option value="percent">%</option>
    474                             <option value="fixed"><?php echo esc_html(get_woocommerce_currency_symbol()); ?></option>
     726                            <option value="percent"><?php esc_html_e('% on top of cost', 'gamerhub-for-woocommerce'); ?></option>
     727                            <option value="fixed"><?php
     728                                /* translators: %s: store currency code, e.g. MYR */
     729                                printf(esc_html__('+ %s on top of cost', 'gamerhub-for-woocommerce'), esc_html(get_woocommerce_currency()));
     730                            ?></option>
    475731                        </select>
    476732                    </p>
    477                     <p class="description"><?php esc_html_e('Leave blank for the default category & global markup. You can change these anytime with Bulk edit or by expanding the product.', 'gamerhub-for-woocommerce'); ?></p>
     733                    <p class="description" style="max-width:520px;"><?php esc_html_e('Leave these empty to use the default category and your Default profit %. Careful: if you type a profit here and some of the ticked games are already in your shop, this REPLACES the profit they had. You can change it later — tick the game and use “Change all ticked games at once”, or press Prices on its row.', 'gamerhub-for-woocommerce'); ?></p>
    478734                    <p class="gamerhub-modal-actions">
    479735                        <button type="button" class="button button-primary" id="gamerhub-im-confirm"><?php esc_html_e('Import', 'gamerhub-for-woocommerce'); ?></button>
  • gamerhub-for-woocommerce/trunk/includes/class-gamerhub-sync.php

    r3626288 r3630953  
    4343        add_action('admin_post_gamerhub_sync_one', [__CLASS__, 'handle_sync_one']);
    4444        add_action('admin_notices', [__CLASS__, 'row_action_notice']);
     45        add_action('admin_notices', [__CLASS__, 'currency_change_notice']);
    4546    }
    4647
    … …  
    5354        }
    5455
    55         // Store currency must match the GamerHub account currency (no FX here).
    56         $bal = Gamerhub_Client::get('/balance');
    57         if (Gamerhub_Client::ok($bal)) {
    58             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    59             $wc_cur  = strtoupper(get_woocommerce_currency());
    60             if ($api_cur !== '' && $api_cur !== $wc_cur) {
    61                 wp_send_json_error(['message' => sprintf(
    62                     /* translators: 1: store currency, 2: GamerHub currency */
    63                     __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s). Change it under WooCommerce → Settings → General, then load again.', 'gamerhub-for-woocommerce'),
    64                     $wc_cur,
    65                     $api_cur
    66                 )]);
    67             }
     56        // Currency gate — ALWAYS via currency_error(), never an inline comparison, so the owner's
     57        // "Currency handling" choice (convert / pause / no conversion) is honoured everywhere.
     58        if ($err = self::currency_error()) {
     59            wp_send_json_error(['message' => $err]);
    6860        }
    6961
    … …  
    143135        $state = get_transient(self::STATE);
    144136        if (! is_array($state)) {
    145             wp_send_json_error(['message' => __('Sync session expired — load the catalog again.', 'gamerhub-for-woocommerce')]);
     137            wp_send_json_error(['message' => __('This took too long and stopped. Nothing was damaged — the games already added are fine. Press “Get the game list” at the top, tick the ones you still need, and add them again.', 'gamerhub-for-woocommerce')]);
    146138        }
    147139
    … …  
    232224        }
    233225
    234         $labels = [];
     226        // Resolve the FINAL denomination label for every SKU up-front, de-duplicating exactly the way
     227        // the variation loop below does.
     228        //
     229        // These two lists MUST be built from the same computation. They used to diverge: the parent's
     230        // options were array_unique() of the RAW labels while a variation whose label collided got a
     231        // " (sku_code)" suffix — so the second SKU's attribute value did not exist among the parent's
     232        // options and WooCommerce could never match it. The variation was created, priced and in
     233        // stock, but no customer could select it, and nothing reported an error. That happens the
     234        // moment a supplier adds a second SKU sharing a denomination name to a product you already
     235        // imported, which is precisely what auto-sync is supposed to handle.
     236        $labelBySku = [];
     237        $seen_label = [];
    235238        foreach ($skus as $s) {
    236             $labels[] = self::denom_label($s);
    237         }
    238         $labels = array_values(array_unique($labels));
     239            $label = self::denom_label($s);
     240            if (isset($seen_label[$label])) {
     241                $label .= ' (' . (string) $s['code'] . ')';
     242            }
     243            $seen_label[$label]              = true;
     244            $labelBySku[(string) $s['code']] = $label;
     245        }
     246        $labels = array_values($labelBySku);
    239247
    240248        $attr = new WC_Product_Attribute();
    … …  
    266274        }
    267275
    268         // Per-product markup chosen at import → store on the parent. Blank = keep whatever is there,
    269         // so a markup set here (or per-variation below) survives re-sync + auto-sync price refreshes.
    270         if ($mk_value !== '') {
    271             update_post_meta($parent_id, '_gamerhub_markup_type', in_array($mk_type, ['percent', 'fixed'], true) ? $mk_type : 'percent');
    272             update_post_meta($parent_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_value));
    273         }
     276        // Per-product profit chosen at import → store on the parent. Blank = keep whatever is there,
     277        // so a profit set here (or per-variation below) survives re-sync + auto-sync price refreshes.
     278        //
     279        // A profit typed in the catalog table BEFORE the game was added lives in the pending option;
     280        // PROMOTE it here (an explicit import-dialog value still wins) and then drop the entry, so a
     281        // game that exists in the shop only ever has ONE source of truth for its profit.
     282        $mk_effective_type  = $mk_type;
     283        $mk_effective_value = $mk_value;
     284        if ($mk_effective_value === '' && $is_new) {
     285            $pending = self::pending_markup($code);
     286            if ($pending !== null) {
     287                $mk_effective_type  = $pending['type'];
     288                $mk_effective_value = (string) $pending['value'];
     289            }
     290        }
     291        if ($mk_effective_value !== '') {
     292            update_post_meta($parent_id, '_gamerhub_markup_type', in_array($mk_effective_type, ['percent', 'fixed'], true) ? $mk_effective_type : 'percent');
     293            update_post_meta($parent_id, '_gamerhub_markup_value', (string) max(0, (float) $mk_effective_value));
     294        }
     295        self::clear_pending_markup($code);   // the product owns the profit from here on
    274296
    275297        // Brand chosen at import → assign it (by name: reuses an existing term or creates it).
    … …  
    283305
    284306        // ── Variations — matched by _gamerhub_sku_code ──
    285         $prices    = [];
    286         $seen_label = [];
    287         $present    = array_fill_keys(array_column($skus, 'code'), true); // sellable sku_codes this sync
     307        $prices  = [];
     308        $present = array_fill_keys(array_column($skus, 'code'), true); // sellable sku_codes this sync
    288309        foreach ($skus as $sku) {
    289310            $sku_code = (string) $sku['code'];
    290             $label    = self::denom_label($sku);
    291             // guard duplicate labels within one product (attribute options must be unique)
    292             if (isset($seen_label[$label])) {
    293                 $label .= ' (' . $sku_code . ')';
    294             }
    295             $seen_label[$label] = true;
     311            // The SAME label the parent's attribute options were built from — never recompute it here,
     312            // or the two can drift apart again and the variation becomes unselectable.
     313            $label    = $labelBySku[$sku_code] ?? self::denom_label($sku);
    296314
    297315            $cost = (float) $sku['price'];
    … …  
    347365        wc_delete_product_transients($parent_id);
    348366        update_post_meta($parent_id, '_gamerhub_synced_at', time());
     367        self::remember_priced_currency();   // prices were just written in the CURRENT store currency
    349368
    350369        return [
    … …  
    394413    // ── Anti-hammer helpers ─────────────────────────────────────────────
    395414
     415    /**
     416     * The transient key for a cached GET. Callers that bust the cache MUST go through this —
     417     * building the md5 by hand is how the force-refresh silently stopped matching once $query
     418     * was added to the key.
     419     */
     420    private static function cache_key(string $path, array $query = []): string
     421    {
     422        return 'gamerhub_c_' . md5($path . '|' . wp_json_encode($query));
     423    }
     424
    396425    /** Cached GET for catalog endpoints — repeated loads/syncs reuse the cache instead of re-hitting the API. */
    397     private static function cached_get(string $path, int $ttl = 600)
    398     {
    399         $key    = 'gamerhub_c_' . md5($path);
     426    private static function cached_get(string $path, int $ttl = 600, array $query = [])
     427    {
     428        $key    = self::cache_key($path, $query);
    400429        $cached = get_transient($key);
    401430        if (is_array($cached)) {
    402431            return $cached;
    403432        }
    404         $r = Gamerhub_Client::get($path);
     433        $r = Gamerhub_Client::get($path, $query);
    405434        if (Gamerhub_Client::ok($r)) {
    406435            set_transient($key, $r, $ttl);
    … …  
    409438    }
    410439
     440    /**
     441     * The conversion rate GamerHub publishes for THIS store's currency, via /balance.
     442     *
     443     * Rides the /balance call the plugin already makes (cached 5 min), so it costs no extra request.
     444     * The server only includes `rate` when we ask for it, so nothing changes for anyone who doesn't.
     445     *
     446     * @return array{value:float,age:int,to:string}|null  null when the server has no usable rate
     447     */
     448    public static function server_fx_rate(): ?array
     449    {
     450        $store = strtoupper((string) get_woocommerce_currency());
     451        if ($store === '') {
     452            return null;
     453        }
     454
     455        $bal = self::cached_get('/balance', 300, ['store_currency' => $store]);
     456        if (! Gamerhub_Client::ok($bal)) {
     457            return null;
     458        }
     459
     460        $rate = $bal['json']['data']['rate'] ?? null;
     461        if (! is_array($rate) || ! isset($rate['value'])) {
     462            return null;   // older API, or no rate for this pair
     463        }
     464
     465        $value = (float) $rate['value'];
     466        if ($value <= 0) {
     467            return null;
     468        }
     469
     470        // Age drives the staleness refusal upstream. An absent timestamp is treated as UNKNOWN age
     471        // (PHP_INT_MAX) so it can never pass a freshness check by omission.
     472        //
     473        // EXCEPT the identity rate: when the store currency equals the account currency the server
     474        // answers value = 1 with no timestamp, and a 1:1 rate cannot go stale — 1 USD is always
     475        // 1 USD. Calling that "infinitely old" would be wrong the moment this age is reused.
     476        $age = PHP_INT_MAX;
     477        if (! empty($rate['updated_at'])) {
     478            $ts = strtotime((string) $rate['updated_at']);
     479            if ($ts) {
     480                $age = max(0, time() - $ts);
     481            }
     482        } elseif (abs($value - 1.0) < 1e-9) {
     483            $age = 0;
     484        }
     485
     486        return ['value' => $value, 'age' => $age, 'to' => (string) ($rate['to'] ?? $store)];
     487    }
     488
    411489    /** Store currency must equal the GamerHub account currency (no FX on import). Returns an error message, or null if OK/unknown. */
    412490    public static function currency_error(): ?string
    413491    {
     492        $mis = self::currency_mismatch();
     493        if ($mis === null) {
     494            return null;   // currencies match, or we could not determine them
     495        }
     496
     497        $mode = gamerhub_currency_mode();
     498
     499        // 'ignore' — the owner has explicitly accepted responsibility for pricing.
     500        if ($mode === 'ignore') {
     501            return null;
     502        }
     503
     504        // 'convert' — allowed ONLY with a usable rate. No rate, or a stale automatic one, keeps
     505        // BLOCKING: the plugin must never write a raw foreign number while merely *claiming* to
     506        // convert, and must never price from a rate old enough to have drifted.
     507        if ($mode === 'convert') {
     508            if (gamerhub_effective_fx_rate() !== null) {
     509                return null;
     510            }
     511
     512            if (gamerhub_fx_source() === 'manual') {
     513                return sprintf(
     514                    /* translators: 1: GamerHub currency, 2: store currency */
     515                    __('You chose to type your own exchange rate, but the rate box is still empty, so nothing was imported and no price was changed. Open the GamerHub menu and type how many %2$s one %1$s is worth.', 'gamerhub-for-woocommerce'),
     516                    $mis['api'],
     517                    $mis['wc']
     518                );
     519            }
     520
     521            return sprintf(
     522                /* translators: 1: GamerHub currency, 2: store currency */
     523                __('GamerHub has no up-to-date %1$s → %2$s exchange rate right now, so no price was written — this protects you from pricing off a stale rate. Wait a few minutes and try again. If it keeps happening, open the GamerHub menu and choose “Type my own rate”.', 'gamerhub-for-woocommerce'),
     524                $mis['api'],
     525                $mis['wc']
     526            );
     527        }
     528
     529        return sprintf(
     530            /* translators: 1: store currency, 2: GamerHub currency */
     531            __('Your shop sells in %1$s but GamerHub charges you in %2$s, so nothing was imported and no price was changed. Open the GamerHub menu and, under “Currency handling”, choose “Convert %2$s prices into %1$s”.', 'gamerhub-for-woocommerce'),
     532            $mis['wc'],
     533            $mis['api']
     534        );
     535    }
     536
     537    /**
     538     * Remember which WooCommerce currency the catalog prices were last WRITTEN in.
     539     *
     540     * WHY THIS MATTERS MORE THAN THE RATE ITSELF: WooCommerce stores a price as a bare number and
     541     * labels it with whatever the store currency currently is. If the owner switches the store from
     542     * MYR to BDT after importing, every imported price keeps its number and silently changes meaning
     543     * — RM 11.75 becomes ৳11.75, roughly a tenth of its worth — with no error anywhere. Recording
     544     * the currency at write time is what lets priced_currency_warning() catch that.
     545     */
     546    public static function remember_priced_currency(): void
     547    {
     548        $settings = get_option(Gamerhub_Settings::OPTION, []);
     549        $settings = is_array($settings) ? $settings : [];
     550        $cur      = strtoupper((string) get_woocommerce_currency());
     551
     552        if ($cur !== '' && ($settings['priced_in_currency'] ?? '') !== $cur) {
     553            $settings['priced_in_currency'] = $cur;
     554            update_option(Gamerhub_Settings::OPTION, $settings);
     555        }
     556    }
     557
     558    /**
     559     * A warning when the store currency changed after prices were written, or null.
     560     *
     561     * @return array{was:string,now:string}|null
     562     */
     563    public static function priced_currency_warning(): ?array
     564    {
     565        $was = strtoupper((string) gamerhub_opt('priced_in_currency', ''));
     566        $now = strtoupper((string) get_woocommerce_currency());
     567
     568        if ($now === '') {
     569            return null;
     570        }
     571
     572        // FAST PATH FIRST. This runs on admin_notices, i.e. on every single admin page, and the
     573        // check below is an unbounded product query. Two option reads answer it for every store
     574        // whose currency has not changed — which is all of them, almost all of the time.
     575        if ($was !== '' && $was === $now) {
     576            return null;
     577        }
     578
     579        // Only meaningful if something is actually priced. (Reached only when the currency really
     580        // differs, or on the one-off self-arming pass below.)
     581        if (empty(self::imported_codes())) {
     582            return null;
     583        }
     584
     585        // SELF-ARMING for stores that imported BEFORE this tracking existed — i.e. every install
     586        // upgrading from an older version, which is exactly the group most exposed. We cannot know
     587        // what currency those prices were written in, so we adopt the current one as the baseline.
     588        // No warning this time (there is nothing to compare against yet), but any LATER change is
     589        // caught. Without this the protection would stay dormant until the next full sync.
     590        if ($was === '') {
     591            self::remember_priced_currency();
     592            return null;
     593        }
     594
     595        if ($was === $now) {
     596            return null;
     597        }
     598
     599        return ['was' => $was, 'now' => $now];
     600    }
     601
     602    /**
     603     * gamerhub_cost_to_store() for DISPLAY paths: returns null instead of throwing.
     604     *
     605     * A read-only screen must never take the whole panel down, but it must also never fall back to
     606     * the raw account-currency number — that is exactly the mislabelled figure this feature exists
     607     * to prevent. Null renders as an em dash.
     608     */
     609    private static function safeCostToStore(float $cost): ?float
     610    {
     611        try {
     612            return gamerhub_cost_to_store($cost);
     613        } catch (\Throwable $e) {
     614            return null;
     615        }
     616    }
     617
     618    /**
     619     * A cost expressed in the STORE currency, or null when that figure does not exist.
     620     *
     621     * Null both when there is nothing to convert (the two currencies are the same) and when we
     622     * CANNOT convert — no confirmed mismatch because /balance is unreachable, or no usable rate.
     623     *
     624     * The distinction matters: gamerhub_cost_to_store() deliberately no-ops when there is no
     625     * confirmed mismatch, so it hands back the untouched account-currency number. A caller that
     626     * prints that under the store's currency label produces exactly the mislabelled figure this
     627     * whole column exists to prevent — and one transient /balance failure is enough to cause it,
     628     * because the currency CODE beside it is read from the cache and still says USD. Callers must
     629     * render nothing at all rather than fall back.
     630     */
     631    private static function cost_in_store_currency(float $cost): ?float
     632    {
     633        if (! function_exists('gamerhub_current_pair') || gamerhub_current_pair() === '') {
     634            return null;
     635        }
     636        return self::safeCostToStore($cost);
     637    }
     638
     639    /**
     640     * The raw currency comparison, independent of how the store chose to handle it.
     641     *
     642     * @return array{wc:string,api:string}|null  null when they match or the API answer is unavailable
     643     */
     644    /**
     645     * The currency GamerHub charges THIS merchant in, '' when it cannot be determined.
     646     *
     647     * currency_mismatch() only reports the account currency when it DIFFERS from the store's, but
     648     * the admin screens need to label a cost figure with its real currency even when the two match.
     649     * Rides the same cached /balance call, so it costs no extra request.
     650     */
     651    public static function account_currency(): string
     652    {
    414653        $bal = self::cached_get('/balance', 300);
    415654        if (! Gamerhub_Client::ok($bal)) {
    416             return null; // can't determine → don't block
    417         }
    418         $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
     655            return '';
     656        }
     657        return strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
     658    }
     659
     660    public static function currency_mismatch(): ?array
     661    {
     662        $api_cur = self::account_currency();
    419663        $wc_cur  = strtoupper(get_woocommerce_currency());
    420         if ($api_cur !== '' && $api_cur !== $wc_cur) {
    421             return sprintf(
    422                 /* translators: 1: store currency, 2: GamerHub currency */
    423                 __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s).', 'gamerhub-for-woocommerce'),
    424                 $wc_cur,
    425                 $api_cur
    426             );
    427         }
    428         return null;
     664
     665        if ($api_cur === '' || $api_cur === $wc_cur) {
     666            return null;
     667        }
     668        return ['wc' => $wc_cur, 'api' => $api_cur];
    429669    }
    430670
    … …  
    460700    }
    461701
     702    // ─────────────────────────────────────────────────────────────────────────────────────────
     703    //  PENDING PROFIT — a profit % chosen for a game that is NOT in the shop yet.
     704    //
     705    //  The real profit lives in post meta on the WooCommerce product, so before a game is added
     706    //  there is nowhere to put it. This option holds it until then. On import it is PROMOTED to
     707    //  product meta and the entry is dropped, so there is never more than one source of truth for
     708    //  a game that exists in the shop.
     709    // ─────────────────────────────────────────────────────────────────────────────────────────
     710
     711    const PENDING_MK = 'gamerhub_pending_markup';
     712
     713    /** @return array<string,array{type:string,value:float}> */
     714    private static function pending_markup_all(): array
     715    {
     716        $all = get_option(self::PENDING_MK, []);
     717        return is_array($all) ? $all : [];
     718    }
     719
     720    /** @return array{type:string,value:float}|null */
     721    public static function pending_markup(string $code): ?array
     722    {
     723        $row = self::pending_markup_all()[$code] ?? null;
     724        if (! is_array($row) || ! isset($row['value']) || $row['value'] === '') {
     725            return null;
     726        }
     727        return [
     728            'type'  => (isset($row['type']) && $row['type'] === 'fixed') ? 'fixed' : 'percent',
     729            'value' => max(0, (float) $row['value']),
     730        ];
     731    }
     732
     733    /** Store (or, with a blank $value, clear) the pre-import profit for one game. */
     734    public static function set_pending_markup(string $code, string $type, string $value): void
     735    {
     736        $all = self::pending_markup_all();
     737        if ($value === '') {
     738            unset($all[$code]);
     739        } else {
     740            $all[$code] = [
     741                'type'  => $type === 'fixed' ? 'fixed' : 'percent',
     742                'value' => max(0, (float) $value),
     743            ];
     744        }
     745        update_option(self::PENDING_MK, $all, false);
     746    }
     747
     748    /** Drop one game's pending profit (called once the product owns it). */
     749    private static function clear_pending_markup(string $code): void
     750    {
     751        $all = self::pending_markup_all();
     752        if (array_key_exists($code, $all)) {
     753            unset($all[$code]);
     754            update_option(self::PENDING_MK, $all, false);
     755        }
     756    }
     757
     758    /** Keep the option from growing forever: drop entries for codes the catalog no longer lists. */
     759    private static function prune_pending_markup(array $valid_codes): void
     760    {
     761        $all = self::pending_markup_all();
     762        if (! $all) {
     763            return;
     764        }
     765        $keep = array_intersect_key($all, array_fill_keys($valid_codes, true));
     766        if (count($keep) !== count($all)) {
     767            update_option(self::PENDING_MK, $keep, false);
     768        }
     769    }
     770
     771    /**
     772     * The profit that will actually be applied to this game's amounts that have no override of
     773     * their own — i.e. gamerhub_apply_markup()'s fallback chain, at the product level.
     774     *
     775     * MUST mirror that chain exactly. A column that shows one number while the price is written
     776     * from another is worse than showing nothing.
     777     *
     778     * @return array{type:string,value:float,source:string}  source: product|pending|default
     779     */
     780    public static function effective_markup(string $code, int $parent_id = 0): array
     781    {
     782        if ($parent_id > 0) {
     783            $mk = gamerhub_markup_meta($parent_id);
     784            if ($mk !== null) {
     785                return $mk + ['source' => 'product'];
     786            }
     787            // Imported and no product-level profit → the global default. A pending entry must NOT
     788            // win here: the product exists, so product meta (or its absence) is authoritative.
     789            return ['type' => 'percent', 'value' => max(0, (float) gamerhub_opt('markup', '0')), 'source' => 'default'];
     790        }
     791
     792        $pending = self::pending_markup($code);
     793        if ($pending !== null) {
     794            return $pending + ['source' => 'pending'];
     795        }
     796        return ['type' => 'percent', 'value' => max(0, (float) gamerhub_opt('markup', '0')), 'source' => 'default'];
     797    }
     798
     799    /**
     800     * Parent ids that have at least one variation carrying its own profit override.
     801     *
     802     * Deliberately computed for ALL parents in ONE query: the catalog table renders a hint per row,
     803     * and doing this per row would load a full product object and re-read meta for every variation
     804     * of every imported game. 'id=>parent' is the whole point — it answers the question directly.
     805     *
     806     * @param int[] $parent_ids
     807     * @return array<int,true>
     808     */
     809    private static function sku_markup_override_parents(array $parent_ids): array
     810    {
     811        $parent_ids = array_values(array_filter(array_map('intval', $parent_ids)));
     812        if (! $parent_ids) {
     813            return [];
     814        }
     815        $rows = get_posts([
     816            'post_type'        => 'product_variation',
     817            'post_status'      => 'any',
     818            'numberposts'      => -1,
     819            'fields'           => 'id=>parent',
     820            'post_parent__in'  => $parent_ids,
     821            'meta_query'       => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin-only, one query for the whole table.
     822                ['key' => '_gamerhub_markup_value', 'value' => '', 'compare' => '!='],
     823            ],
     824        ]);
     825        return array_fill_keys(array_map('intval', array_values((array) $rows)), true);
     826    }
     827
    462828    /** code => WC product id for imported VARIABLE products (excludes manual/promo). */
    463829    public static function imported_map(): array
    … …  
    467833            ['key' => '_gamerhub_manual', 'compare' => 'NOT EXISTS'],
    468834        ]]);
     835        // 'fields' => 'ids' skips WP's meta priming, so the loop below would be one query per
     836        // product. Prime it in a single query instead.
     837        if ($ids) {
     838            update_meta_cache('post', $ids);
     839        }
     840
    469841        $map = [];
    470842        foreach ($ids as $id) {
    … …  
    490862    public static function refresh_imported(): void
    491863    {
    492         // Never mis-price on a currency mismatch.
    493         $bal = self::cached_get('/balance', 300);
    494         if (Gamerhub_Client::ok($bal)) {
    495             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    496             if ($api_cur !== '' && $api_cur !== strtoupper(get_woocommerce_currency())) {
    497                 gamerhub_log('auto-sync skipped: currency mismatch');
    498                 return;
    499             }
     864        // Never mis-price on a currency problem — but go through currency_error() so a store that
     865        // has chosen to convert (or to price manually) is NOT silently skipped forever. This ran on
     866        // a raw comparison before, which meant auto-sync did nothing at all, with no visible sign,
     867        // for any store whose currency differed — even one correctly set up to convert.
     868        if ($err = self::currency_error()) {
     869            gamerhub_log('auto-sync skipped: ' . $err);
     870            return;
    500871        }
    501872
    … …  
    6401011        $autoprice = get_post_meta($product_id, '_gamerhub_autoprice', true) === '1';
    6411012        if ($manual && ! $autoprice) {
    642             return ['ok' => false, 'msg' => __('Auto-update is off for this product — it keeps the fixed price you set.', 'gamerhub-for-woocommerce')];
     1013            // Correct, expected behaviour — returning ok=false painted it as a red error notice.
     1014            return ['ok' => true, 'msg' => __('Nothing to update — you chose a fixed price for this product, so GamerHub does not change it. To let GamerHub set the price, tick “Keep price and stock up to date from GamerHub” in the GamerHub box on this product.', 'gamerhub-for-woocommerce')];
    6431015        }
    6441016        // Never mis-price on a currency mismatch (same guard as every other import/sync path).
    … …  
    7081080    }
    7091081
     1082    /**
     1083     * Loud, undismissable warning when the store currency changed after prices were written.
     1084     *
     1085     * This is the single most damaging currency mistake available: every imported price keeps its
     1086     * number and silently changes meaning, so the whole catalog can end up on sale at a fraction of
     1087     * cost with nothing in the logs. Shown on every admin screen until it is resolved.
     1088     */
     1089    public static function currency_change_notice(): void
     1090    {
     1091        if (! current_user_can('manage_woocommerce')) {
     1092            return;
     1093        }
     1094        $w = self::priced_currency_warning();
     1095        if (! $w) {
     1096            return;
     1097        }
     1098
     1099        $url = admin_url('admin.php?page=gamerhub');
     1100
     1101        echo '<div class="notice notice-error"><p><strong>'
     1102            . esc_html__('GamerHub: your store currency changed after products were imported.', 'gamerhub-for-woocommerce')
     1103            . '</strong></p><p>'
     1104            . esc_html(sprintf(
     1105                /* translators: 1: old currency, 2: new currency */
     1106                __('Prices were written in %1$s but your store now sells in %2$s. Those product prices are the OLD numbers with a NEW label, so they are almost certainly wrong. Re-sync your products to reprice them before taking orders.', 'gamerhub-for-woocommerce'),
     1107                $w['was'],
     1108                $w['now']
     1109            ))
     1110            . '</p><p><a class="button button-primary" href="' . esc_url($url) . '">'
     1111            . esc_html__('Open GamerHub and re-import my products', 'gamerhub-for-woocommerce')
     1112            . '</a></p></div>';
     1113    }
     1114
    7101115    /** Admin notice after a single-product sync — status is carried in our own redirect's query args. */
    7111116    public static function row_action_notice(): void
    … …  
    7351140        }
    7361141
    737         $bal = Gamerhub_Client::get('/balance');
    738         if (Gamerhub_Client::ok($bal)) {
    739             $api_cur = strtoupper((string) ($bal['json']['data']['currency'] ?? ''));
    740             $wc_cur  = strtoupper(get_woocommerce_currency());
    741             if ($api_cur !== '' && $api_cur !== $wc_cur) {
    742                 wp_send_json_error(['message' => sprintf(
    743                     /* translators: 1: store currency, 2: GamerHub currency */
    744                     __('Your WooCommerce currency (%1$s) must match your GamerHub account currency (%2$s).', 'gamerhub-for-woocommerce'),
    745                     $wc_cur,
    746                     $api_cur
    747                 )]);
    748             }
     1142        // Same single gate as every other price-touching path (honours the currency mode).
     1143        if ($err = self::currency_error()) {
     1144            wp_send_json_error(['message' => $err]);
    7491145        }
    7501146
    … …  
    7651161        update_option('gamerhub_catalog', ['at' => time(), 'games' => $games]);
    7661162        // wipe cached detail so expanded prices are fresh next time
    767         delete_transient('gamerhub_c_' . md5('/products'));
     1163        delete_transient(self::cache_key('/products'));
     1164        // Drop parked profits for games GamerHub no longer lists, so the option cannot grow forever.
     1165        self::prune_pending_markup(array_column($games, 'code'));
    7681166
    7691167        wp_send_json_success(['at' => time(), 'count' => count($games), 'html' => self::render_catalog_rows()]);
    … …  
    7771175    }
    7781176
    779     /** Server-rendered table body: catalog rows + any delisted (imported-but-gone) rows. */
    780     public static function render_catalog_rows(): string
     1177    /**
     1178     * Server-rendered table body: catalog rows + any delisted (imported-but-gone) rows.
     1179     *
     1180     * @param string $only Render just this one game's row. Used by the inline editors, which would
     1181     *                     otherwise ship the entire table (hundreds of KB) on every keystroke-save.
     1182     */
     1183    public static function render_catalog_rows(string $only = ''): string
    7811184    {
    7821185        $cat   = self::get_catalog();
    7831186        $games = $cat['games'] ?? [];
     1187        if ($only !== '') {
     1188            $games = array_values(array_filter($games, static fn($g) => (string) ($g['code'] ?? '') === $only));
     1189        }
    7841190        $map   = self::imported_map();
    7851191        $btax  = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : '';
    7861192        $seen  = [];
    7871193
    788         // Cached retail price ranges (filled when a game's "Prices" panel is opened — no import needed).
    789         $priceCache = get_option('gamerhub_price_cache', []);
    790         $priceCache = is_array($priceCache) ? $priceCache : [];
    791         $curCode    = strtoupper(get_woocommerce_currency());
    792         $curSym     = html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8');
    793         $priceCell  = static function (string $code) use ($priceCache, $curCode, $curSym): string {
    794             $prc = $priceCache[$code] ?? null;
    795             if (is_array($prc) && isset($prc['min']) && (empty($prc['cur']) || strtoupper((string) $prc['cur']) === $curCode)) {
    796                 $min = (float) $prc['min'];
    797                 $max = isset($prc['max']) ? (float) $prc['max'] : $min;
    798                 return esc_html($curSym . ' ' . number_format($min, 2) . ($max > $min ? ' – ' . number_format($max, 2) : ''));
    799             }
    800             return '<span style="color:#c3c4c7;">—</span>';
     1194        $curCode = strtoupper(get_woocommerce_currency());
     1195
     1196        // The profit that will be applied — editable right here, whether or not the game is in the
     1197        // shop yet (a value set before import is parked and applied on import).
     1198        $globalMk  = max(0, (float) gamerhub_opt('markup', '0'));
     1199        $overrideP = self::sku_markup_override_parents(array_values($map));   // ONE query for the whole table
     1200        $mkCell    = static function (string $code, int $parentId) use ($curCode, $globalMk, $overrideP): string {
     1201            $eff       = self::effective_markup($code, $parentId);
     1202            $isSet     = $eff['source'] !== 'default';
     1203            $val       = $isSet ? (string) (0 + $eff['value']) : '';
     1204            $overrides = $parentId > 0 && isset($overrideP[$parentId]);
     1205
     1206            $out  = '<div class="gamerhub-mk-inline">';
     1207            $out .= '<input type="number" class="gamerhub-row-mk-value" step="0.01" min="0" value="' . esc_attr($val) . '"'
     1208                 . ' placeholder="' . esc_attr(number_format($globalMk, 2)) . '"'
     1209                 . ' title="' . esc_attr__('Leave empty to use your Default profit %.', 'gamerhub-for-woocommerce') . '" /> ';
     1210            $out .= '<select class="gamerhub-row-mk-type">'
     1211                 . '<option value="percent"' . selected($eff['type'], 'percent', false) . '>%</option>'
     1212                 . '<option value="fixed"' . selected($eff['type'], 'fixed', false) . '>+' . esc_html($curCode) . '</option>'
     1213                 . '</select> ';
     1214            $out .= '<button type="button" class="button gamerhub-save-row-mk" data-code="' . esc_attr($code) . '">'
     1215                 . esc_html__('Save', 'gamerhub-for-woocommerce') . '</button>';
     1216            $out .= '<span class="gamerhub-row-mk-result gamerhub-result"></span>';
     1217
     1218            if ($eff['source'] === 'pending') {
     1219                $out .= '<br /><span class="description" style="font-size:11px;">'
     1220                     . esc_html__('will apply when you add this game', 'gamerhub-for-woocommerce') . '</span>';
     1221            } elseif (! $isSet) {
     1222                $out .= '<br /><span class="description" style="font-size:11px;">'
     1223                     . esc_html__('using your default', 'gamerhub-for-woocommerce') . '</span>';
     1224            }
     1225            if ($overrides) {
     1226                $out .= '<br /><span class="description" style="font-size:11px;">'
     1227                     . esc_html__('some amounts have their own — see Prices', 'gamerhub-for-woocommerce') . '</span>';
     1228            }
     1229            return $out . '</div>';
    8011230        };
    8021231
    … …  
    8281257            <tr data-code="<?php echo esc_attr($code); ?>" data-type="<?php echo esc_attr($g['type']); ?>" data-region="<?php echo esc_attr($g['region']); ?>" data-imported="<?php echo $imported ? '1' : '0'; ?>" data-brand="<?php echo esc_attr($brandNames); ?>" data-cat="<?php echo esc_attr($catNames); ?>">
    8291258                <td class="gamerhub-cb"><input type="checkbox" class="gamerhub-pick" value="<?php echo esc_attr($code); ?>" /></td>
    830                 <td><strong><?php echo esc_html($g['name']); ?></strong><br><code style="font-size:11px;color:#787c82;"><?php echo esc_html($code); ?></code></td>
    831                 <td><?php echo esc_html($g['region']); ?></td>
    832                 <td><?php echo $g['type'] === 'voucher' ? '🎟' : '🎮'; ?></td>
    833                 <td class="gamerhub-price gamerhub-price-cell" data-code="<?php echo esc_attr($code); ?>"><span class="gamerhub-price-val"><?php echo $priceCell($code); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns esc_html'd text or a trusted static dash. ?></span> <a href="#" class="gamerhub-price-refresh" data-code="<?php echo esc_attr($code); ?>" title="<?php esc_attr_e('Refresh this game’s price', 'gamerhub-for-woocommerce'); ?>">&#8635;</a></td>
     1259                <?php // Region, Type and Status used to be three columns of near-empty text ("—", one
     1260                      // emoji, one badge) costing ~200px between them. Folded in here so the money
     1261                      // columns fit on screen without a horizontal scrollbar; both Region and Type
     1262                      // still have their own dropdown filter above the table. ?>
     1263                <td class="gamerhub-game-cell">
     1264                    <strong><?php echo esc_html($g['name']); ?></strong><br />
     1265                    <code><?php echo esc_html($code); ?></code>
     1266                    <span class="gamerhub-game-meta">
     1267                        <span title="<?php echo esc_attr($g['type'] === 'voucher' ? __('Voucher', 'gamerhub-for-woocommerce') : __('Game top-up', 'gamerhub-for-woocommerce')); ?>"><?php echo $g['type'] === 'voucher' ? '🎟' : '🎮'; ?></span>
     1268                        <?php if ($g['region'] !== '' && $g['region'] !== '—') : ?>
     1269                            · <?php echo esc_html($g['region']); ?>
     1270                        <?php endif; ?>
     1271                        ·
     1272                        <?php if ($imported) : ?>
     1273                            <a href="<?php echo esc_url($edit); ?>"><span class="gamerhub-badge ok"><?php esc_html_e('✓ In my shop', 'gamerhub-for-woocommerce'); ?></span></a>
     1274                        <?php else : ?>
     1275                            <span style="color:#8c8f94;"><?php esc_html_e('not added', 'gamerhub-for-woocommerce'); ?></span>
     1276                        <?php endif; ?>
     1277                    </span>
     1278                </td>
     1279                <td class="gamerhub-mk-row-cell"><?php echo $mkCell($code, $imported ? (int) $map[$code] : 0); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper escapes every interpolated value. ?></td>
    8341280                <td>
    8351281                    <?php if ($imported) : ?>
    836                         <a href="<?php echo esc_url($edit); ?>"><span class="gamerhub-badge ok"><?php esc_html_e('✓ Imported', 'gamerhub-for-woocommerce'); ?></span></a>
    837                     <?php else : ?>
    838                         <span style="color:#8c8f94;"><?php esc_html_e('Not imported', 'gamerhub-for-woocommerce'); ?></span>
    839                     <?php endif; ?>
    840                 </td>
    841                 <td>
    842                     <?php if ($imported) : ?>
    843                         <label class="gamerhub-autosync-lbl" title="<?php esc_attr_e('Auto-refresh this product’s price & stock in the background', 'gamerhub-for-woocommerce'); ?>"><input type="checkbox" class="gamerhub-autosync-toggle" data-code="<?php echo esc_attr($code); ?>" <?php checked($autosyncOn); ?> /> <?php esc_html_e('On', 'gamerhub-for-woocommerce'); ?></label>
     1282                        <label class="gamerhub-autosync-lbl" title="<?php esc_attr_e('Ticked: GamerHub keeps this price and its sold-out status up to date by itself, and replaces any price or name you typed by hand. Not ticked: the price AND the stock stay frozen, even when GamerHub changes the cost or runs out.', 'gamerhub-for-woocommerce'); ?>"><input type="checkbox" class="gamerhub-autosync-toggle" data-code="<?php echo esc_attr($code); ?>" <?php checked($autosyncOn); ?> /> <?php esc_html_e('Keep updated', 'gamerhub-for-woocommerce'); ?></label>
    8441283                        <?php if ($syncedAt) : ?>
    8451284                            <br /><span class="gamerhub-synced-at" title="<?php echo esc_attr(date_i18n('Y-m-d H:i', $syncedAt)); ?>"><?php
    … …  
    8531292                </td>
    8541293                <td>
    855                     <button type="button" class="button gamerhub-expand" data-code="<?php echo esc_attr($code); ?>" aria-expanded="false" title="<?php esc_attr_e('Show denominations & prices', 'gamerhub-for-woocommerce'); ?>"><span class="gamerhub-caret" aria-hidden="true">&#9656;</span> <?php esc_html_e('Prices', 'gamerhub-for-woocommerce'); ?></button>
    856                     <button type="button" class="button gamerhub-import-one" data-code="<?php echo esc_attr($code); ?>"><?php echo $imported ? esc_html__('Re-sync', 'gamerhub-for-woocommerce') : esc_html__('Import', 'gamerhub-for-woocommerce'); ?></button>
     1294                    <button type="button" class="button gamerhub-expand" data-code="<?php echo esc_attr($code); ?>" aria-expanded="false" title="<?php esc_attr_e('Show this game’s top-up amounts, what you pay and what the buyer pays', 'gamerhub-for-woocommerce'); ?>"><span class="gamerhub-caret" aria-hidden="true">&#9656;</span> <?php esc_html_e('Prices', 'gamerhub-for-woocommerce'); ?></button>
     1295                    <button type="button" class="button gamerhub-import-one" data-code="<?php echo esc_attr($code); ?>"><?php echo $imported ? esc_html__('Update prices now', 'gamerhub-for-woocommerce') : esc_html__('Add to my shop', 'gamerhub-for-woocommerce'); ?></button>
    8571296                    <?php if ($imported) : ?>
    858                         <button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Remove', 'gamerhub-for-woocommerce'); ?></button>
     1297                        <button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Delete from shop', 'gamerhub-for-woocommerce'); ?></button>
    8591298                    <?php endif; ?>
    8601299                </td>
    8611300            </tr>
    862             <tr class="gamerhub-detail-row" data-code="<?php echo esc_attr($code); ?>" style="display:none;"><td colspan="8" class="gamerhub-detail-cell"></td></tr>
     1301            <tr class="gamerhub-detail-row" data-code="<?php echo esc_attr($code); ?>" style="display:none;"><td colspan="5" class="gamerhub-detail-cell"></td></tr>
    8631302            <?php
    8641303        }
    … …  
    8681307            if (isset($seen[$code])) {
    8691308                continue;
     1309            }
     1310            if ($only !== '' && $code !== $only) {
     1311                continue;   // single-row render must return exactly one row
    8701312            }
    8711313            $edit     = admin_url('post.php?post=' . (int) $pid . '&action=edit');
    … …  
    8741316            <tr data-code="<?php echo esc_attr($code); ?>" data-type="" data-region="—" data-imported="1" data-brand="" data-cat="" style="background:rgba(201,40,63,.05);">
    8751317                <td class="gamerhub-cb"></td>
    876                 <td><a href="<?php echo esc_url($edit); ?>"><strong><?php echo esc_html($code); ?></strong></a></td>
     1318                <td class="gamerhub-game-cell">
     1319                    <a href="<?php echo esc_url($edit); ?>"><strong><?php echo esc_html($code); ?></strong></a>
     1320                    <span class="gamerhub-game-meta"><span class="gamerhub-badge err"><?php esc_html_e('⚠ GamerHub stopped selling this', 'gamerhub-for-woocommerce'); ?></span></span>
     1321                    <br /><span class="description"><?php esc_html_e('Still in your shop. Orders for it will fail — delete it.', 'gamerhub-for-woocommerce'); ?></span>
     1322                </td>
    8771323                <td>—</td>
    878                 <td>—</td>
    879                 <td>—</td>
    880                 <td><span class="gamerhub-badge err"><?php esc_html_e('⚠ Delisted', 'gamerhub-for-woocommerce'); ?></span></td>
    8811324                <td><?php if ($syncedAt) { echo esc_html(sprintf(/* translators: %s: time-ago */ __('synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($syncedAt))); } else { echo '—'; } ?></td>
    882                 <td><button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Remove', 'gamerhub-for-woocommerce'); ?></button></td>
     1325                <td><button type="button" class="button gamerhub-remove-one" data-code="<?php echo esc_attr($code); ?>"><?php esc_html_e('Delete from shop', 'gamerhub-for-woocommerce'); ?></button></td>
    8831326            </tr>
    8841327            <?php
    … …  
    9001343        }
    9011344        $skus     = (array) ($r['json']['data']['skus'] ?? []);
    902         $markup   = (float) gamerhub_opt('markup', '0');
    9031345        $parentId = self::imported_parent_id($code);
    9041346        $vidBySku = [];
    … …  
    9111353            }
    9121354        }
     1355
     1356        // Costs are collected separately from retails and are NEVER converted: the Cost column is
     1357        // labelled with the ACCOUNT currency on purpose, so the owner can see the real GamerHub
     1358        // charge. It therefore stays available even when no exchange rate exists.
     1359        $costs   = [];
    9131360        $retails = [];
     1361        $priced  = true;
    9141362        foreach ($skus as $s) {
    9151363            if (empty($s['code']) || ! isset($s['price']) || $s['price'] === null || $s['price'] === '') {
    9161364                continue;
    9171365            }
    918             $cost      = (float) $s['price'];
    919             $sc        = (string) $s['code'];
    920             $retails[] = isset($vidBySku[$sc]) ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId) : round($cost * (1 + $markup / 100), 2);
    921         }
    922         return $retails ? ['min' => min($retails), 'max' => max($retails)] : null;
     1366            $cost    = (float) $s['price'];
     1367            $sc      = (string) $s['code'];
     1368            $costs[] = $cost;
     1369            if (! $priced) {
     1370                continue;
     1371            }
     1372            try {
     1373                $retails[] = isset($vidBySku[$sc])
     1374                    ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId)
     1375                    : self::preview_markup($code, $cost);
     1376            } catch (\Throwable $e) {
     1377                // No honest store-currency figure available → drop the retail range rather than show
     1378                // a number in the wrong currency. The cost range is still perfectly valid.
     1379                $retails = [];
     1380                $priced  = false;
     1381            }
     1382        }
     1383
     1384        if (! $costs) {
     1385            return null;
     1386        }
     1387        return [
     1388            'min'  => $retails ? min($retails) : null,
     1389            'max'  => $retails ? max($retails) : null,
     1390            'cmin' => min($costs),
     1391            'cmax' => max($costs),
     1392            'ccur' => self::account_currency(),
     1393        ];
     1394    }
     1395
     1396    /**
     1397     * The retail a NOT-yet-imported game would get: cost + whatever profit is parked for it.
     1398     *
     1399     * gamerhub_apply_markup() reads profit from post meta, which does not exist before import, so
     1400     * a pending profit has to be applied here. Same maths, same floor, same rounding — the preview
     1401     * must not be able to disagree with the price that is eventually written.
     1402     */
     1403    private static function preview_markup(string $code, float $cost): float
     1404    {
     1405        $pending = self::pending_markup($code);
     1406        if ($pending === null) {
     1407            return gamerhub_apply_markup($cost);
     1408        }
     1409        $c     = gamerhub_cost_to_store($cost);
     1410        $price = $pending['type'] === 'fixed' ? ($c + $pending['value']) : ($c * (1 + $pending['value'] / 100));
     1411        return gamerhub_round_price(max($price, $c));
     1412    }
     1413
     1414    /** Write (or drop) one game's cached ranges. $range comes from compute_price_range(). */
     1415    private static function cache_price_range(string $code, ?array $range): void
     1416    {
     1417        $cache = get_option('gamerhub_price_cache', []);
     1418        if (! is_array($cache)) {
     1419            $cache = [];
     1420        }
     1421        if ($range === null) {
     1422            unset($cache[$code]);
     1423        } else {
     1424            $cache[$code] = [
     1425                'min'  => $range['min'],
     1426                'max'  => $range['max'],
     1427                'cmin' => $range['cmin'] ?? null,
     1428                'cmax' => $range['cmax'] ?? null,
     1429                'ccur' => $range['ccur'] ?? '',
     1430                'cur'  => get_woocommerce_currency(),
     1431                'at'   => time(),
     1432            ];
     1433        }
     1434        update_option('gamerhub_price_cache', $cache, false);
     1435    }
     1436
     1437    /** Recompute + re-cache one game's ranges. Cheap: /products/{code} is itself cached. */
     1438    private static function refresh_price_cache(string $code): ?array
     1439    {
     1440        $range = self::compute_price_range($code);
     1441        self::cache_price_range($code, $range);
     1442        return $range;
    9231443    }
    9241444
    … …  
    9381458            wp_send_json_error(['message' => 'no code']);
    9391459        }
    940         delete_transient('gamerhub_c_' . md5('/products/' . rawurlencode($code))); // force a live fetch
    941         $range = self::compute_price_range($code);
    942         $cache = get_option('gamerhub_price_cache', []);
    943         if (! is_array($cache)) {
    944             $cache = [];
    945         }
    946         if ($range) {
    947             $cache[$code] = ['min' => $range['min'], 'max' => $range['max'], 'cur' => get_woocommerce_currency(), 'at' => time()];
    948         } else {
    949             unset($cache[$code]);
    950         }
    951         update_option('gamerhub_price_cache', $cache, false);
    952         wp_send_json_success(['code' => $code, 'price_min' => $range['min'] ?? null, 'price_max' => $range['max'] ?? null]);
     1460        delete_transient(self::cache_key('/products/' . rawurlencode($code))); // force a live fetch
     1461        $range = self::refresh_price_cache($code);
     1462        wp_send_json_success([
     1463            'code'          => $code,
     1464            'price_min'     => $range['min']  ?? null,
     1465            'price_max'     => $range['max']  ?? null,
     1466            'cost_min'      => $range['cmin'] ?? null,
     1467            'cost_max'      => $range['cmax'] ?? null,
     1468            'cost_cur'      => $range['ccur'] ?? '',
     1469            // The same cost in the shop's currency, so the table can show both lines. null when
     1470            // there is no usable rate — the account-currency figure above is still the truth.
     1471            'cost_min_conv' => isset($range['cmin']) ? self::cost_in_store_currency((float) $range['cmin']) : null,
     1472            'cost_max_conv' => isset($range['cmax']) ? self::cost_in_store_currency((float) $range['cmax']) : null,
     1473            'store_cur'     => get_woocommerce_currency(),
     1474        ]);
    9531475    }
    9541476
    … …  
    10011523            $sc   = (string) $s['code'];
    10021524            $cost = (isset($s['price']) && $s['price'] !== null && $s['price'] !== '') ? (float) $s['price'] : null;
     1525            $src  = $cost;   // the ACCOUNT-currency figure, shown as-is under its own currency label
    10031526            if ($cost === null) {
    10041527                $retail = null;
    1005             } elseif (isset($vidBySku[$sc])) {
    1006                 $retail = gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId); // real applied price
    10071528            } else {
    1008                 $retail = round($cost * (1 + $markup / 100), 2); // preview at global markup
     1529                // A throw here means "no honest store-currency figure". Show nothing rather than a
     1530                // number in the wrong currency — the panel renders an em dash for null. The
     1531                // account-currency cost in $src stays valid either way and is still shown.
     1532                try {
     1533                    $retail = isset($vidBySku[$sc])
     1534                        ? gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId)   // real applied price
     1535                        : self::preview_markup($code, $cost);                        // preview at the profit this game would get
     1536                } catch (\Throwable $e) {
     1537                    $retail = null;
     1538                    $cost   = null;   // never show a raw account-currency cost under the store label
     1539                }
    10091540            }
    10101541            $skus[] = [
    10111542                'code'     => $sc,
    10121543                'name'     => (string) ($s['name'] ?? $sc),
    1013                 'cost'     => $cost,
     1544                // Convert the cost too. The admin renders Cost and Retail side by side under ONE
     1545                // currency symbol (the STORE's), but the API quotes cost in the ACCOUNT currency —
     1546                // so sending it raw printed a USD figure labelled BDT, right next to a converted
     1547                // retail figure. Two different currencies in adjacent columns, no warning.
     1548                // null when this SKU has no sellable price — must stay null, not become 0.0
     1549                'cost'     => $cost === null ? null : self::cost_in_store_currency($cost),
     1550                // What GamerHub actually charges, in GamerHub's own currency, unconverted. Shown
     1551                // next to the converted figure so the owner can always see the real charge.
     1552                'cost_src' => $src,
    10141553                'retail'   => $retail,
    10151554                'stock'    => (string) ($s['stock_status'] ?? 'in_stock'),
    … …  
    10191558        }
    10201559
    1021         // Cache the retail price RANGE so the catalog list's Price column can show it WITHOUT importing.
    1022         // Stored in its own option so it survives a catalog re-sync; keyed by code, with the currency it
    1023         // was priced in (the list only shows it while that matches the store currency).
    1024         $retails   = array_values(array_filter(array_column($skus, 'retail'), static fn($v) => $v !== null));
    1025         $price_min = $retails ? min($retails) : null;
    1026         $price_max = $retails ? max($retails) : null;
    1027         $cache     = get_option('gamerhub_price_cache', []);
    1028         if (! is_array($cache)) {
    1029             $cache = [];
    1030         }
    1031         if ($price_min !== null) {
    1032             $cache[$code] = ['min' => $price_min, 'max' => $price_max, 'cur' => get_woocommerce_currency(), 'at' => time()];
    1033         } else {
    1034             unset($cache[$code]);
    1035         }
    1036         update_option('gamerhub_price_cache', $cache, false);
     1560        // Cache both RANGES so the catalog list's "You pay" / "Buyer pays" columns can show them
     1561        // WITHOUT importing. Stored in its own option so it survives a catalog re-sync; keyed by
     1562        // code, tagged with the currency each figure is in.
     1563        $retails = array_values(array_filter(array_column($skus, 'retail'), static fn($v) => $v !== null));
     1564        $costs   = array_values(array_filter(array_column($skus, 'cost_src'), static fn($v) => $v !== null));
     1565        self::cache_price_range($code, $costs ? [
     1566            'min'  => $retails ? min($retails) : null,
     1567            'max'  => $retails ? max($retails) : null,
     1568            'cmin' => min($costs),
     1569            'cmax' => max($costs),
     1570            'ccur' => self::account_currency(),
     1571        ] : null);
    10371572
    10381573        $input_form = [];
    … …  
    10661601        }
    10671602
     1603        // A not-yet-imported game has no product meta, so its profit editor is pre-filled from the
     1604        // pending store instead. Both end up in the same field, so the JS needs no special case.
     1605        if (! $parentId) {
     1606            $pend = self::pending_markup($code);
     1607            if ($pend !== null) {
     1608                $product_mk = ['value' => (string) $pend['value'], 'type' => $pend['type']];
     1609            }
     1610        }
     1611
    10681612        wp_send_json_success([
    10691613            'code'           => $code,
    10701614            'currency'       => get_woocommerce_currency(),
     1615            'cost_currency'  => self::account_currency(),
    10711616            'imported'       => (bool) $parentId,
    10721617            'product_markup' => $product_mk,
    … …  
    10751620            'input_form'     => $input_form,
    10761621            'field_map'      => $field_map,
    1077             'price_min'      => $price_min,
    1078             'price_max'      => $price_max,
     1622            'price_min'      => $retails ? min($retails) : null,
     1623            'price_max'      => $retails ? max($retails) : null,
    10791624        ]);
    10801625    }
    … …  
    11691714        }
    11701715
    1171         $retail = gamerhub_apply_markup((float) $sku['price'], 0, 0); // cost × default markup, floored at cost
     1716        // cost × default markup, floored at cost. Throws if a conversion is needed but no honest rate
     1717        // exists — refuse the product rather than create one at an account-currency price.
     1718        try {
     1719            $retail = gamerhub_apply_markup((float) $sku['price'], 0, 0);
     1720        } catch (\Throwable $e) {
     1721            wp_send_json_error(['message' => $e->getMessage()]);
     1722        }
    11721723
    11731724        // Auto-update price & stock (default ON): the product tracks GamerHub cost like a normal import,
    … …  
    13101861                continue;
    13111862            }
     1863            // A missing/stale exchange rate throws here, and that is deliberately allowed to
     1864            // propagate: stopping the whole reprice is safer than leaving half the variations
     1865            // priced in the old currency. Every caller wraps this.
    13121866            $retail = gamerhub_apply_markup($costBySku[$sc], (int) $vid, $parentId);
    13131867            $var    = wc_get_product($vid);
    … …  
    13401894        }
    13411895        $parentId = self::imported_parent_id($code);
     1896
     1897        // NOT IN THE SHOP YET — a game-level profit is still worth accepting: park it and apply it
     1898        // the moment the game is added. Nothing is priced here, so no currency guard is needed and
     1899        // no WooCommerce product is touched.
    13421900        if (! $parentId) {
    1343             wp_send_json_error(['message' => __('Import this product first, then set markup.', 'gamerhub-for-woocommerce')]);
    1344         }
     1901            if ($sku_code !== '') {
     1902                wp_send_json_error(['message' => __('Add this game to your shop first, then you can set a profit for one single amount.', 'gamerhub-for-woocommerce')]);
     1903            }
     1904            self::set_pending_markup($code, $mk_type, $mk_value);
     1905            wp_send_json_success([
     1906                'pending'  => true,
     1907                'skus'     => [],
     1908                'currency' => get_woocommerce_currency(),
     1909                'html'     => self::render_catalog_rows($code),
     1910                'message'  => $mk_value === ''
     1911                    ? __('Cleared — this game will use your Default profit % when you add it.', 'gamerhub-for-woocommerce')
     1912                    : __('Saved. It will be applied when you add this game to your shop.', 'gamerhub-for-woocommerce'),
     1913            ]);
     1914        }
     1915
    13451916        if ($err = self::currency_error()) {
    13461917            wp_send_json_error(['message' => $err]);
    … …  
    13641935            wp_send_json_error(['message' => $e->getMessage()]);
    13651936        }
    1366         wp_send_json_success(['skus' => $retail, 'currency' => get_woocommerce_currency()]);
     1937        // Send the refreshed row too, so the Profit cell's hint ("using your default" /
     1938        // "will apply when you add this game") moves with the edit.
     1939        // NB: no price re-compute here any more — the catalog table shows no prices, so recomputing
     1940        // the cached range on every save was pure waste. Prices come live from the Prices panel.
     1941        wp_send_json_success([
     1942            'skus'     => $retail,
     1943            'currency' => get_woocommerce_currency(),
     1944            'html'     => self::render_catalog_rows($code),
     1945        ]);
    13671946    }
    13681947
    … …  
    14131992        $btax       = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : '';
    14141993
    1415         $n = 0;
     1994        $n      = 0;
     1995        $failed = 0;   // profit saved but the price could not be rewritten — must not be reported as changed
    14161996        foreach ($codes as $code) {
    14171997            $pid = self::imported_parent_id($code);
    … …  
    14392019                } catch (\Throwable $e) {
    14402020                    gamerhub_log('bulk reprice failed', ['code' => $code, 'error' => $e->getMessage()]);
     2021                    $failed++;
     2022                    continue;   // the profit is stored, but no price moved — do not count it as changed
    14412023                }
    14422024            }
    14432025            $n++;
    14442026        }
    1445         wp_send_json_success(['updated' => $n, 'html' => self::render_catalog_rows()]);
     2027        wp_send_json_success([
     2028            'updated' => $n,
     2029            'failed'  => $failed,
     2030            // Bulk edit touches many rows, so this one really does re-render the whole table.
     2031            // ($code here is only the loop leftover — never pass it as a single-row filter.)
     2032            'html'    => self::render_catalog_rows(),
     2033        ]);
    14462034    }
    14472035
  • gamerhub-for-woocommerce/trunk/readme.txt

    r3626288 r3630953  
    77WC requires at least: 7.1
    88WC tested up to: 10.9
    9 Stable tag: 1.1.0
     9Stable tag: 1.2.0
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1212
    13 Sell game top-ups & vouchers through GamerHub. Orders auto-fulfil on payment and sync status back via a signed webhook.
     13Sell game top-ups and vouchers in your WooCommerce shop. When a customer pays, GamerHub delivers automatically and the order completes on its own.
    1414
    1515== Description ==
    … …  
    1717Connects a WooCommerce store to the GamerHub merchant API and turns it into a fully automated game top-up / voucher shop.
    1818
    19 * **One-click catalog sync** - every active GamerHub product becomes a WooCommerce product (variable product with one variation per denomination), priced at your cost plus a markup you set.
    20 * **Your prices, your way** - set a default markup %, then fine-tune it per product or per denomination right in the catalog, with filters and bulk edit. Retail is never allowed below your GamerHub cost.
    21 * **Player ID / Server fields** on the product page, generated automatically from the game's schema, with an optional live "Verify ID" check before purchase. The fields use standard WooCommerce markup, so they inherit your theme's styling.
     19* **Add games in one click** - every game GamerHub sells becomes a WooCommerce product (one variation per top-up amount), priced at what you pay plus the profit % you set.
     20* **Your prices, your way** - set a default profit %, then fine-tune it for one game or one single top-up amount, with filters and a bulk edit. A price is never allowed to fall below what GamerHub charges you (see the currency note below).
     21* **Sell in your own currency** - if your shop sells in a different currency from the one GamerHub charges you in, the plugin converts every cost at an exchange rate, adds an optional safety buffer, then adds your profit. If it cannot get a trustworthy rate it refuses to write any price at all rather than guess.
     22* **Player ID / Server fields** on the product page, generated automatically from the game's own schema, with an optional live account check before purchase. The fields use standard WooCommerce markup, so they inherit your theme's styling.
    2223* **Automatic fulfilment** - when a WooCommerce order is paid, each unit is submitted to GamerHub server-side over an HMAC-signed request. One supplier order per unit, so every supplier and every quantity works.
    2324* **Signed status webhook** - the order flips to Completed once GamerHub confirms delivery; voucher codes are attached to the order and emailed to the customer.
    2425* **Works with your add-ons** - already render Player ID / Server with PPOM or another product add-on? Turn off the built-in fields and the plugin rebuilds the fulfilment data from the order automatically. If it can't be certain which field is which, it blocks the order instead of guessing - a top-up is never sent to the wrong account.
    25 * **Manual linking** - hand-build any product (your own name / price / image) and link it to a GamerHub denomination, or add a single denomination as a standalone product that keeps tracking cost & stock.
    26 * **Auto-sync (optional)** - refresh prices & stock in the background every 1, 3, 6, 12 or 24 hours, with a per-product on/off switch. Products that go inactive or out of stock upstream are marked out of stock automatically and restock themselves when they return.
     26* **Manual linking** - hand-build any product (your own name / price / image) and link it to a GamerHub top-up amount, or sell a single amount as its own standalone product that keeps tracking cost and stock.
     27* **Keep everything up to date (optional)** - refresh prices and stock in the background every 1, 3, 6, 12 or 24 hours, with a per-product on/off switch. Products that go inactive or out of stock upstream are marked out of stock automatically and restock themselves when they return.
    2728* **HPOS compatible.**
    2829
    … …  
    3334This plugin connects to the **GamerHub merchant API** (https://portal.gamerhubstore.shop) - the service it exists to integrate with. It communicates with that API in these situations:
    3435
    35 1. **Catalog sync / price refresh** - sends your API key (in signed request headers) and requests the product list and per-product prices. No customer data is sent.
     361. **Loading the game list / checking prices** - sends your API key (in signed request headers) and requests the game list and per-game prices. No customer data is sent.
    36372. **Order fulfilment** - when a WooCommerce order is paid, the plugin sends the product code, SKU code, a unique order reference (`out_trade_no`), the buyer-entered game fields (e.g. Player ID / Server), and your store's webhook callback URL, so GamerHub can fulfil the order and notify you of the result.
    37 3. **Verify ID (optional)** - if you enable the "Verify ID" button, the game fields the buyer types are sent to GamerHub to check the account exists before purchase.
    38 4. **Account & order status** - the plugin queries your GamerHub account balance/currency (connection test and before pricing) and polls an order's status by reference. These send only your API key and the order reference (`out_trade_no`); no customer data is sent.
     383. **Checking a game account (optional)** - if you enable the account-check button, the game fields the buyer types are sent to GamerHub to confirm the account exists before purchase.
     394. **Account status, balance and exchange rate** - the plugin queries your GamerHub account balance and currency (connection test, and before writing prices) and polls an order's status by reference. When your shop currency differs from your GamerHub account currency, this same request also asks GamerHub for the exchange rate between the two, so prices can be converted. These send only your API key, your store's currency code, and the order reference (`out_trade_no`); no customer data is sent.
    3940
    4041The buyer-entered game fields (such as a Player ID) are personal data that leaves your site and is sent to GamerHub as a necessary part of delivering the ordered top-up.
    … …  
    4344* Privacy Policy: https://portal.gamerhubstore.shop/privacy
    4445
    45 No data is sent to any other third party.
     46No data is sent to any other third party. The plugin does not call any exchange-rate service or any other API - the rate comes from your own GamerHub account, or you type it yourself.
    4647
    4748== Installation ==
    4849
    49 1. Install & activate the plugin (WooCommerce must be active first).
    50 2. Go to **GamerHub -> Settings** and paste your API Key, API Secret and Webhook Secret from portal.gamerhubstore.shop, set a retail markup %, and Save. The Webhook Secret must be identical to the one set on your GamerHub merchant profile, or status callbacks are rejected.
    51 3. Whitelist this server's IP on the API Key page in the portal ("Test connection" reports the exact IP to add).
    52 4. Click **Sync catalog**, then Import the products you want to sell.
    53 5. Place a small test order to confirm the full flow end-to-end.
     501. Install and activate the plugin. WooCommerce must already be active.
     512. In the WordPress menu on the left, click **GamerHub**. Paste your API Key, API Secret and Webhook Secret from portal.gamerhubstore.shop. The Webhook Secret must be exactly the same as the one in your GamerHub account - if it is not, GamerHub still delivers and still charges you, but your shop never marks the order Complete. Set your **Default profit %** - leave it at 0 and you sell at cost price and earn nothing. Press **Save settings**.
     523. Press **Test connection**. If it fails, the message shows this server's address. Open the API Key page in your GamerHub account, add that address to the allowed list, then test again. When it succeeds it also tells you which currency GamerHub charges you in - you need that for the next step.
     534. Still on the GamerHub page, check **Currency handling**. If your shop sells in a different currency from the one GamerHub charges you in, nothing will be imported until you choose **Convert**. See the currency question below.
     545. Press **Get the game list**, then press **Add to my shop** on each game you want to sell.
     556. Put some money in your GamerHub wallet, then buy one cheap item on your own shop to check the code or top-up really arrives.
    5456
    5557== Frequently Asked Questions ==
     58
     59= My shop and my GamerHub account use different currencies. What happens? =
     60GamerHub charges you in one currency (often USD). Your shop sells in another. A cost of 2.50 USD is not a price of 2.50 in your currency, so the plugin will not guess - by default it stops and imports nothing. That is deliberate: it protects you.
     61
     62On the GamerHub page, under **Currency handling**, pick one of three:
     63
     64* **Convert** - what almost every shop wants. GamerHub's cost is multiplied by an exchange rate, then your profit % is added. The rate comes from your GamerHub account and stays up to date on its own, or you can type your own. If the rate is missing or more than a day old, the plugin stops writing prices until it has a fresh one, so you are never priced from a stale rate.
     65* **Pause** - the default. Nothing is imported and no price changes while the two currencies are different.
     66* **No conversion** - the number is copied across with no maths at all, so a cost of 2.50 USD becomes a price of 2.50 in your currency. Only safe if you type every price by hand and turn "Keep price and stock up to date" off. **In this mode the below-cost protection cannot work**, because the two numbers are not in the same currency.
     67
     68There is also a **Rate buffer** (a small % added on top of the rate, 2% is a sensible start). Your prices stay as they are until the next sync, but exchange rates move daily - the buffer covers that drift so it does not come out of your profit.
     69
     70= Can I change my WooCommerce currency after adding games? =
     71Not safely. WooCommerce stores a price as a plain number and puts your currency label on it. If you switch currency, every imported price keeps its old number and only the label changes - so 11.75 in the old currency becomes 11.75 in the new one, and most of your shop is suddenly priced wrong. The plugin shows a red warning if it detects this. Fix it by adding your games again before you take any orders.
    5672
    5773= Does it need a GamerHub account? =
    … …  
    6177Only the game fields the product requires (e.g. Player ID, Server) - these come from GamerHub automatically. Vouchers require nothing; the buyer just pays and receives a code.
    6278
    63 = Can I set my own markup or prices? =
    64 Yes. Set a default markup % in Settings, then fine-tune it per product or per denomination right in **GamerHub -> Products** (inline, with filters and bulk edit). Markup is applied to a product's price, so a catalog item has to be imported first (or added as a standalone product) - that's the step that creates the WooCommerce product whose price the markup adjusts. Retail is never allowed below your GamerHub cost.
     79= Can I set my own profit or prices? =
     80Yes. Set a **Default profit %** on the GamerHub page. Every game in the list then has its own **Your profit** box right in the table — you can fill it in even before you add the game to your shop, and it is applied the moment you do. Press **Prices** on a game's row to see each top-up amount with what you pay and what the buyer pays side by side, and to give one single amount its own profit. Note the two profit types: **% on top of cost** multiplies, and **+ CUR on top of cost** adds that many units of your currency on top of the cost - it does not set the price to that amount. A price is never allowed to fall below what GamerHub charges you, as long as your shop currency matches your GamerHub currency or you use Convert.
    6581
    6682= How often do prices and stock refresh? =
    67 Turn on Auto-sync in Settings and pick an interval - every 1, 3, 6, 12 or 24 hours. Each imported product can also be excluded individually. If a product goes inactive or out of stock upstream it is automatically marked out of stock so it can't be sold while it can't be fulfilled, and it restocks itself on the next successful sync.
     83Turn on the interval on the GamerHub page - every 1, 3, 6, 12 or 24 hours. Each time it runs it **rewrites** the prices of the games already in your shop using your profit %, and also puts the GamerHub name back and puts the product back on sale - so a price or a name you typed by hand on one of those products will be replaced. To protect one game from this, untick **Keep updated** on its row. If a product goes inactive or out of stock upstream it is automatically marked out of stock so it can't be sold while it can't be fulfilled, and it restocks itself on the next successful sync.
    6884
    6985= I use PPOM or another product add-on for the Player ID fields. Does it work? =
    70 Yes. On a product that uses an add-on, tick "Hide GamerHub's built-in fields" in its GamerHub box to turn ours off and let your add-on render the Player ID / Server. When an order is paid the plugin rebuilds the fulfilment data from the order automatically - matching each GamerHub field by your saved mapping, its label, or a synonym. If it can't be certain which field is which, it blocks the order instead of guessing, so a top-up is never sent to the wrong account. **GamerHub -> Products** shows exactly which field each game needs, and for dropdowns (such as a Genshin server) the exact values to use. You can also map fields explicitly, per product or per variation.
     86Yes. On a product that uses an add-on, tick "Another plugin already asks for the Player ID" in its GamerHub box to turn ours off and let your add-on render the Player ID / Server. When an order is paid the plugin rebuilds the fulfilment data from the order automatically - matching each GamerHub field by your saved mapping, its label, or a synonym. If it can't be certain which field is which, it blocks the order instead of guessing, so a top-up is never sent to the wrong account. Note that with this on, the plugin no longer checks that the buyer filled the fields in - so place one test order before you go live. Pressing **Prices** on a game's row shows exactly which fields that game needs, and for dropdowns (such as a Genshin server) the exact values to use. You can also map fields explicitly, per product or per variation.
    7187
    7288= Will the player fields match my theme? =
    … …  
    8197== Requirements ==
    8298
    83 * A public HTTPS site (the status webhook is delivered to `/wp-json/gamerhub/v1/callback`).
     99* A public HTTPS site (the status webhook is delivered to `/wp-json/gamerhub/v1/callback`). On a local or test site GamerHub cannot reach it and orders stay in Processing.
    84100* Server clock synced (NTP) - requests are signed with a +/-5-minute window.
    85101* A GamerHub merchant account with sufficient balance.
    86102
    87103== Changelog ==
     104
     105= 1.2.0 =
     106* **Sell in your own currency.** If your shop's currency is not the one GamerHub charges you in, costs are now converted at a rate taken from your GamerHub account (or one you type), with an optional safety buffer and optional rounding. If no trustworthy rate is available the plugin refuses to write a price rather than guess, and rounding is always upward so it can never push a price below cost.
     107* See what you pay and what the buyer pays for every top-up amount, side by side and each labelled with its own currency, without adding anything to your shop - press Prices on any game.
     108* Set the profit for a game straight from the game list, including games you have not added yet: the value is remembered and applied the moment you add them.
     109* Give a hand-built or single-amount product its own profit %, instead of only the global default - so you can set a different margin without losing the automatic sold-out protection.
     110* The denomination picker on the product editor now shows what each amount costs you, in GamerHub's currency and in yours, so you can price against real numbers.
     111* Plain-language rewrite of every admin screen, aimed at shop owners rather than developers, and every admin string is now translatable.
     112* Fixed: with a rounding step set, a price could jump a whole step because of a floating-point rounding artefact (for example 50.00 plus 10% became 60 instead of 55).
     113* Fixed: choosing "no conversion" and then saving the settings page again silently reset it to "Pause", which stopped all imports and repricing with no explanation.
     114* Fixed: the per-game price refresh silently returned cached data instead of fetching fresh figures.
     115* Fixed: a bulk edit reported games as changed even when their prices could not be rewritten.
     116* Fixed: "Sync from GamerHub" on a fixed-price product showed a red error for what is normal, expected behaviour.
     117* Faster: the admin pages no longer run an unbounded product query on every screen, and the game list no longer runs one query per product.
    88118
    89119= 1.1.0 =
    … …  
    102132== Upgrade Notice ==
    103133
     134= 1.2.0 =
     135Sell in a currency other than your GamerHub account currency, see cost and retail side by side, and set a profit per game or per product. Includes a fix for a rounding bug that could overcharge. Nothing changes if your shop and GamerHub already use the same currency.
     136
    104137= 1.1.0 =
    105138Inline per-product/per-denomination markup, more auto-sync intervals, closed-loop stock, PPOM / add-on compatibility, and theme-matching fields. No action required after updating.
  • gamerhub-for-woocommerce/trunk/uninstall.php

    r3617487 r3630953  
    1515        'gamerhub_settings',        // api_key, api_secret, webhook_secret, markup, auto_sync, auto_import
    1616        'gamerhub_catalog',         // cached catalog snapshot
     17        'gamerhub_price_cache',     // per-game cost/retail ranges shown in the catalog table
     18        'gamerhub_pending_markup',  // profit chosen for games not added to the shop yet
    1719        'gamerhub_last_autosync',   // last auto-sync timestamp
    1820        'gamerhub_autosync_offset', // batch cursor
Note: See TracChangeset for help on using the changeset viewer.