Changeset 3630953
- Timestamp:
- 08/01/2026 12:30:08 PM (3 months ago)
- Location:
- gamerhub-for-woocommerce
- Files:
-
- 2 added
- 24 edited
- 1 copied
-
tags/1.2.0 (copied) (copied from gamerhub-for-woocommerce/trunk )
-
tags/1.2.0/README.md (added)
-
tags/1.2.0/assets/gamerhub-admin.css (modified) (1 diff)
-
tags/1.2.0/assets/gamerhub-admin.js (modified) (21 diffs)
-
tags/1.2.0/assets/gamerhub-frontend.js (modified) (1 diff)
-
tags/1.2.0/assets/gamerhub-product.js (modified) (3 diffs)
-
tags/1.2.0/gamerhub-for-woocommerce.php (modified) (4 diffs)
-
tags/1.2.0/includes/class-gamerhub-checkout.php (modified) (4 diffs)
-
tags/1.2.0/includes/class-gamerhub-orders.php (modified) (1 diff)
-
tags/1.2.0/includes/class-gamerhub-product-meta.php (modified) (8 diffs)
-
tags/1.2.0/includes/class-gamerhub-settings.php (modified) (17 diffs)
-
tags/1.2.0/includes/class-gamerhub-sync.php (modified) (34 diffs)
-
tags/1.2.0/readme.txt (modified) (7 diffs)
-
tags/1.2.0/uninstall.php (modified) (1 diff)
-
trunk/README.md (added)
-
trunk/assets/gamerhub-admin.css (modified) (1 diff)
-
trunk/assets/gamerhub-admin.js (modified) (21 diffs)
-
trunk/assets/gamerhub-frontend.js (modified) (1 diff)
-
trunk/assets/gamerhub-product.js (modified) (3 diffs)
-
trunk/gamerhub-for-woocommerce.php (modified) (4 diffs)
-
trunk/includes/class-gamerhub-checkout.php (modified) (4 diffs)
-
trunk/includes/class-gamerhub-orders.php (modified) (1 diff)
-
trunk/includes/class-gamerhub-product-meta.php (modified) (8 diffs)
-
trunk/includes/class-gamerhub-settings.php (modified) (17 diffs)
-
trunk/includes/class-gamerhub-sync.php (modified) (34 diffs)
-
trunk/readme.txt (modified) (7 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-admin.css
r3626288 r3630953 165 165 .gamerhub-adv-map > summary { cursor: pointer; color: #2271b1; font-size: 13px; } 166 166 .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 13 13 .replace(/'/g, '''); 14 14 } 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)); } 16 23 function post(data) { return $.post(GAMERHUB_ADMIN.ajax, $.extend({ nonce: GAMERHUB_ADMIN.nonce_sync }, data)); } 17 24 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 } 18 39 19 40 $(function () { … … 27 48 if (r && r.success) { 28 49 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); 30 54 } else { 31 55 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; } 34 58 $tres.addClass('err').text('✗ ' + msg); 35 59 } 36 60 }) 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.')); }) 38 62 .always(function () { $test.prop('disabled', false); }); 39 63 }); … … 54 78 return false; 55 79 } 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 56 97 function applyCatFilter() { 57 98 var q = $.trim($search.val() || '').toLowerCase(); … … 63 104 if ($tr.hasClass('gamerhub-detail-row')) { return; } 64 105 total++; 65 var match = (q === '' || $tr.text().toLowerCase().indexOf(q) !== -1);106 var match = (q === '' || rowSearchText($tr).indexOf(q) !== -1); 66 107 if (match && fi !== '') { match = (String($tr.attr('data-imported')) === fi); } 67 108 if (match && ft !== '') { match = (String($tr.attr('data-type')) === ft); } … … 92 133 setBody(r.data.html); 93 134 $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)); 95 136 $('#gamerhub-catalog-time').text('· ' + GAMERHUB_ADMIN.i18n.justnow); 96 137 }) 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.')); }) 98 139 .always(function () { $sc.prop('disabled', false); }); 99 140 }); 100 141 101 142 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('▸'); 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); }); 119 183 }); 120 184 … … 137 201 var d = r.data, imported = d.imported; 138 202 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>'; 149 221 } 150 222 … … 154 226 '<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" /> ' + 155 227 '<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>' + 157 229 '</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>'; 159 231 return '<tr data-sku="' + esc(s.code) + '"><td><strong>' + esc(s.name) + '</strong></td>' + 160 232 '<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>' + 162 234 '<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>' + 164 238 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>'; 166 240 }).join(''); 167 241 … … 170 244 var pm = d.product_markup || {}; 171 245 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> ' + 173 247 '<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" /> ' + 174 248 '<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>'; 177 251 } 178 252 … … 184 258 var refItems = d.input_form.map(function (f) { 185 259 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>'; 188 262 if (isSel) { 189 263 li += '<div class="gamerhub-optvals"><span class="description">Your add-on dropdown must use these exact values:</span><br>' + … … 199 273 adv = '<details class="gamerhub-adv-map"><summary>' + esc('Advanced: manual field mapping') + '</summary>' + 200 274 '<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>'; 202 276 } 203 277 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>' + 205 279 '<ul class="gamerhub-fieldref-list">' + refItems + '</ul>' + 206 280 '<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>' + … … 213 287 '<div class="gamerhub-detail-inner">' + prodMk + fieldsRef + 214 288 '<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>' + 217 295 '</tr></thead><tbody>' + rows + '</tbody></table></div>' 218 296 ); 219 297 $cell.data('loaded', true); 220 298 }) 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>'); }); 222 300 }); 223 301 … … 239 317 setTimeout(function () { $btn.text(restoreLabel); }, 1200); 240 318 }) 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); }) 242 320 .always(function () { $btn.prop('disabled', false); }); 243 321 } … … 248 326 $body.on('click', '.gamerhub-save-prod-mk', function () { 249 327 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')); 251 329 }); 252 330 … … 266 344 $res.addClass('ok').text('✓ Saved'); 267 345 }) 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')); }); 270 348 }); 271 349 … … 298 376 setBody(r.data.html); 299 377 }) 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.')); }); 301 379 }); 302 380 … … 318 396 $('#gamerhub-modal-name').val($btn.data('name') || ''); 319 397 $('#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')); 321 399 $('#gamerhub-modal-result').removeClass('ok err').text(''); 322 400 $('#gamerhub-modal-autoprice').prop('checked', true); … … 332 410 name = $.trim($('#gamerhub-modal-name').val()), 333 411 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; } 335 413 $c.prop('disabled', true); 336 414 post({ action: 'gamerhub_add_promo', code: pendData.code, sku_code: pendData.sku, name: name, price: price, autoprice: $('#gamerhub-modal-autoprice').is(':checked') ? '1' : '0' }) … … 343 421 } 344 422 }) 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.')); }) 346 424 .always(function () { $c.prop('disabled', false); }); 347 425 }); … … 377 455 autosync = $('#gamerhub-bulk-autosync').val() || ''; 378 456 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; 380 458 } 381 459 $btn.prop('disabled', true); 382 $res.removeClass('ok err').text( GAMERHUB_ADMIN.i18n.syncing);460 $res.removeClass('ok err').text(t('working', 'Working…')); 383 461 post({ action: 'gamerhub_bulk_edit', codes: codes, mk_value: mkv, mk_type: mkt, brand: brand, cat: cat, autosync: autosync }) 384 462 .done(function (r) { 385 463 if (!r || !r.success) { $res.addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed')); return; } 386 464 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.')); }) 390 476 .always(function () { $btn.prop('disabled', false); }); 391 477 }); … … 417 503 $imModal.hide(); setBody(r.data.html); 418 504 }) 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.')); }) 420 506 .always(function () { $c.prop('disabled', false); }); 421 507 } else { … … 461 547 462 548 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.'))); 464 550 $sync.prop('disabled', false); 465 551 } -
gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-frontend.js
r3626288 r3630953 37 37 .done(function (r) { 38 38 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 ); 40 45 } 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.'); 42 47 $res.removeClass('ok').addClass('err').text('✗ ' + m); 43 48 } -
gamerhub-for-woocommerce/tags/1.2.0/assets/gamerhub-product.js
r3617487 r3630953 19 19 } 20 20 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 += ' · <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 21 56 function loadDenoms($sel, code, current, $preview) { 22 57 if (!code) { 23 58 $sel.html('<option value="">— choose a game first —</option>'); 59 $sel.removeData('ghSkus').siblings('.gamerhub-denom-info').empty(); 24 60 if ($preview) { renderPreview($preview, []); } 25 61 return; … … 29 65 .done(function (r) { 30 66 if (r && r.success) { 67 var acct = r.data.cost_currency || ''; 31 68 var opts = '<option value="">— choose —</option>'; 32 69 (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) : ''; 34 82 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>'; 36 84 }); 37 $sel.html(opts); 85 $sel.html(opts).data('ghSkus', r.data.skus || []).data('ghAcct', acct); 86 renderDenomInfo($sel); 38 87 if ($preview) { renderPreview($preview, r.data.input_form || []); } 39 88 } else { … … 50 99 $row.find('.gamerhub-var-pcode').val(game); 51 100 loadDenoms($row.find('.gamerhub-var-denom'), game, ''); 101 }); 102 $(document).on('change', '.gamerhub-var-denom, #gamerhub_denom', function () { 103 renderDenomInfo($(this)); 52 104 }); 53 105 $(document).on('change', '.gamerhub-var-denom', function () { -
gamerhub-for-woocommerce/tags/1.2.0/gamerhub-for-woocommerce.php
r3626288 r3630953 4 4 * Plugin URI: https://portal.gamerhubstore.shop 5 5 * 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.06 * Version: 1.2.0 7 7 * Author: GamerHub 8 8 * License: GPL-2.0-or-later … … 20 20 } 21 21 22 define('GAMERHUB_VERSION', '1. 1.0');22 define('GAMERHUB_VERSION', '1.2.0'); 23 23 define('GAMERHUB_FILE', __FILE__); 24 24 define('GAMERHUB_DIR', plugin_dir_path(__FILE__)); … … 70 70 71 71 /** 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 */ 79 function 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). */ 86 function 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. */ 93 function 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 */ 105 function 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. */ 111 function 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. */ 117 function 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 */ 129 function 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). */ 181 function 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. */ 187 function 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 */ 204 function 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 */ 244 function 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 /** 72 268 * Retail price for a supplier cost, applying the effective markup. 73 269 * Fallback: per-SKU (variation) override → per-product override → global % (settings). 74 270 * 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. 75 274 */ 76 275 function gamerhub_apply_markup(float $cost, int $variation_id = 0, int $product_id = 0): float 77 276 { 277 $cost = gamerhub_cost_to_store($cost); // account currency → store currency (no-op when equal) 278 78 279 $mk = gamerhub_markup_meta($variation_id); 79 280 if ($mk === null) { … … 88 289 : ($cost * (1 + $mk['value'] / 100)); 89 290 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)); 91 294 } 92 295 -
gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-checkout.php
r3626288 r3630953 239 239 echo '<button type="button" class="button gamerhub-verify-btn" data-product="' 240 240 . esc_attr($verify_code) . '">' 241 . esc_html__(' Verify ID', 'gamerhub-for-woocommerce') . '</button> ';241 . esc_html__('Check my game account', 'gamerhub-for-woocommerce') . '</button> '; 242 242 echo '<span class="gamerhub-verify-result" aria-live="polite"></span>'; 243 243 echo '</p>'; … … 363 363 'nonce' => wp_create_nonce('gamerhub_verify'), 364 364 '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'), 367 370 ], 368 371 ]); … … 403 406 404 407 if ($product_code === '' || empty($payload)) { 405 wp_send_json_error(['message' => __(' Enter the Player IDfirst.', 'gamerhub-for-woocommerce')]);408 wp_send_json_error(['message' => __('Please fill in your details above first.', 'gamerhub-for-woocommerce')]); 406 409 } 407 410 … … 422 425 423 426 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')), 425 428 ]); 426 429 } -
gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-orders.php
r3626288 r3630953 115 115 $item->save(); 116 116 $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 )); 118 123 $touched = true; 119 124 continue; -
gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-product-meta.php
r3626288 r3630953 62 62 <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option> 63 63 </select> 64 <span class="gamerhub-denom-info"></span> 64 65 </div> 65 66 <div class="form-row form-row-first"> … … 71 72 <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" /> 72 73 </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> 74 75 <?php 75 76 // Buyer-field reference + optional PPOM / add-on mapping for THIS variation's linked game. … … 256 257 <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option> 257 258 </select> 259 <span class="gamerhub-denom-info"></span> 258 260 </p> 259 261 <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;"> … … 272 274 <?php echo self::render_form_preview($form); // phpcs:ignore WordPress.Security.EscapeOutput ?> 273 275 </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 ?> 275 282 <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> 279 316 <?php if (! empty($form)) : 280 317 $fmap = json_decode((string) get_post_meta($post->ID, '_gamerhub_field_map', true) ?: '[]', true); … … 337 374 if ($gh_show_hide) : ?> 338 375 <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> 341 378 </p> 342 379 <?php endif; ?> … … 442 479 if (! empty($_POST['gamerhub_autoprice'])) { 443 480 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 } 444 496 445 497 // Money safety: the SKU price is in the GamerHub ACCOUNT currency and we do no FX. On a … … 455 507 $wc = wc_get_product($post_id); 456 508 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 } 461 522 } 462 523 break; … … 487 548 'nonce' => wp_create_nonce('gamerhub_sync'), 488 549 '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 ], 489 555 ]); 490 556 } -
gamerhub-for-woocommerce/tags/1.2.0/includes/class-gamerhub-settings.php
r3626288 r3630953 45 45 'nonce_sync' => wp_create_nonce('gamerhub_sync'), 46 46 '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(), 48 52 'i18n' => [ 49 53 'testing' => __('Testing…', 'gamerhub-for-woocommerce'), 50 54 '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'), 52 57 'done' => __('Done', 'gamerhub-for-woocommerce'), 53 58 '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'), 55 60 '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'), 59 101 ], 60 102 ]); … … 80 122 if (! in_array($auto_sync, ['off', '1', '3', '6', '12', '24'], true)) { 81 123 $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'; 82 152 } 83 153 … … 96 166 'auto_sync' => $auto_sync, 97 167 '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'] ?? ''), 98 180 ]; 99 181 … … 153 235 } 154 236 $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'); 155 242 $callback_url = rest_url('gamerhub/v1/callback'); 156 243 $configured = $api_key !== '' && $api_secret !== ''; … … 178 265 <div class="gamerhub-stat"> 179 266 <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> 181 268 </div> 182 269 <div class="gamerhub-stat"> … … 225 312 <td> 226 313 <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> 228 315 </td> 229 316 </tr> 230 317 <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> 232 319 <td> 233 320 <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; ?> 235 327 </td> 236 328 </tr> … … 246 338 <option value="24" <?php selected($auto_sync, '24'); ?>><?php esc_html_e('Every 24 hours', 'gamerhub-for-woocommerce'); ?></option> 247 339 </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> 249 341 <label style="display:inline-block;margin-top:8px;"> 250 342 <input type="checkbox" name="auto_import" value="1" <?php checked($auto_import, '1'); ?> /> 251 <?php esc_html_e('Also a uto-import newly listed games', 'gamerhub-for-woocommerce'); ?>343 <?php esc_html_e('Also add brand-new games to my shop automatically', 'gamerhub-for-woocommerce'); ?> 252 344 </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> 253 501 </td> 254 502 </tr> … … 260 508 <div class="gamerhub-card"> 261 509 <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> 263 511 <p> 264 512 <button type="button" class="button" id="gamerhub-test-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Test connection', 'gamerhub-for-woocommerce'); ?></button> … … 269 517 <div class="gamerhub-card"> 270 518 <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> 272 520 <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> 274 522 <span id="gamerhub-synccat-result" class="gamerhub-result"></span> 275 523 <span class="gamerhub-last-sync" id="gamerhub-catalog-time"> … … 279 527 echo esc_html(sprintf(__('· catalog synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($catalog_at))); 280 528 } else { 281 esc_html_e('· no t synced yet — click Sync catalog', 'gamerhub-for-woocommerce');529 esc_html_e('· no games loaded yet — press “Get the game list”', 'gamerhub-for-woocommerce'); 282 530 } 283 531 ?> … … 296 544 <button type="button" class="button button-primary" id="gamerhub-sync-btn"><?php esc_html_e('Import selected', 'gamerhub-for-woocommerce'); ?></button> 297 545 <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> 299 547 <span id="gamerhub-sync-result" class="gamerhub-result"></span> 300 548 <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;" /> … … 359 607 360 608 <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'); ?> 363 612 <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'); ?>" /> 364 613 <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> 367 619 </select> 368 620 </label> … … 401 653 <th class="gamerhub-cb"></th> 402 654 <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> 407 659 <th><?php esc_html_e('Auto-sync', 'gamerhub-for-woocommerce'); ?></th> 408 660 <th><?php esc_html_e('Actions', 'gamerhub-for-woocommerce'); ?></th> … … 416 668 <div class="gamerhub-card"> 417 669 <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> 419 671 <p><code class="gamerhub-copy"><?php echo esc_html($callback_url); ?></code></p> 420 672 </div> … … 422 674 <div id="gamerhub-modal" class="gamerhub-modal" style="display:none;"> 423 675 <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> 425 678 <p class="gamerhub-modal-sku"></p> 426 679 <p> … … 429 682 </p> 430 683 <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> 433 686 </p> 434 687 <p> … … 468 721 <?php endif; ?> 469 722 <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'); ?>" /> 472 725 <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> 475 731 </select> 476 732 </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> 478 734 <p class="gamerhub-modal-actions"> 479 735 <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 43 43 add_action('admin_post_gamerhub_sync_one', [__CLASS__, 'handle_sync_one']); 44 44 add_action('admin_notices', [__CLASS__, 'row_action_notice']); 45 add_action('admin_notices', [__CLASS__, 'currency_change_notice']); 45 46 } 46 47 … … 53 54 } 54 55 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]); 68 60 } 69 61 … … 143 135 $state = get_transient(self::STATE); 144 136 if (! is_array($state)) { 145 wp_send_json_error(['message' => __(' Sync session expired — load the catalogagain.', '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')]); 146 138 } 147 139 … … 232 224 } 233 225 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 = []; 235 238 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); 239 247 240 248 $attr = new WC_Product_Attribute(); … … 266 274 } 267 275 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 274 296 275 297 // Brand chosen at import → assign it (by name: reuses an existing term or creates it). … … 283 305 284 306 // ── 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 288 309 foreach ($skus as $sku) { 289 310 $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); 296 314 297 315 $cost = (float) $sku['price']; … … 347 365 wc_delete_product_transients($parent_id); 348 366 update_post_meta($parent_id, '_gamerhub_synced_at', time()); 367 self::remember_priced_currency(); // prices were just written in the CURRENT store currency 349 368 350 369 return [ … … 394 413 // ── Anti-hammer helpers ───────────────────────────────────────────── 395 414 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 396 425 /** 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); 400 429 $cached = get_transient($key); 401 430 if (is_array($cached)) { 402 431 return $cached; 403 432 } 404 $r = Gamerhub_Client::get($path );433 $r = Gamerhub_Client::get($path, $query); 405 434 if (Gamerhub_Client::ok($r)) { 406 435 set_transient($key, $r, $ttl); … … 409 438 } 410 439 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 411 489 /** Store currency must equal the GamerHub account currency (no FX on import). Returns an error message, or null if OK/unknown. */ 412 490 public static function currency_error(): ?string 413 491 { 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 { 414 653 $bal = self::cached_get('/balance', 300); 415 654 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(); 419 663 $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]; 429 669 } 430 670 … … 460 700 } 461 701 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 462 828 /** code => WC product id for imported VARIABLE products (excludes manual/promo). */ 463 829 public static function imported_map(): array … … 467 833 ['key' => '_gamerhub_manual', 'compare' => 'NOT EXISTS'], 468 834 ]]); 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 469 841 $map = []; 470 842 foreach ($ids as $id) { … … 490 862 public static function refresh_imported(): void 491 863 { 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; 500 871 } 501 872 … … 640 1011 $autoprice = get_post_meta($product_id, '_gamerhub_autoprice', true) === '1'; 641 1012 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')]; 643 1015 } 644 1016 // Never mis-price on a currency mismatch (same guard as every other import/sync path). … … 708 1080 } 709 1081 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 710 1115 /** Admin notice after a single-product sync — status is carried in our own redirect's query args. */ 711 1116 public static function row_action_notice(): void … … 735 1140 } 736 1141 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]); 749 1145 } 750 1146 … … 765 1161 update_option('gamerhub_catalog', ['at' => time(), 'games' => $games]); 766 1162 // 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')); 768 1166 769 1167 wp_send_json_success(['at' => time(), 'count' => count($games), 'html' => self::render_catalog_rows()]); … … 777 1175 } 778 1176 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 781 1184 { 782 1185 $cat = self::get_catalog(); 783 1186 $games = $cat['games'] ?? []; 1187 if ($only !== '') { 1188 $games = array_values(array_filter($games, static fn($g) => (string) ($g['code'] ?? '') === $only)); 1189 } 784 1190 $map = self::imported_map(); 785 1191 $btax = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : ''; 786 1192 $seen = []; 787 1193 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>'; 801 1230 }; 802 1231 … … 828 1257 <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); ?>"> 829 1258 <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'); ?>">↻</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> 834 1280 <td> 835 1281 <?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> 844 1283 <?php if ($syncedAt) : ?> 845 1284 <br /><span class="gamerhub-synced-at" title="<?php echo esc_attr(date_i18n('Y-m-d H:i', $syncedAt)); ?>"><?php … … 853 1292 </td> 854 1293 <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">▸</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">▸</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> 857 1296 <?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> 859 1298 <?php endif; ?> 860 1299 </td> 861 1300 </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> 863 1302 <?php 864 1303 } … … 868 1307 if (isset($seen[$code])) { 869 1308 continue; 1309 } 1310 if ($only !== '' && $code !== $only) { 1311 continue; // single-row render must return exactly one row 870 1312 } 871 1313 $edit = admin_url('post.php?post=' . (int) $pid . '&action=edit'); … … 874 1316 <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);"> 875 1317 <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> 877 1323 <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>881 1324 <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> 883 1326 </tr> 884 1327 <?php … … 900 1343 } 901 1344 $skus = (array) ($r['json']['data']['skus'] ?? []); 902 $markup = (float) gamerhub_opt('markup', '0');903 1345 $parentId = self::imported_parent_id($code); 904 1346 $vidBySku = []; … … 911 1353 } 912 1354 } 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 = []; 913 1360 $retails = []; 1361 $priced = true; 914 1362 foreach ($skus as $s) { 915 1363 if (empty($s['code']) || ! isset($s['price']) || $s['price'] === null || $s['price'] === '') { 916 1364 continue; 917 1365 } 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; 923 1443 } 924 1444 … … 938 1458 wp_send_json_error(['message' => 'no code']); 939 1459 } 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 ]); 953 1475 } 954 1476 … … 1001 1523 $sc = (string) $s['code']; 1002 1524 $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 1003 1526 if ($cost === null) { 1004 1527 $retail = null; 1005 } elseif (isset($vidBySku[$sc])) {1006 $retail = gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId); // real applied price1007 1528 } 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 } 1009 1540 } 1010 1541 $skus[] = [ 1011 1542 'code' => $sc, 1012 1543 '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, 1014 1553 'retail' => $retail, 1015 1554 'stock' => (string) ($s['stock_status'] ?? 'in_stock'), … … 1019 1558 } 1020 1559 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); 1037 1572 1038 1573 $input_form = []; … … 1066 1601 } 1067 1602 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 1068 1612 wp_send_json_success([ 1069 1613 'code' => $code, 1070 1614 'currency' => get_woocommerce_currency(), 1615 'cost_currency' => self::account_currency(), 1071 1616 'imported' => (bool) $parentId, 1072 1617 'product_markup' => $product_mk, … … 1075 1620 'input_form' => $input_form, 1076 1621 '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, 1079 1624 ]); 1080 1625 } … … 1169 1714 } 1170 1715 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 } 1172 1723 1173 1724 // Auto-update price & stock (default ON): the product tracks GamerHub cost like a normal import, … … 1310 1861 continue; 1311 1862 } 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. 1312 1866 $retail = gamerhub_apply_markup($costBySku[$sc], (int) $vid, $parentId); 1313 1867 $var = wc_get_product($vid); … … 1340 1894 } 1341 1895 $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. 1342 1900 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 1345 1916 if ($err = self::currency_error()) { 1346 1917 wp_send_json_error(['message' => $err]); … … 1364 1935 wp_send_json_error(['message' => $e->getMessage()]); 1365 1936 } 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 ]); 1367 1946 } 1368 1947 … … 1413 1992 $btax = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : ''; 1414 1993 1415 $n = 0; 1994 $n = 0; 1995 $failed = 0; // profit saved but the price could not be rewritten — must not be reported as changed 1416 1996 foreach ($codes as $code) { 1417 1997 $pid = self::imported_parent_id($code); … … 1439 2019 } catch (\Throwable $e) { 1440 2020 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 1441 2023 } 1442 2024 } 1443 2025 $n++; 1444 2026 } 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 ]); 1446 2034 } 1447 2035 -
gamerhub-for-woocommerce/tags/1.2.0/readme.txt
r3626288 r3630953 7 7 WC requires at least: 7.1 8 8 WC tested up to: 10.9 9 Stable tag: 1. 1.09 Stable tag: 1.2.0 10 10 License: GPLv2 or later 11 11 License URI: https://www.gnu.org/licenses/gpl-2.0.html 12 12 13 Sell game top-ups & vouchers through GamerHub. Orders auto-fulfil on payment and sync status back via a signed webhook.13 Sell game top-ups and vouchers in your WooCommerce shop. When a customer pays, GamerHub delivers automatically and the order completes on its own. 14 14 15 15 == Description == … … 17 17 Connects a WooCommerce store to the GamerHub merchant API and turns it into a fully automated game top-up / voucher shop. 18 18 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. 22 23 * **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. 23 24 * **Signed status webhook** - the order flips to Completed once GamerHub confirms delivery; voucher codes are attached to the order and emailed to the customer. 24 25 * **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. 27 28 * **HPOS compatible.** 28 29 … … 33 34 This 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: 34 35 35 1. ** Catalog sync / price refresh** - sends your API key (in signed request headers) and requests the product list and per-productprices. No customer data is sent.36 1. **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. 36 37 2. **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 checkthe 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 keyand the order reference (`out_trade_no`); no customer data is sent.38 3. **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. 39 4. **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. 39 40 40 41 The 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. … … 43 44 * Privacy Policy: https://portal.gamerhubstore.shop/privacy 44 45 45 No data is sent to any other third party. 46 No 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. 46 47 47 48 == Installation == 48 49 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. 50 1. Install and activate the plugin. WooCommerce must already be active. 51 2. 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**. 52 3. 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. 53 4. 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. 54 5. Press **Get the game list**, then press **Add to my shop** on each game you want to sell. 55 6. 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. 54 56 55 57 == Frequently Asked Questions == 58 59 = My shop and my GamerHub account use different currencies. What happens? = 60 GamerHub 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 62 On 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 68 There 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? = 71 Not 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. 56 72 57 73 = Does it need a GamerHub account? = … … 61 77 Only 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. 62 78 63 = Can I set my own markupor 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? = 80 Yes. 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. 65 81 66 82 = 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.83 Turn 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. 68 84 69 85 = 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 eachgame 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.86 Yes. 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. 71 87 72 88 = Will the player fields match my theme? = … … 81 97 == Requirements == 82 98 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. 84 100 * Server clock synced (NTP) - requests are signed with a +/-5-minute window. 85 101 * A GamerHub merchant account with sufficient balance. 86 102 87 103 == 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. 88 118 89 119 = 1.1.0 = … … 102 132 == Upgrade Notice == 103 133 134 = 1.2.0 = 135 Sell 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 104 137 = 1.1.0 = 105 138 Inline 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 15 15 'gamerhub_settings', // api_key, api_secret, webhook_secret, markup, auto_sync, auto_import 16 16 '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 17 19 'gamerhub_last_autosync', // last auto-sync timestamp 18 20 'gamerhub_autosync_offset', // batch cursor -
gamerhub-for-woocommerce/trunk/assets/gamerhub-admin.css
r3626288 r3630953 165 165 .gamerhub-adv-map > summary { cursor: pointer; color: #2271b1; font-size: 13px; } 166 166 .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 13 13 .replace(/'/g, '''); 14 14 } 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)); } 16 23 function post(data) { return $.post(GAMERHUB_ADMIN.ajax, $.extend({ nonce: GAMERHUB_ADMIN.nonce_sync }, data)); } 17 24 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 } 18 39 19 40 $(function () { … … 27 48 if (r && r.success) { 28 49 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); 30 54 } else { 31 55 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; } 34 58 $tres.addClass('err').text('✗ ' + msg); 35 59 } 36 60 }) 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.')); }) 38 62 .always(function () { $test.prop('disabled', false); }); 39 63 }); … … 54 78 return false; 55 79 } 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 56 97 function applyCatFilter() { 57 98 var q = $.trim($search.val() || '').toLowerCase(); … … 63 104 if ($tr.hasClass('gamerhub-detail-row')) { return; } 64 105 total++; 65 var match = (q === '' || $tr.text().toLowerCase().indexOf(q) !== -1);106 var match = (q === '' || rowSearchText($tr).indexOf(q) !== -1); 66 107 if (match && fi !== '') { match = (String($tr.attr('data-imported')) === fi); } 67 108 if (match && ft !== '') { match = (String($tr.attr('data-type')) === ft); } … … 92 133 setBody(r.data.html); 93 134 $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)); 95 136 $('#gamerhub-catalog-time').text('· ' + GAMERHUB_ADMIN.i18n.justnow); 96 137 }) 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.')); }) 98 139 .always(function () { $sc.prop('disabled', false); }); 99 140 }); 100 141 101 142 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('▸'); 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); }); 119 183 }); 120 184 … … 137 201 var d = r.data, imported = d.imported; 138 202 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>'; 149 221 } 150 222 … … 154 226 '<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" /> ' + 155 227 '<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>' + 157 229 '</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>'; 159 231 return '<tr data-sku="' + esc(s.code) + '"><td><strong>' + esc(s.name) + '</strong></td>' + 160 232 '<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>' + 162 234 '<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>' + 164 238 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>'; 166 240 }).join(''); 167 241 … … 170 244 var pm = d.product_markup || {}; 171 245 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> ' + 173 247 '<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" /> ' + 174 248 '<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>'; 177 251 } 178 252 … … 184 258 var refItems = d.input_form.map(function (f) { 185 259 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>'; 188 262 if (isSel) { 189 263 li += '<div class="gamerhub-optvals"><span class="description">Your add-on dropdown must use these exact values:</span><br>' + … … 199 273 adv = '<details class="gamerhub-adv-map"><summary>' + esc('Advanced: manual field mapping') + '</summary>' + 200 274 '<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>'; 202 276 } 203 277 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>' + 205 279 '<ul class="gamerhub-fieldref-list">' + refItems + '</ul>' + 206 280 '<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>' + … … 213 287 '<div class="gamerhub-detail-inner">' + prodMk + fieldsRef + 214 288 '<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>' + 217 295 '</tr></thead><tbody>' + rows + '</tbody></table></div>' 218 296 ); 219 297 $cell.data('loaded', true); 220 298 }) 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>'); }); 222 300 }); 223 301 … … 239 317 setTimeout(function () { $btn.text(restoreLabel); }, 1200); 240 318 }) 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); }) 242 320 .always(function () { $btn.prop('disabled', false); }); 243 321 } … … 248 326 $body.on('click', '.gamerhub-save-prod-mk', function () { 249 327 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')); 251 329 }); 252 330 … … 266 344 $res.addClass('ok').text('✓ Saved'); 267 345 }) 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')); }); 270 348 }); 271 349 … … 298 376 setBody(r.data.html); 299 377 }) 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.')); }); 301 379 }); 302 380 … … 318 396 $('#gamerhub-modal-name').val($btn.data('name') || ''); 319 397 $('#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')); 321 399 $('#gamerhub-modal-result').removeClass('ok err').text(''); 322 400 $('#gamerhub-modal-autoprice').prop('checked', true); … … 332 410 name = $.trim($('#gamerhub-modal-name').val()), 333 411 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; } 335 413 $c.prop('disabled', true); 336 414 post({ action: 'gamerhub_add_promo', code: pendData.code, sku_code: pendData.sku, name: name, price: price, autoprice: $('#gamerhub-modal-autoprice').is(':checked') ? '1' : '0' }) … … 343 421 } 344 422 }) 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.')); }) 346 424 .always(function () { $c.prop('disabled', false); }); 347 425 }); … … 377 455 autosync = $('#gamerhub-bulk-autosync').val() || ''; 378 456 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; 380 458 } 381 459 $btn.prop('disabled', true); 382 $res.removeClass('ok err').text( GAMERHUB_ADMIN.i18n.syncing);460 $res.removeClass('ok err').text(t('working', 'Working…')); 383 461 post({ action: 'gamerhub_bulk_edit', codes: codes, mk_value: mkv, mk_type: mkt, brand: brand, cat: cat, autosync: autosync }) 384 462 .done(function (r) { 385 463 if (!r || !r.success) { $res.addClass('err').text('✗ ' + ((r && r.data && r.data.message) || 'Failed')); return; } 386 464 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.')); }) 390 476 .always(function () { $btn.prop('disabled', false); }); 391 477 }); … … 417 503 $imModal.hide(); setBody(r.data.html); 418 504 }) 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.')); }) 420 506 .always(function () { $c.prop('disabled', false); }); 421 507 } else { … … 461 547 462 548 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.'))); 464 550 $sync.prop('disabled', false); 465 551 } -
gamerhub-for-woocommerce/trunk/assets/gamerhub-frontend.js
r3626288 r3630953 37 37 .done(function (r) { 38 38 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 ); 40 45 } 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.'); 42 47 $res.removeClass('ok').addClass('err').text('✗ ' + m); 43 48 } -
gamerhub-for-woocommerce/trunk/assets/gamerhub-product.js
r3617487 r3630953 19 19 } 20 20 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 += ' · <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 21 56 function loadDenoms($sel, code, current, $preview) { 22 57 if (!code) { 23 58 $sel.html('<option value="">— choose a game first —</option>'); 59 $sel.removeData('ghSkus').siblings('.gamerhub-denom-info').empty(); 24 60 if ($preview) { renderPreview($preview, []); } 25 61 return; … … 29 65 .done(function (r) { 30 66 if (r && r.success) { 67 var acct = r.data.cost_currency || ''; 31 68 var opts = '<option value="">— choose —</option>'; 32 69 (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) : ''; 34 82 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>'; 36 84 }); 37 $sel.html(opts); 85 $sel.html(opts).data('ghSkus', r.data.skus || []).data('ghAcct', acct); 86 renderDenomInfo($sel); 38 87 if ($preview) { renderPreview($preview, r.data.input_form || []); } 39 88 } else { … … 50 99 $row.find('.gamerhub-var-pcode').val(game); 51 100 loadDenoms($row.find('.gamerhub-var-denom'), game, ''); 101 }); 102 $(document).on('change', '.gamerhub-var-denom, #gamerhub_denom', function () { 103 renderDenomInfo($(this)); 52 104 }); 53 105 $(document).on('change', '.gamerhub-var-denom', function () { -
gamerhub-for-woocommerce/trunk/gamerhub-for-woocommerce.php
r3626288 r3630953 4 4 * Plugin URI: https://portal.gamerhubstore.shop 5 5 * 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.06 * Version: 1.2.0 7 7 * Author: GamerHub 8 8 * License: GPL-2.0-or-later … … 20 20 } 21 21 22 define('GAMERHUB_VERSION', '1. 1.0');22 define('GAMERHUB_VERSION', '1.2.0'); 23 23 define('GAMERHUB_FILE', __FILE__); 24 24 define('GAMERHUB_DIR', plugin_dir_path(__FILE__)); … … 70 70 71 71 /** 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 */ 79 function 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). */ 86 function 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. */ 93 function 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 */ 105 function 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. */ 111 function 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. */ 117 function 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 */ 129 function 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). */ 181 function 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. */ 187 function 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 */ 204 function 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 */ 244 function 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 /** 72 268 * Retail price for a supplier cost, applying the effective markup. 73 269 * Fallback: per-SKU (variation) override → per-product override → global % (settings). 74 270 * 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. 75 274 */ 76 275 function gamerhub_apply_markup(float $cost, int $variation_id = 0, int $product_id = 0): float 77 276 { 277 $cost = gamerhub_cost_to_store($cost); // account currency → store currency (no-op when equal) 278 78 279 $mk = gamerhub_markup_meta($variation_id); 79 280 if ($mk === null) { … … 88 289 : ($cost * (1 + $mk['value'] / 100)); 89 290 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)); 91 294 } 92 295 -
gamerhub-for-woocommerce/trunk/includes/class-gamerhub-checkout.php
r3626288 r3630953 239 239 echo '<button type="button" class="button gamerhub-verify-btn" data-product="' 240 240 . esc_attr($verify_code) . '">' 241 . esc_html__(' Verify ID', 'gamerhub-for-woocommerce') . '</button> ';241 . esc_html__('Check my game account', 'gamerhub-for-woocommerce') . '</button> '; 242 242 echo '<span class="gamerhub-verify-result" aria-live="polite"></span>'; 243 243 echo '</p>'; … … 363 363 'nonce' => wp_create_nonce('gamerhub_verify'), 364 364 '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'), 367 370 ], 368 371 ]); … … 403 406 404 407 if ($product_code === '' || empty($payload)) { 405 wp_send_json_error(['message' => __(' Enter the Player IDfirst.', 'gamerhub-for-woocommerce')]);408 wp_send_json_error(['message' => __('Please fill in your details above first.', 'gamerhub-for-woocommerce')]); 406 409 } 407 410 … … 422 425 423 426 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')), 425 428 ]); 426 429 } -
gamerhub-for-woocommerce/trunk/includes/class-gamerhub-orders.php
r3626288 r3630953 115 115 $item->save(); 116 116 $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 )); 118 123 $touched = true; 119 124 continue; -
gamerhub-for-woocommerce/trunk/includes/class-gamerhub-product-meta.php
r3626288 r3630953 62 62 <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option> 63 63 </select> 64 <span class="gamerhub-denom-info"></span> 64 65 </div> 65 66 <div class="form-row form-row-first"> … … 71 72 <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" /> 72 73 </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> 74 75 <?php 75 76 // Buyer-field reference + optional PPOM / add-on mapping for THIS variation's linked game. … … 256 257 <option value="">— <?php esc_html_e('choose a game first', 'gamerhub-for-woocommerce'); ?> —</option> 257 258 </select> 259 <span class="gamerhub-denom-info"></span> 258 260 </p> 259 261 <p style="margin-top:10px;border-top:1px solid #eee;padding-top:10px;"> … … 272 274 <?php echo self::render_form_preview($form); // phpcs:ignore WordPress.Security.EscapeOutput ?> 273 275 </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 ?> 275 282 <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> 279 316 <?php if (! empty($form)) : 280 317 $fmap = json_decode((string) get_post_meta($post->ID, '_gamerhub_field_map', true) ?: '[]', true); … … 337 374 if ($gh_show_hide) : ?> 338 375 <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> 341 378 </p> 342 379 <?php endif; ?> … … 442 479 if (! empty($_POST['gamerhub_autoprice'])) { 443 480 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 } 444 496 445 497 // Money safety: the SKU price is in the GamerHub ACCOUNT currency and we do no FX. On a … … 455 507 $wc = wc_get_product($post_id); 456 508 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 } 461 522 } 462 523 break; … … 487 548 'nonce' => wp_create_nonce('gamerhub_sync'), 488 549 '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 ], 489 555 ]); 490 556 } -
gamerhub-for-woocommerce/trunk/includes/class-gamerhub-settings.php
r3626288 r3630953 45 45 'nonce_sync' => wp_create_nonce('gamerhub_sync'), 46 46 '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(), 48 52 'i18n' => [ 49 53 'testing' => __('Testing…', 'gamerhub-for-woocommerce'), 50 54 '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'), 52 57 'done' => __('Done', 'gamerhub-for-woocommerce'), 53 58 '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'), 55 60 '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'), 59 101 ], 60 102 ]); … … 80 122 if (! in_array($auto_sync, ['off', '1', '3', '6', '12', '24'], true)) { 81 123 $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'; 82 152 } 83 153 … … 96 166 'auto_sync' => $auto_sync, 97 167 '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'] ?? ''), 98 180 ]; 99 181 … … 153 235 } 154 236 $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'); 155 242 $callback_url = rest_url('gamerhub/v1/callback'); 156 243 $configured = $api_key !== '' && $api_secret !== ''; … … 178 265 <div class="gamerhub-stat"> 179 266 <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> 181 268 </div> 182 269 <div class="gamerhub-stat"> … … 225 312 <td> 226 313 <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> 228 315 </td> 229 316 </tr> 230 317 <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> 232 319 <td> 233 320 <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; ?> 235 327 </td> 236 328 </tr> … … 246 338 <option value="24" <?php selected($auto_sync, '24'); ?>><?php esc_html_e('Every 24 hours', 'gamerhub-for-woocommerce'); ?></option> 247 339 </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> 249 341 <label style="display:inline-block;margin-top:8px;"> 250 342 <input type="checkbox" name="auto_import" value="1" <?php checked($auto_import, '1'); ?> /> 251 <?php esc_html_e('Also a uto-import newly listed games', 'gamerhub-for-woocommerce'); ?>343 <?php esc_html_e('Also add brand-new games to my shop automatically', 'gamerhub-for-woocommerce'); ?> 252 344 </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> 253 501 </td> 254 502 </tr> … … 260 508 <div class="gamerhub-card"> 261 509 <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> 263 511 <p> 264 512 <button type="button" class="button" id="gamerhub-test-btn" <?php disabled(! $configured); ?>><?php esc_html_e('Test connection', 'gamerhub-for-woocommerce'); ?></button> … … 269 517 <div class="gamerhub-card"> 270 518 <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> 272 520 <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> 274 522 <span id="gamerhub-synccat-result" class="gamerhub-result"></span> 275 523 <span class="gamerhub-last-sync" id="gamerhub-catalog-time"> … … 279 527 echo esc_html(sprintf(__('· catalog synced %s ago', 'gamerhub-for-woocommerce'), human_time_diff($catalog_at))); 280 528 } else { 281 esc_html_e('· no t synced yet — click Sync catalog', 'gamerhub-for-woocommerce');529 esc_html_e('· no games loaded yet — press “Get the game list”', 'gamerhub-for-woocommerce'); 282 530 } 283 531 ?> … … 296 544 <button type="button" class="button button-primary" id="gamerhub-sync-btn"><?php esc_html_e('Import selected', 'gamerhub-for-woocommerce'); ?></button> 297 545 <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> 299 547 <span id="gamerhub-sync-result" class="gamerhub-result"></span> 300 548 <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;" /> … … 359 607 360 608 <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'); ?> 363 612 <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'); ?>" /> 364 613 <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> 367 619 </select> 368 620 </label> … … 401 653 <th class="gamerhub-cb"></th> 402 654 <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> 407 659 <th><?php esc_html_e('Auto-sync', 'gamerhub-for-woocommerce'); ?></th> 408 660 <th><?php esc_html_e('Actions', 'gamerhub-for-woocommerce'); ?></th> … … 416 668 <div class="gamerhub-card"> 417 669 <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> 419 671 <p><code class="gamerhub-copy"><?php echo esc_html($callback_url); ?></code></p> 420 672 </div> … … 422 674 <div id="gamerhub-modal" class="gamerhub-modal" style="display:none;"> 423 675 <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> 425 678 <p class="gamerhub-modal-sku"></p> 426 679 <p> … … 429 682 </p> 430 683 <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> 433 686 </p> 434 687 <p> … … 468 721 <?php endif; ?> 469 722 <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'); ?>" /> 472 725 <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> 475 731 </select> 476 732 </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> 478 734 <p class="gamerhub-modal-actions"> 479 735 <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 43 43 add_action('admin_post_gamerhub_sync_one', [__CLASS__, 'handle_sync_one']); 44 44 add_action('admin_notices', [__CLASS__, 'row_action_notice']); 45 add_action('admin_notices', [__CLASS__, 'currency_change_notice']); 45 46 } 46 47 … … 53 54 } 54 55 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]); 68 60 } 69 61 … … 143 135 $state = get_transient(self::STATE); 144 136 if (! is_array($state)) { 145 wp_send_json_error(['message' => __(' Sync session expired — load the catalogagain.', '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')]); 146 138 } 147 139 … … 232 224 } 233 225 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 = []; 235 238 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); 239 247 240 248 $attr = new WC_Product_Attribute(); … … 266 274 } 267 275 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 274 296 275 297 // Brand chosen at import → assign it (by name: reuses an existing term or creates it). … … 283 305 284 306 // ── 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 288 309 foreach ($skus as $sku) { 289 310 $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); 296 314 297 315 $cost = (float) $sku['price']; … … 347 365 wc_delete_product_transients($parent_id); 348 366 update_post_meta($parent_id, '_gamerhub_synced_at', time()); 367 self::remember_priced_currency(); // prices were just written in the CURRENT store currency 349 368 350 369 return [ … … 394 413 // ── Anti-hammer helpers ───────────────────────────────────────────── 395 414 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 396 425 /** 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); 400 429 $cached = get_transient($key); 401 430 if (is_array($cached)) { 402 431 return $cached; 403 432 } 404 $r = Gamerhub_Client::get($path );433 $r = Gamerhub_Client::get($path, $query); 405 434 if (Gamerhub_Client::ok($r)) { 406 435 set_transient($key, $r, $ttl); … … 409 438 } 410 439 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 411 489 /** Store currency must equal the GamerHub account currency (no FX on import). Returns an error message, or null if OK/unknown. */ 412 490 public static function currency_error(): ?string 413 491 { 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 { 414 653 $bal = self::cached_get('/balance', 300); 415 654 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(); 419 663 $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]; 429 669 } 430 670 … … 460 700 } 461 701 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 462 828 /** code => WC product id for imported VARIABLE products (excludes manual/promo). */ 463 829 public static function imported_map(): array … … 467 833 ['key' => '_gamerhub_manual', 'compare' => 'NOT EXISTS'], 468 834 ]]); 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 469 841 $map = []; 470 842 foreach ($ids as $id) { … … 490 862 public static function refresh_imported(): void 491 863 { 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; 500 871 } 501 872 … … 640 1011 $autoprice = get_post_meta($product_id, '_gamerhub_autoprice', true) === '1'; 641 1012 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')]; 643 1015 } 644 1016 // Never mis-price on a currency mismatch (same guard as every other import/sync path). … … 708 1080 } 709 1081 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 710 1115 /** Admin notice after a single-product sync — status is carried in our own redirect's query args. */ 711 1116 public static function row_action_notice(): void … … 735 1140 } 736 1141 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]); 749 1145 } 750 1146 … … 765 1161 update_option('gamerhub_catalog', ['at' => time(), 'games' => $games]); 766 1162 // 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')); 768 1166 769 1167 wp_send_json_success(['at' => time(), 'count' => count($games), 'html' => self::render_catalog_rows()]); … … 777 1175 } 778 1176 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 781 1184 { 782 1185 $cat = self::get_catalog(); 783 1186 $games = $cat['games'] ?? []; 1187 if ($only !== '') { 1188 $games = array_values(array_filter($games, static fn($g) => (string) ($g['code'] ?? '') === $only)); 1189 } 784 1190 $map = self::imported_map(); 785 1191 $btax = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : ''; 786 1192 $seen = []; 787 1193 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>'; 801 1230 }; 802 1231 … … 828 1257 <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); ?>"> 829 1258 <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'); ?>">↻</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> 834 1280 <td> 835 1281 <?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> 844 1283 <?php if ($syncedAt) : ?> 845 1284 <br /><span class="gamerhub-synced-at" title="<?php echo esc_attr(date_i18n('Y-m-d H:i', $syncedAt)); ?>"><?php … … 853 1292 </td> 854 1293 <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">▸</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">▸</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> 857 1296 <?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> 859 1298 <?php endif; ?> 860 1299 </td> 861 1300 </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> 863 1302 <?php 864 1303 } … … 868 1307 if (isset($seen[$code])) { 869 1308 continue; 1309 } 1310 if ($only !== '' && $code !== $only) { 1311 continue; // single-row render must return exactly one row 870 1312 } 871 1313 $edit = admin_url('post.php?post=' . (int) $pid . '&action=edit'); … … 874 1316 <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);"> 875 1317 <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> 877 1323 <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>881 1324 <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> 883 1326 </tr> 884 1327 <?php … … 900 1343 } 901 1344 $skus = (array) ($r['json']['data']['skus'] ?? []); 902 $markup = (float) gamerhub_opt('markup', '0');903 1345 $parentId = self::imported_parent_id($code); 904 1346 $vidBySku = []; … … 911 1353 } 912 1354 } 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 = []; 913 1360 $retails = []; 1361 $priced = true; 914 1362 foreach ($skus as $s) { 915 1363 if (empty($s['code']) || ! isset($s['price']) || $s['price'] === null || $s['price'] === '') { 916 1364 continue; 917 1365 } 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; 923 1443 } 924 1444 … … 938 1458 wp_send_json_error(['message' => 'no code']); 939 1459 } 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 ]); 953 1475 } 954 1476 … … 1001 1523 $sc = (string) $s['code']; 1002 1524 $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 1003 1526 if ($cost === null) { 1004 1527 $retail = null; 1005 } elseif (isset($vidBySku[$sc])) {1006 $retail = gamerhub_apply_markup($cost, $vidBySku[$sc], $parentId); // real applied price1007 1528 } 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 } 1009 1540 } 1010 1541 $skus[] = [ 1011 1542 'code' => $sc, 1012 1543 '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, 1014 1553 'retail' => $retail, 1015 1554 'stock' => (string) ($s['stock_status'] ?? 'in_stock'), … … 1019 1558 } 1020 1559 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); 1037 1572 1038 1573 $input_form = []; … … 1066 1601 } 1067 1602 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 1068 1612 wp_send_json_success([ 1069 1613 'code' => $code, 1070 1614 'currency' => get_woocommerce_currency(), 1615 'cost_currency' => self::account_currency(), 1071 1616 'imported' => (bool) $parentId, 1072 1617 'product_markup' => $product_mk, … … 1075 1620 'input_form' => $input_form, 1076 1621 '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, 1079 1624 ]); 1080 1625 } … … 1169 1714 } 1170 1715 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 } 1172 1723 1173 1724 // Auto-update price & stock (default ON): the product tracks GamerHub cost like a normal import, … … 1310 1861 continue; 1311 1862 } 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. 1312 1866 $retail = gamerhub_apply_markup($costBySku[$sc], (int) $vid, $parentId); 1313 1867 $var = wc_get_product($vid); … … 1340 1894 } 1341 1895 $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. 1342 1900 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 1345 1916 if ($err = self::currency_error()) { 1346 1917 wp_send_json_error(['message' => $err]); … … 1364 1935 wp_send_json_error(['message' => $e->getMessage()]); 1365 1936 } 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 ]); 1367 1946 } 1368 1947 … … 1413 1992 $btax = function_exists('gamerhub_brand_taxonomy') ? gamerhub_brand_taxonomy() : ''; 1414 1993 1415 $n = 0; 1994 $n = 0; 1995 $failed = 0; // profit saved but the price could not be rewritten — must not be reported as changed 1416 1996 foreach ($codes as $code) { 1417 1997 $pid = self::imported_parent_id($code); … … 1439 2019 } catch (\Throwable $e) { 1440 2020 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 1441 2023 } 1442 2024 } 1443 2025 $n++; 1444 2026 } 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 ]); 1446 2034 } 1447 2035 -
gamerhub-for-woocommerce/trunk/readme.txt
r3626288 r3630953 7 7 WC requires at least: 7.1 8 8 WC tested up to: 10.9 9 Stable tag: 1. 1.09 Stable tag: 1.2.0 10 10 License: GPLv2 or later 11 11 License URI: https://www.gnu.org/licenses/gpl-2.0.html 12 12 13 Sell game top-ups & vouchers through GamerHub. Orders auto-fulfil on payment and sync status back via a signed webhook.13 Sell game top-ups and vouchers in your WooCommerce shop. When a customer pays, GamerHub delivers automatically and the order completes on its own. 14 14 15 15 == Description == … … 17 17 Connects a WooCommerce store to the GamerHub merchant API and turns it into a fully automated game top-up / voucher shop. 18 18 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. 22 23 * **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. 23 24 * **Signed status webhook** - the order flips to Completed once GamerHub confirms delivery; voucher codes are attached to the order and emailed to the customer. 24 25 * **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. 27 28 * **HPOS compatible.** 28 29 … … 33 34 This 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: 34 35 35 1. ** Catalog sync / price refresh** - sends your API key (in signed request headers) and requests the product list and per-productprices. No customer data is sent.36 1. **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. 36 37 2. **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 checkthe 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 keyand the order reference (`out_trade_no`); no customer data is sent.38 3. **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. 39 4. **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. 39 40 40 41 The 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. … … 43 44 * Privacy Policy: https://portal.gamerhubstore.shop/privacy 44 45 45 No data is sent to any other third party. 46 No 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. 46 47 47 48 == Installation == 48 49 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. 50 1. Install and activate the plugin. WooCommerce must already be active. 51 2. 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**. 52 3. 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. 53 4. 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. 54 5. Press **Get the game list**, then press **Add to my shop** on each game you want to sell. 55 6. 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. 54 56 55 57 == Frequently Asked Questions == 58 59 = My shop and my GamerHub account use different currencies. What happens? = 60 GamerHub 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 62 On 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 68 There 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? = 71 Not 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. 56 72 57 73 = Does it need a GamerHub account? = … … 61 77 Only 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. 62 78 63 = Can I set my own markupor 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? = 80 Yes. 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. 65 81 66 82 = 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.83 Turn 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. 68 84 69 85 = 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 eachgame 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.86 Yes. 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. 71 87 72 88 = Will the player fields match my theme? = … … 81 97 == Requirements == 82 98 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. 84 100 * Server clock synced (NTP) - requests are signed with a +/-5-minute window. 85 101 * A GamerHub merchant account with sufficient balance. 86 102 87 103 == 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. 88 118 89 119 = 1.1.0 = … … 102 132 == Upgrade Notice == 103 133 134 = 1.2.0 = 135 Sell 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 104 137 = 1.1.0 = 105 138 Inline 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 15 15 'gamerhub_settings', // api_key, api_secret, webhook_secret, markup, auto_sync, auto_import 16 16 '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 17 19 'gamerhub_last_autosync', // last auto-sync timestamp 18 20 'gamerhub_autosync_offset', // batch cursor
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)