feat: Mercuryo gateway, crypto QR deposit, mono products, wallet auto-refresh

- Replace Quickex/Guardarian with Mercuryo (https://mercuryo.io/)
- Add crypto QR code payment option in deposit flow (qrcode package)
- Add is_mono product flag for digital/infinite products
- Mono products: no quantity buttons in bot, always available
- Admin wallet page: auto-refresh balances from blockchain APIs
- Migration 010: add is_mono column to products
- i18n updates for en/de/es
This commit is contained in:
NW
2026-07-08 12:08:13 +01:00
parent 2b30bc4a91
commit f0afada884
24 changed files with 1393 additions and 268 deletions

238
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"node-telegram-bot-api": "^0.64.0",
"pino": "^8.21.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"tiny-secp256k1": "^2.2.3"
},
"devDependencies": {
@@ -975,6 +976,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@@ -1018,6 +1028,46 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1395,6 +1445,15 @@
"ms": "^2.1.1"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz",
@@ -1622,6 +1681,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
@@ -2172,6 +2237,19 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -2316,6 +2394,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -3101,6 +3188,18 @@
"node": ">= 0.6.3"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -3461,6 +3560,42 @@
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3475,6 +3610,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3779,6 +3923,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -3886,6 +4039,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -4168,6 +4338,21 @@
"uuid": "bin/uuid"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -4381,6 +4566,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -5219,6 +5410,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
@@ -5377,6 +5574,47 @@
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",

View File

@@ -26,6 +26,7 @@
"node-telegram-bot-api": "^0.64.0",
"pino": "^8.21.0",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"tiny-secp256k1": "^2.2.3"
},
"devDependencies": {

View File

@@ -1,11 +1,13 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
const router = Router();
function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function buildTreeHtml(tree) {
function buildTreeHtml(tree, csrf) {
const csrfInput = `<input type="hidden" name="_csrf" value="${esc(csrf)}">`;
let treeHtml = '';
let accordionIndex = 0;
for (const [country, cdata] of Object.entries(tree)) {
@@ -35,10 +37,12 @@ function buildTreeHtml(tree) {
<div id="${subId}" class="accordion-collapse collapse" data-bs-parent="#${catId}">
<div class="accordion-body py-2">
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2">
${csrfInput}
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Add</button>
</form>
<form method="POST" action="/catalog/subcategories/${s.id}/delete" class="d-inline ms-1" onsubmit="return confirm('Delete?')">
${csrfInput}
<button class="btn btn-sm btn-outline-danger">✕</button>
</form>
</div>
@@ -57,6 +61,7 @@ function buildTreeHtml(tree) {
<div class="accordion-body py-2">
${subHtml}
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2 mt-2">
${csrfInput}
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Add</button>
</form>
@@ -76,11 +81,13 @@ function buildTreeHtml(tree) {
<div class="accordion-body py-2">
${catHtml}
<form method="POST" action="/catalog/categories" class="d-inline-flex gap-2 mt-2">
${csrfInput}
<input type="hidden" name="location_id" value="${ldata.id}">
<input name="name" class="form-control form-control-sm" placeholder="+ Category" required size="12" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Add</button>
</form>
<form method="POST" action="/catalog/locations/${ldata.id}/delete" class="d-inline ms-1" onsubmit="return confirm('Delete?')">
${csrfInput}
<button class="btn btn-sm btn-outline-danger">✕</button>
</form>
</div>
@@ -100,6 +107,7 @@ function buildTreeHtml(tree) {
<div class="accordion-body py-2">
${districtHtml}
<form method="POST" action="/catalog/locations/add-district" class="d-inline-flex gap-2 mt-2">
${csrfInput}
<input type="hidden" name="country" value="${esc(country)}">
<input type="hidden" name="city" value="${esc(city)}">
<input name="district" class="form-control form-control-sm" placeholder="+ District" required size="12" style="width:auto">
@@ -121,6 +129,7 @@ function buildTreeHtml(tree) {
<div class="accordion-body p-0">
${cityHtml}
<form method="POST" action="/catalog/locations/add-city" class="d-inline-flex gap-2 p-3 pt-0">
${csrfInput}
<input type="hidden" name="country" value="${esc(country)}">
<input name="city" class="form-control form-control-sm" placeholder="+ City" required size="10" style="width:auto">
<button class="btn btn-sm btn-outline-primary">Add</button>
@@ -136,123 +145,144 @@ function buildProductFormHtml(action, catOptions, locations) {
const isEdit = action.includes('/edit');
const title = isEdit ? 'Edit Product' : 'Add Product';
return `
<div class="row g-3">
<div class="col-12">
<h4 class="mb-3">${title}</h4>
</div>
<div class="col-md-12">
<label class="form-label">Location</label>
<div class="row g-2">
<div class="col-md-4">
<select name="location_country" id="loc-country" class="form-select form-select-sm" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
<form method="POST" action="${action}" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="__CSRF__">
<div class="row g-3">
<div class="col-12">
<h4 class="mb-3">${title}</h4>
</div>
<div class="col-md-12">
<label class="form-label">Location</label>
<div class="row g-2">
<div class="col-md-4">
<select name="location_country" id="loc-country" class="form-select form-select-sm" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_city" id="loc-city" class="form-select form-select-sm" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_id" id="loc-district" class="form-select form-select-sm" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
</div>
<div class="col-md-4">
<select name="location_city" id="loc-city" class="form-select form-select-sm" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
</div>
<div class="col-md-12">
<label class="form-label">Name</label>
<input name="name" class="form-control form-control-sm" required placeholder="Product name">
</div>
<div class="col-md-6">
<label class="form-label">Price ($)</label>
<input name="price" type="number" step="0.01" min="0.01" class="form-control form-control-sm" required placeholder="0.00">
</div>
<div class="col-md-6">
<label class="form-label">Stock</label>
<input name="quantity_in_stock" type="number" min="0" value="0" class="form-control form-control-sm" placeholder="0" id="stock-input">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()">
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
<div class="col-md-4">
<select name="location_id" id="loc-district" class="form-select form-select-sm" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<select name="category_id" id="pf-category" class="form-select form-select-sm" required onchange="updateSubcats(this.value)">
<option value="">-- Select --</option>${catOptions}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Subcategory</label>
<select name="subcategory_id" class="form-select form-select-sm">
<option value="">-- Subcategory --</option>
</select>
</div>
<div class="col-md-12" id="new-cat-row" style="display:none">
<div class="alert alert-info py-2 mb-2">
<small>No category? Create one for: <strong id="new-cat-loc-label"></strong></small>
</div>
<div class="d-inline-flex gap-2 w-100">
<input id="new-cat-name" class="form-control form-control-sm" placeholder="Category name" style="flex:1">
<input type="hidden" id="new-cat-location-id" value="">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addCategoryInline()">+ Add Category</button>
</div>
</div>
<div class="col-md-12" id="new-subcat-row" style="display:none">
<div class="alert alert-info py-2 mb-2">
<small>Add subcategory to: <strong id="new-subcat-cat-label"></strong></small>
</div>
<div class="d-inline-flex gap-2 w-100">
<input id="new-subcat-name" class="form-control form-control-sm" placeholder="Subcategory name" style="flex:1">
<input type="hidden" id="new-subcat-category-id" value="">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubcategoryInline()">+ Add Subcategory</button>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control form-control-sm" rows="3" placeholder="Public description"></textarea>
</div>
<div class="col-12">
<hr class="my-2">
<h6 class="text-muted mb-2">Public Photo</h6>
</div>
<div class="col-md-6">
<label class="form-label">Photo URL</label>
<input name="photo_url" class="form-control form-control-sm" placeholder="https://...">
</div>
<div class="col-md-6">
<label class="form-label">Or Upload Photo</label>
<input type="file" name="photo_file" accept="image/*" class="form-control form-control-sm">
</div>
<div class="col-12">
<hr class="my-2">
<h6 class="text-muted mb-2">Hidden Content (shown after purchase)</h6>
</div>
<div class="col-md-6">
<label class="form-label">Hidden Photo URL</label>
<input name="hidden_photo_url" class="form-control form-control-sm" placeholder="https://...">
</div>
<div class="col-md-6">
<label class="form-label">Or Upload Hidden Photo</label>
<input type="file" name="hidden_photo_file" accept="image/*" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label">Hidden Coordinates</label>
<input name="hidden_coordinates" class="form-control form-control-sm" placeholder="lat,lng">
</div>
<div class="col-md-6">
<label class="form-label">Hidden Description</label>
<textarea name="hidden_description" class="form-control form-control-sm" rows="2" placeholder="Shown after purchase"></textarea>
</div>
<div class="col-md-12">
<label class="form-label">Private Data</label>
<textarea name="private_data" class="form-control form-control-sm" rows="2" placeholder="Internal notes, not shown to users"></textarea>
</div>
<div class="col-12 mt-4">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Name</label>
<input name="name" class="form-control form-control-sm" required placeholder="Product name">
</div>
<div class="col-md-6">
<label class="form-label">Price ($)</label>
<input name="price" type="number" step="0.01" min="0" class="form-control form-control-sm" required placeholder="0.00">
</div>
<div class="col-md-6">
<label class="form-label">Stock</label>
<input name="quantity_in_stock" type="number" min="0" value="0" class="form-control form-control-sm" placeholder="0">
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<select name="category_id" id="pf-category" class="form-select form-select-sm" required onchange="updateSubcats(this.value)">
<option value="">-- Select --</option>${catOptions}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Subcategory</label>
<select name="subcategory_id" class="form-select form-select-sm">
<option value="">-- Subcategory --</option>
</select>
</div>
<div class="col-md-12" id="new-cat-row" style="display:none">
<div class="alert alert-info py-2 mb-2">
<small>No category? Create one for: <strong id="new-cat-loc-label"></strong></small>
</div>
<div class="d-inline-flex gap-2 w-100">
<input id="new-cat-name" class="form-control form-control-sm" placeholder="Category name" style="flex:1">
<input type="hidden" id="new-cat-location-id" value="">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addCategoryInline()">+ Add Category</button>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control form-control-sm" rows="3" placeholder="Public description"></textarea>
</div>
<div class="col-12">
<hr class="my-2">
<h6 class="text-muted mb-2">Public Photo</h6>
</div>
<div class="col-md-6">
<label class="form-label">Photo URL</label>
<input name="photo_url" class="form-control form-control-sm" placeholder="https://...">
</div>
<div class="col-md-6">
<label class="form-label">Or Upload Photo</label>
<input type="file" name="photo_file" accept="image/*" class="form-control form-control-sm">
</div>
<div class="col-12">
<hr class="my-2">
<h6 class="text-muted mb-2">Hidden Content (shown after purchase)</h6>
</div>
<div class="col-md-6">
<label class="form-label">Hidden Photo URL</label>
<input name="hidden_photo_url" class="form-control form-control-sm" placeholder="https://...">
</div>
<div class="col-md-6">
<label class="form-label">Or Upload Hidden Photo</label>
<input type="file" name="hidden_photo_file" accept="image/*" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label">Hidden Coordinates</label>
<input name="hidden_coordinates" class="form-control form-control-sm" placeholder="lat,lng">
</div>
<div class="col-md-6">
<label class="form-label">Hidden Description</label>
<textarea name="hidden_description" class="form-control form-control-sm" rows="2" placeholder="Shown after purchase"></textarea>
</div>
<div class="col-md-12">
<label class="form-label">Private Data</label>
<textarea name="private_data" class="form-control form-control-sm" rows="2" placeholder="Internal notes, not shown to users"></textarea>
</div>
<div class="col-12 mt-4">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>`;
</form>`;
}
router.get('/', async (req, res) => {
router.get('/', asyncHandler(async (req, res) => {
const { loc, cat, sub, msg, msg_type } = req.query;
const [locations, categories, subcategories] = await Promise.all([
db.allAsync('SELECT * FROM locations ORDER BY country, city, district'),
@@ -279,9 +309,10 @@ router.get('/', async (req, res) => {
}
const catOptions = categories.map(c => `<option value="${c.id}" data-loc="${c.location_id}">${esc(c.name)}</option>`).join('');
const treeHtml = buildTreeHtml(tree);
const addFormHtml = buildProductFormHtml('/catalog/products', catOptions, locations);
const editFormHtml = buildProductFormHtml('/catalog/products/__ID__/edit', catOptions, locations);
const csrf = res.locals.csrfToken || '';
const treeHtml = buildTreeHtml(tree, csrf);
const addFormHtml = buildProductFormHtml('/catalog/products', catOptions, locations).replace('__CSRF__', esc(csrf));
const editFormHtml = buildProductFormHtml('/catalog/products/__ID__/edit', catOptions, locations).replace('__CSRF__', esc(csrf));
res.render('catalog', {
title: 'Catalog',
@@ -295,74 +326,89 @@ router.get('/', async (req, res) => {
msg: msg || '',
msgType: msg_type || 'info',
});
});
}));
router.post('/locations', async (req, res) => {
router.post('/locations', asyncHandler(async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), (district||'').trim()]);
res.redirect('/catalog?msg=Location+added&msg_type=success');
});
}));
router.post('/locations/:id/delete', async (req, res) => {
router.post('/locations/:id/delete', asyncHandler(async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
const pc = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE location_id=?', [req.params.id]);
if (pc?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM locations WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Location+deleted&msg_type=success');
});
}));
router.post('/locations/add-city', async (req, res) => {
router.post('/locations/add-city', asyncHandler(async (req, res) => {
const { country, city } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), '']);
res.redirect('/catalog?msg=City+added&msg_type=success');
});
}));
router.post('/locations/add-district', async (req, res) => {
router.post('/locations/add-district', asyncHandler(async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city || !district) return res.redirect('/catalog?msg=All+fields+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), district.trim()]);
res.redirect('/catalog?msg=District+added&msg_type=success');
});
}));
router.post('/categories', async (req, res) => {
router.post('/categories', asyncHandler(async (req, res) => {
const { name, location_id } = req.body;
if (!name || !location_id) return res.redirect('/catalog?msg=Name+and+location+required&msg_type=error');
await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
res.redirect('/catalog?msg=Category+added&msg_type=success');
});
}));
router.post('/categories/json', async (req, res) => {
router.post('/categories/json', asyncHandler(async (req, res) => {
const { name, location_id } = req.body;
if (!name || !location_id) return res.status(400).json({ error: 'Name and location required' });
const result = await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
const cat = await db.getAsync('SELECT * FROM categories WHERE id=?', [result.lastInsertRowid]);
res.json(cat);
});
}));
router.post('/categories/:id/delete', async (req, res) => {
router.post('/categories/:id/delete', asyncHandler(async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
const sc = await db.getAsync(
'SELECT COUNT(*) as n FROM products WHERE subcategory_id IN (SELECT id FROM subcategories WHERE category_id=?)',
[req.params.id]
);
if (sc?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products+in+subcategories&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE category_id=?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Category+deleted&msg_type=success');
});
}));
router.post('/categories/:id/subcategories', async (req, res) => {
router.post('/categories/:id/subcategories', asyncHandler(async (req, res) => {
const { name } = req.body;
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
await db.runAsync('INSERT INTO subcategories (category_id,name) VALUES (?,?)', [req.params.id, name.trim()]);
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
});
}));
router.post('/subcategories/:id/delete', async (req, res) => {
router.post('/categories/:id/subcategories/json', asyncHandler(async (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const result = await db.runAsync('INSERT INTO subcategories (category_id,name) VALUES (?,?)', [req.params.id, name.trim()]);
const sub = await db.getAsync('SELECT * FROM subcategories WHERE id=?', [result.lastInsertRowid]);
res.json(sub);
}));
router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Subcategory+deleted&msg_type=success');
});
}));
export default router;

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
import crypto from 'crypto';
import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
import { validateCsrfFromBody } from '../csrf.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const uploadsDir = join(__dirname, '..', '..', '..', 'uploads');
@@ -30,31 +31,41 @@ const upload = multer({
const router = Router();
router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), asyncHandler(async (req, res) => {
if (!validateCsrfFromBody(req, res)) return;
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (parseInt(quantity_in_stock, 10) || 0);
await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url,
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock, 10) || 0, description || '', pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId]);
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id,is_mono)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), priceNum, stockQty, description || '', pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
}));
router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), asyncHandler(async (req, res) => {
if (!validateCsrfFromBody(req, res)) return;
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (parseInt(quantity_in_stock, 10) || 0);
await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?,
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=? WHERE id=?`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock, 10) || 0, description || '', pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, req.params.id]);
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=?,is_mono=? WHERE id=?`,
[name.trim(), priceNum, stockQty, description || '', pu, hu,
hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag, req.params.id]);
res.redirect('/catalog?msg=Product+updated&msg_type=success');
}));

View File

@@ -1,9 +1,10 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
const router = Router();
router.get('/', async (req, res) => {
router.get('/', asyncHandler(async (req, res) => {
const [products, categories, subcategories] = await Promise.all([
db.allAsync(`SELECT p.*, c.name as category_name, s.name as subcategory_name
FROM products p
@@ -14,41 +15,60 @@ router.get('/', async (req, res) => {
db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'),
]);
res.render('products', { title: 'Products', products, categories, subcategories });
});
}));
router.post('/', async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
router.post('/', asyncHandler(async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, is_mono } = req.body;
if (!name || !price || !category_id) {
return res.redirect('/products?error=Name+price+and+category+are+required');
}
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) {
return res.redirect('/products?error=Price+must+be+greater+than+0');
}
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0);
await db.runAsync(
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id)
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?))`,
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, category_id]
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id, is_mono)
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?), ?)`,
[name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, category_id, monoFlag]
);
res.redirect('/products');
});
}));
router.get('/:id/edit', async (req, res) => {
const [product, categories, subcategories] = await Promise.all([
router.get('/:id/edit', asyncHandler(async (req, res) => {
const [product, categories, subcategories, locations] = await Promise.all([
db.getAsync('SELECT * FROM products WHERE id = ?', [req.params.id]),
db.allAsync('SELECT id, name FROM categories ORDER BY name'),
db.allAsync('SELECT id, name, location_id FROM categories ORDER BY name'),
db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'),
db.allAsync('SELECT id, country, city, district FROM locations ORDER BY country, city, district'),
]);
if (!product) return res.status(404).send('Product not found');
res.render('product-edit', { title: 'Edit Product', product, categories, subcategories });
});
res.render('product-edit', { title: 'Edit Product', product, categories, subcategories, locations });
}));
router.post('/:id/update', async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
router.post('/:id/update', asyncHandler(async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id, is_mono } = req.body;
if (!name || !price || !category_id) {
return res.redirect('/products?error=Name+price+and+category+are+required');
}
const priceNum = parseFloat(price);
if (isNaN(priceNum) || priceNum <= 0) {
return res.redirect('/products?error=Price+must+be+greater+than+0');
}
const monoFlag = is_mono === '1' ? 1 : 0;
const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0);
await db.runAsync(
`UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=?
`UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=?, location_id=?, is_mono=?
WHERE id=?`,
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, req.params.id]
[name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, location_id || null, monoFlag, req.params.id]
);
res.redirect('/products');
});
}));
router.post('/:id/delete', async (req, res) => {
router.post('/:id/delete', asyncHandler(async (req, res) => {
await db.runAsync('DELETE FROM products WHERE id = ?', [req.params.id]);
res.redirect('/products');
});
}));
export default router;

View File

@@ -181,4 +181,101 @@ router.post('/export-seeds', async (req, res) => {
}
});
router.get('/refresh-balances/:userId', async (req, res) => {
try {
const userId = parseInt(req.params.userId, 10);
if (isNaN(userId) || userId <= 0) {
return res.status(400).json({ error: 'Invalid user ID' });
}
const wallets = await db.allAsync(
`SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#'`,
[userId]
);
if (wallets.length === 0) {
return res.json({ wallets: [], totalBalance: 0, balances: {}, timestamp: new Date().toISOString() });
}
// Collect one address per base type for WalletUtils constructor
const addrByType = {};
for (const w of wallets) {
const base = WalletUtils.getBaseWalletType(w.wallet_type).toUpperCase();
if (!addrByType[base]) addrByType[base] = w.address;
}
const walletUtils = new WalletUtils(
addrByType.BTC || null,
addrByType.LTC || null,
addrByType.ETH || null,
addrByType.USDT || null,
addrByType.USDC || null,
userId,
null
);
const balances = await walletUtils.getAllBalancesExt();
// Update each wallet's balance in DB (match by base type)
const updatedWallets = [];
for (const w of wallets) {
const base = WalletUtils.getBaseWalletType(w.wallet_type).toUpperCase();
const bal = balances[base];
const newBalance = bal ? bal.amount : (w.balance || 0);
await db.runAsync(
`UPDATE crypto_wallets SET balance = ? WHERE id = ?`,
[newBalance, w.id]
);
updatedWallets.push({
id: w.id,
wallet_type: w.wallet_type,
address: w.address,
balance: newBalance,
usdValue: bal ? bal.usdValue : 0,
created_at: w.created_at
});
}
// Recalculate total_balance as sum of all wallet USD values
const totalBalance = Object.values(balances).reduce((sum, b) => sum + (b.usdValue || 0), 0);
await db.runAsync(
`UPDATE users SET total_balance = ? WHERE id = ?`,
[totalBalance, userId]
);
res.json({
wallets: updatedWallets,
totalBalance,
balances,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error({ err: error, userId: req.params.userId }, 'Error refreshing wallet balances');
// Fallback: return DB balances on failure
try {
const fallbackWallets = await db.allAsync(
`SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#'`,
[parseInt(req.params.userId, 10)]
);
const fallbackUser = await db.getAsync('SELECT total_balance FROM users WHERE id = ?', [parseInt(req.params.userId, 10)]);
return res.status(200).json({
wallets: fallbackWallets.map(w => ({
id: w.id, wallet_type: w.wallet_type, address: w.address,
balance: w.balance, usdValue: 0, created_at: w.created_at
})),
totalBalance: fallbackUser ? fallbackUser.total_balance : 0,
balances: {},
timestamp: new Date().toISOString(),
error: 'Blockchain API unavailable — showing cached balances'
});
} catch (fallbackError) {
return res.status(500).json({ error: 'Failed to refresh balances' });
}
}
});
export default router;

View File

@@ -18,6 +18,7 @@
</button>
<div class="collapse mb-3" id="addLocationCollapse">
<form method="POST" action="/catalog/locations" class="d-inline-flex gap-2 w-100">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="country" class="form-control form-control-sm" placeholder="Country" required>
<input name="city" class="form-control form-control-sm" placeholder="City" required>
<input name="district" class="form-control form-control-sm" placeholder="District">
@@ -72,10 +73,11 @@
<td><%= p.cn || '' %></td>
<td><%= p.sn || '' %></td>
<td>$<%= Number(p.price || 0).toFixed(2) %></td>
<td><%= p.quantity_in_stock || 0 %></td>
<td><%= p.is_mono ? '∞' : (p.quantity_in_stock || 0) %></td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openEdit(<%= p.id %>)">&#9998;</button>
<form method="POST" action="/catalog/products/<%= p.id %>/delete" class="d-inline" onsubmit="return confirm('Delete?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">&#10005;</button>
</form>
</td>
@@ -112,6 +114,7 @@ var allLocations = <%- JSON.stringify(locations.map(function(l) { return { id: l
var allCategories = <%- JSON.stringify(categories.map(function(c) { return { id: c.id, name: c.name, location_id: c.location_id }; })) %>;
var addFormTpl = <%- JSON.stringify(addFormHtml) %>;
var editFormTpl = <%- JSON.stringify(editFormHtml) %>;
var csrfToken = <%- JSON.stringify(csrfToken) %>;
var productModal = null;
document.addEventListener('DOMContentLoaded', function() {
@@ -207,7 +210,7 @@ async function addCategoryInline() {
var locId = locIdInput.value;
if (!name || !locId) return;
var btn = nameInput.nextElementSibling;
var btn = locIdInput.nextElementSibling;
var originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
btn.disabled = true;
@@ -215,7 +218,7 @@ async function addCategoryInline() {
try {
var res = await fetch('/catalog/categories/json', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json', 'x-csrf-token': csrfToken},
body: JSON.stringify({name: name, location_id: parseInt(locId)})
});
var cat = await res.json();
@@ -231,6 +234,7 @@ async function addCategoryInline() {
var catSel = document.getElementById('pf-category');
catSel.value = cat.id;
updateSubcats(cat.id);
showSubcatInline(cat.id);
btn.innerHTML = originalContent;
btn.disabled = false;
} catch(e) {
@@ -240,6 +244,54 @@ async function addCategoryInline() {
}
}
function showSubcatInline(catId) {
var row = document.getElementById('new-subcat-row');
if (!row) return;
row.style.display = catId ? '' : 'none';
if (catId) {
document.getElementById('new-subcat-category-id').value = catId;
var cat = allCategories.find(function(c) { return c.id == catId; });
document.getElementById('new-subcat-cat-label').textContent = cat ? cat.name : '';
}
}
async function addSubcategoryInline() {
var nameInput = document.getElementById('new-subcat-name');
var catIdInput = document.getElementById('new-subcat-category-id');
var name = nameInput.value.trim();
var catId = catIdInput.value;
if (!name || !catId) return;
var btn = catIdInput.nextElementSibling;
var originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
btn.disabled = true;
try {
var res = await fetch('/catalog/categories/' + catId + '/subcategories/json', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'x-csrf-token': csrfToken},
body: JSON.stringify({name: name})
});
var sub = await res.json();
if (sub.error) {
alert(sub.error);
btn.innerHTML = originalContent;
btn.disabled = false;
return;
}
subcats.push(sub);
nameInput.value = '';
updateSubcats(catId);
btn.innerHTML = originalContent;
btn.disabled = false;
} catch(e) {
alert('Error adding subcategory');
btn.innerHTML = originalContent;
btn.disabled = false;
}
}
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Node label click navigation
@@ -269,7 +321,8 @@ function openAdd() {
async function openEdit(id) {
var r = await fetch('/catalog/products/' + id + '/json');
var p = await r.json();
document.getElementById('modal-body').innerHTML = editFormTpl.replace('/__ID__/', '/' + p.id + '/');
var html = editFormTpl.replace('__ID__', id);
document.getElementById('modal-body').innerHTML = html;
document.getElementById('productModalLabel').textContent = 'Edit Product';
fillEditForm(p);
productModal.show();
@@ -287,6 +340,11 @@ function fillEditForm(p) {
f.querySelector('[name=hidden_coordinates]').value = p.hidden_coordinates || '';
f.querySelector('[name=hidden_description]').value = p.hidden_description || '';
f.querySelector('[name=private_data]').value = p.private_data || '';
var monoCheck = f.querySelector('[name=is_mono]');
if (monoCheck) {
monoCheck.checked = p.is_mono == 1;
toggleMonoProduct();
}
initLocationSelects(p.location_id);
if (p.category_id) {
setTimeout(function() {
@@ -315,6 +373,22 @@ function updateSubcats(catId, selSub) {
document.addEventListener('change', function(e) {
if (e.target.name === 'category_id' && e.target.closest('#product-modal')) {
updateSubcats(e.target.value);
showSubcatInline(e.target.value);
}
});
function toggleMonoProduct() {
var check = document.getElementById('is-mono-check');
var stockInput = document.getElementById('stock-input');
if (!check || !stockInput) return;
if (check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '0';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
</script>

View File

@@ -167,7 +167,7 @@ export function renderCatalog(tree, products, filter, categories, subcategories,
<td>${esc(p.cn||'')}</td>
<td>${esc(p.sn||'')}</td>
<td>$${(p.price||0).toFixed(2)}</td>
<td>${p.quantity_in_stock||0}</td>
<td>${p.is_mono ? '∞' : (p.quantity_in_stock||0)}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openEdit(${p.id})">✎</button>
<form method="POST" action="/catalog/products/${p.id}/delete" class="d-inline" onsubmit="return confirm('Delete?')">
@@ -339,7 +339,7 @@ async function addCategoryInline() {
const locId = locIdInput.value;
if (!name || !locId) return;
const btn = nameInput.nextElementSibling;
const btn = locIdInput.nextElementSibling;
const originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
btn.disabled = true;
@@ -363,6 +363,7 @@ async function addCategoryInline() {
const catSel = document.getElementById('pf-category');
catSel.value = cat.id;
updateSubcats(cat.id);
showSubcatInline(cat.id);
btn.innerHTML = originalContent;
btn.disabled = false;
} catch(e) {
@@ -372,6 +373,54 @@ async function addCategoryInline() {
}
}
function showSubcatInline(catId) {
const row = document.getElementById('new-subcat-row');
if (!row) return;
row.style.display = catId ? '' : 'none';
if (catId) {
document.getElementById('new-subcat-category-id').value = catId;
const cat = allCategories.find(c => c.id == catId);
document.getElementById('new-subcat-cat-label').textContent = cat ? cat.name : '';
}
}
async function addSubcategoryInline() {
const nameInput = document.getElementById('new-subcat-name');
const catIdInput = document.getElementById('new-subcat-category-id');
const name = nameInput.value.trim();
const catId = catIdInput.value;
if (!name || !catId) return;
const btn = catIdInput.nextElementSibling;
const originalContent = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
btn.disabled = true;
try {
const res = await fetch('/catalog/categories/' + catId + '/subcategories/json', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name})
});
const sub = await res.json();
if (sub.error) {
alert(sub.error);
btn.innerHTML = originalContent;
btn.disabled = false;
return;
}
subcats.push(sub);
nameInput.value = '';
updateSubcats(catId);
btn.innerHTML = originalContent;
btn.disabled = false;
} catch(e) {
alert('Error adding subcategory');
btn.innerHTML = originalContent;
btn.disabled = false;
}
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Node label click navigation
@@ -419,6 +468,11 @@ function fillEditForm(p) {
f.querySelector('[name=hidden_coordinates]').value = p.hidden_coordinates || '';
f.querySelector('[name=hidden_description]').value = p.hidden_description || '';
f.querySelector('[name=private_data]').value = p.private_data || '';
const monoCheck = f.querySelector('[name=is_mono]');
if(monoCheck) {
monoCheck.checked = p.is_mono == 1;
toggleMonoProduct();
}
initLocationSelects(p.location_id);
if(p.category_id) {
setTimeout(() => {
@@ -447,8 +501,24 @@ function updateSubcats(catId, selSub) {
document.addEventListener('change', e => {
if(e.target.name === 'category_id' && e.target.closest('#product-modal')) {
updateSubcats(e.target.value);
showSubcatInline(e.target.value);
}
});
function toggleMonoProduct() {
const check = document.getElementById('is-mono-check');
const stockInput = document.getElementById('stock-input');
if(!check || !stockInput) return;
if(check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '0';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
</script>`;
return layout('Catalog', content, 'catalog');
}

View File

@@ -39,7 +39,14 @@ export function renderProductEditForm(action, catOptions, subcatJson, locations)
</div>
<div class="col-md-6">
<label class="form-label">Stock</label>
<input name="quantity_in_stock" type="number" min="0" value="0" class="form-control form-control-sm" placeholder="0">
<input name="quantity_in_stock" type="number" min="0" value="0" class="form-control form-control-sm" placeholder="0" id="stock-input">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()">
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
</div>
<div class="col-md-6">
@@ -66,6 +73,17 @@ export function renderProductEditForm(action, catOptions, subcatJson, locations)
</div>
</div>
<div class="col-md-12" id="new-subcat-row" style="display:none">
<div class="alert alert-info py-2 mb-2">
<small>Add subcategory to: <strong id="new-subcat-cat-label"></strong></small>
</div>
<div class="d-inline-flex gap-2 w-100">
<input id="new-subcat-name" class="form-control form-control-sm" placeholder="Subcategory name" style="flex:1">
<input type="hidden" id="new-subcat-category-id" value="">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="addSubcategoryInline()">+ Add Subcategory</button>
</div>
</div>
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control form-control-sm" rows="3" placeholder="Public description"></textarea>

View File

@@ -5,6 +5,27 @@
<div class="panel-container">
<div class="panel-content">
<form method="POST" action="/products/<%= product.id %>/update" id="editProductForm" class="row g-3">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="col-md-12">
<label class="form-label">Location</label>
<div class="row g-2">
<div class="col-md-4">
<select name="location_country" id="loc-country" class="form-select form-select-sm" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_city" id="loc-city" class="form-select form-select-sm" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_id" id="loc-district" class="form-select form-select-sm" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
</div>
</div>
<div class="col-md-6">
<label for="edit-name" class="form-label">Product Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-name" name="name" value="<%= product.name %>" required>
@@ -13,13 +34,19 @@
<label for="edit-price" class="form-label">Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="edit-price" name="price" value="<%= product.price %>" step="0.01" min="0" required>
<input type="number" class="form-control" id="edit-price" name="price" value="<%= product.price %>" step="0.01" min="0.01" required>
</div>
</div>
<div class="col-md-6">
<label for="edit-stock" class="form-label">Stock Quantity</label>
<input type="number" class="form-control" id="edit-stock" name="quantity_in_stock" value="<%= product.quantity_in_stock || 0 %>" min="0">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()" <%= product.is_mono ? 'checked' : '' %>>
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
</div>
<div class="col-md-6">
<label for="edit-category" class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="edit-category" name="category_id" required>
@@ -58,6 +85,9 @@
</div>
<script>
var allLocations = <%- JSON.stringify(locations.map(function(l) { return { id: l.id, country: l.country, city: l.city, district: l.district }; })) %>;
var allCategories = <%- JSON.stringify(categories.map(function(c) { return { id: c.id, name: c.name, location_id: c.location_id }; })) %>;
(function() {
var catSel = document.getElementById('edit-category');
var subSel = document.getElementById('edit-subcategory');
@@ -72,5 +102,93 @@
}
});
});
})();
initLocationSelects(<%= product.location_id || 'null' %>);
toggleMonoProduct();
})();
function toggleMonoProduct() {
var check = document.getElementById('is-mono-check');
var stockInput = document.getElementById('edit-stock');
if (!check || !stockInput) return;
if (check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '<%= product.quantity_in_stock || 0 %>';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
function initLocationSelects(selectedLocId) {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
if (!cs) return;
var countries = Array.from(new Set(allLocations.map(function(l) { return l.country; }))).sort();
cs.innerHTML = '<option value="">-- Country --</option>' + countries.map(function(c) { return '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>'; }).join('');
ci.innerHTML = '<option value="">-- City --</option>'; ci.disabled = true;
di.innerHTML = '<option value="">-- District --</option>';
if (selectedLocId) {
var sel = allLocations.find(function(l) { return l.id == selectedLocId; });
if (sel) {
cs.value = sel.country;
locOnCountryChange();
ci.value = sel.city;
locOnCityChange();
di.value = sel.id;
locOnDistrictChange();
}
} else {
updateCategoryOptions(null);
}
}
function locOnCountryChange() {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
var country = cs.value;
ci.innerHTML = '<option value="">-- City --</option>';
di.innerHTML = '<option value="">-- District --</option>';
if (!country) { ci.disabled = true; updateCategoryOptions(null); return; }
ci.disabled = false;
var cities = Array.from(new Set(allLocations.filter(function(l) { return l.country === country; }).map(function(l) { return l.city; }))).sort();
ci.innerHTML = '<option value="">-- City --</option>' + cities.map(function(c) { return '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>'; }).join('');
updateCategoryOptions(null);
}
function locOnCityChange() {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
var country = cs.value;
var city = ci.value;
di.innerHTML = '<option value="">-- District --</option>';
if (!city) { updateCategoryOptions(null); return; }
var locs = allLocations.filter(function(l) { return l.country === country && l.city === city; });
di.innerHTML = '<option value="">-- District --</option>' + locs.map(function(l) { return '<option value="'+l.id+'">'+escHtml(l.district || l.city)+'</option>'; }).join('');
updateCategoryOptions(null);
}
function locOnDistrictChange() {
var di = document.getElementById('loc-district');
var locId = di ? di.value : '';
updateCategoryOptions(locId || null);
}
function updateCategoryOptions(locId) {
var catSel = document.getElementById('edit-category');
if (!catSel) return;
var currentVal = catSel.value;
var filtered = locId ? allCategories.filter(function(c) { return c.location_id == locId; }) : allCategories;
catSel.innerHTML = filtered.map(function(c) { return '<option value="'+c.id+'">'+escHtml(c.name)+'</option>'; }).join('');
if (filtered.find(function(c) { return c.id == currentVal; })) catSel.value = currentVal;
}
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
</script>

View File

@@ -5,6 +5,7 @@
<div class="panel-container">
<div class="panel-content">
<form method="POST" action="/products" id="addProductForm" class="row g-3">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="col-md-6">
<label for="add-name" class="form-label">Product Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="add-name" name="name" placeholder="Enter product name" required>
@@ -13,13 +14,19 @@
<label for="add-price" class="form-label">Price <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" id="add-price" name="price" placeholder="0.00" step="0.01" min="0" required>
<input type="number" class="form-control" id="add-price" name="price" placeholder="0.00" step="0.01" min="0.01" required>
</div>
</div>
<div class="col-md-6">
<label for="add-stock" class="form-label">Stock Quantity</label>
<input type="number" class="form-control" id="add-stock" name="quantity_in_stock" placeholder="0" value="0" min="0">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()">
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
</div>
<div class="col-md-6">
<label for="add-category" class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="add-category" name="category_id" required>
@@ -86,10 +93,11 @@
<td><%= p.category_name || '-' %></td>
<td><%= p.subcategory_name || '-' %></td>
<td>$<%= Number(p.price || 0).toFixed(2) %></td>
<td><%= p.quantity_in_stock || 0 %></td>
<td><%= p.is_mono ? '∞' : (p.quantity_in_stock || 0) %></td>
<td>
<a href="/products/<%= p.id %>/edit" class="btn btn-sm btn-outline-primary">Edit</a>
<form method="POST" action="/products/<%= p.id %>/delete" class="d-inline" onsubmit="return confirm('Delete?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
@@ -117,5 +125,20 @@
}
});
});
})();
})();
function toggleMonoProduct() {
var check = document.getElementById('is-mono-check');
var stockInput = document.getElementById('add-stock');
if (!check || !stockInput) return;
if (check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '0';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
</script>

View File

@@ -16,7 +16,7 @@ export function renderProductList(products, categories, subcategories) {
<td>${escapeHtml(p.category_name) || '-'}</td>
<td>${escapeHtml(p.subcategory_name) || '-'}</td>
<td>$${(p.price || 0).toFixed(2)}</td>
<td>${p.quantity_in_stock || 0}</td>
<td>${p.is_mono ? '∞' : (p.quantity_in_stock || 0)}</td>
<td>
<a href="/products/${p.id}/edit" class="btn btn-sm btn-outline-primary">Edit</a>
<form method="POST" action="/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')">
@@ -48,6 +48,12 @@ export function renderProductList(products, categories, subcategories) {
<label for="add-stock" class="form-label">Stock Quantity</label>
<input type="number" class="form-control" id="add-stock" name="quantity_in_stock" placeholder="0" value="0" min="0">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()">
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
</div>
<div class="col-md-6">
<label for="add-category" class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="add-category" name="category_id" required>
@@ -123,12 +129,27 @@ export function renderProductList(products, categories, subcategories) {
});
});
})();
function toggleMonoProduct() {
var check = document.getElementById('is-mono-check');
var stockInput = document.getElementById('add-stock');
if (!check || !stockInput) return;
if (check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '0';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
</script>`;
return layout('Products', content, 'products');
}
export function renderProductEdit(product, categories, subcategories) {
export function renderProductEdit(product, categories, subcategories, locations) {
const catOptions = categories.map(c =>
`<option value="${c.id}" ${c.id === product.category_id ? 'selected' : ''}>${escapeHtml(c.name)}</option>`
).join('');
@@ -137,6 +158,9 @@ export function renderProductEdit(product, categories, subcategories) {
`<option value="${s.id}" data-cat="${s.category_id}" ${s.id === product.subcategory_id ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const locJson = JSON.stringify((locations || []).map(l => ({ id: l.id, country: l.country, city: l.city, district: l.district })));
const catJson = JSON.stringify(categories.map(c => ({ id: c.id, name: c.name, location_id: c.location_id })));
const content = `
<div class="panel">
<div class="panel-hdr">
@@ -145,6 +169,26 @@ export function renderProductEdit(product, categories, subcategories) {
<div class="panel-container">
<div class="panel-content">
<form method="POST" action="/products/${product.id}/update" id="editProductForm" class="row g-3">
<div class="col-md-12">
<label class="form-label">Location</label>
<div class="row g-2">
<div class="col-md-4">
<select name="location_country" id="loc-country" class="form-select form-select-sm" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_city" id="loc-city" class="form-select form-select-sm" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
</div>
<div class="col-md-4">
<select name="location_id" id="loc-district" class="form-select form-select-sm" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
</div>
</div>
<div class="col-md-6">
<label for="edit-name" class="form-label">Product Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-name" name="name" value="${escapeHtml(product.name)}" required>
@@ -160,6 +204,12 @@ export function renderProductEdit(product, categories, subcategories) {
<label for="edit-stock" class="form-label">Stock Quantity</label>
<input type="number" class="form-control" id="edit-stock" name="quantity_in_stock" value="${product.quantity_in_stock || 0}" min="0">
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="is_mono" value="1" id="is-mono-check" onchange="toggleMonoProduct()" ${product.is_mono ? 'checked' : ''}>
<label class="form-check-label" for="is-mono-check">📦 Digital Product (infinite stock)</label>
</div>
</div>
<div class="col-md-6">
<label for="edit-category" class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="edit-category" name="category_id" required>
@@ -194,13 +244,16 @@ export function renderProductEdit(product, categories, subcategories) {
</div>
<script>
var allLocations = ${locJson};
var allCategories = ${catJson};
(function() {
const catSel = document.getElementById('edit-category');
const subSel = document.getElementById('edit-subcategory');
const allOpts = Array.from(subSel.options);
var catSel = document.getElementById('edit-category');
var subSel = document.getElementById('edit-subcategory');
var allOpts = Array.from(subSel.options);
catSel.addEventListener('change', function() {
const catId = this.value;
var catId = this.value;
subSel.innerHTML = '<option value="">-- None --</option>';
allOpts.forEach(function(o) {
if (o.dataset.cat === catId || !o.dataset.cat) {
@@ -208,7 +261,95 @@ export function renderProductEdit(product, categories, subcategories) {
}
});
});
initLocationSelects(${product.location_id || 'null'});
toggleMonoProduct();
})();
function toggleMonoProduct() {
var check = document.getElementById('is-mono-check');
var stockInput = document.getElementById('edit-stock');
if (!check || !stockInput) return;
if (check.checked) {
stockInput.value = '999999';
stockInput.disabled = true;
stockInput.style.backgroundColor = '#e9ecef';
} else {
stockInput.value = '${product.quantity_in_stock || 0}';
stockInput.disabled = false;
stockInput.style.backgroundColor = '';
}
}
function initLocationSelects(selectedLocId) {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
if (!cs) return;
var countries = Array.from(new Set(allLocations.map(function(l) { return l.country; }))).sort();
cs.innerHTML = '<option value="">-- Country --</option>' + countries.map(function(c) { return '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>'; }).join('');
ci.innerHTML = '<option value="">-- City --</option>'; ci.disabled = true;
di.innerHTML = '<option value="">-- District --</option>';
if (selectedLocId) {
var sel = allLocations.find(function(l) { return l.id == selectedLocId; });
if (sel) {
cs.value = sel.country;
locOnCountryChange();
ci.value = sel.city;
locOnCityChange();
di.value = sel.id;
locOnDistrictChange();
}
} else {
updateCategoryOptions(null);
}
}
function locOnCountryChange() {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
var country = cs.value;
ci.innerHTML = '<option value="">-- City --</option>';
di.innerHTML = '<option value="">-- District --</option>';
if (!country) { ci.disabled = true; updateCategoryOptions(null); return; }
ci.disabled = false;
var cities = Array.from(new Set(allLocations.filter(function(l) { return l.country === country; }).map(function(l) { return l.city; }))).sort();
ci.innerHTML = '<option value="">-- City --</option>' + cities.map(function(c) { return '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>'; }).join('');
updateCategoryOptions(null);
}
function locOnCityChange() {
var cs = document.getElementById('loc-country');
var ci = document.getElementById('loc-city');
var di = document.getElementById('loc-district');
var country = cs.value;
var city = ci.value;
di.innerHTML = '<option value="">-- District --</option>';
if (!city) { updateCategoryOptions(null); return; }
var locs = allLocations.filter(function(l) { return l.country === country && l.city === city; });
di.innerHTML = '<option value="">-- District --</option>' + locs.map(function(l) { return '<option value="'+l.id+'">'+escHtml(l.district || l.city)+'</option>'; }).join('');
updateCategoryOptions(null);
}
function locOnDistrictChange() {
var di = document.getElementById('loc-district');
var locId = di ? di.value : '';
updateCategoryOptions(locId || null);
}
function updateCategoryOptions(locId) {
var catSel = document.getElementById('edit-category');
if (!catSel) return;
var currentVal = catSel.value;
var filtered = locId ? allCategories.filter(function(c) { return c.location_id == locId; }) : allCategories;
catSel.innerHTML = filtered.map(function(c) { return '<option value="'+c.id+'">'+escHtml(c.name)+'</option>'; }).join('');
if (filtered.find(function(c) { return c.id == currentVal; })) catSel.value = currentVal;
}
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
</script>`;
return layout('Edit Product', content, 'products');

View File

@@ -40,12 +40,16 @@
<div class="panel panel-icon">
<div class="panel-hdr">
<h2>Balances</h2>
<div class="panel-toolbar">
<button id="btn-refresh-wallets" class="btn btn-sm btn-outline-primary" title="Refresh balances from blockchain">🔄 Refresh</button>
<span id="refresh-status" class="text-muted small ms-2"></span>
</div>
</div>
<div class="panel-container show">
<div class="panel-content">
<p><strong>Main:</strong> $<%= Number(selectedUser.total_balance || 0).toFixed(2) %></p>
<p><strong>Bonus:</strong> $<%= Number(selectedUser.bonus_balance || 0).toFixed(2) %></p>
<p><strong>Available:</strong> $<%= Number((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2) %></p>
<p><strong>Main:</strong> $<span id="bal-main"><%= Number(selectedUser.total_balance || 0).toFixed(2) %></span></p>
<p><strong>Bonus:</strong> $<span id="bal-bonus"><%= Number(selectedUser.bonus_balance || 0).toFixed(2) %></span></p>
<p><strong>Available:</strong> $<span id="bal-available"><%= Number((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2) %></span></p>
<p><strong>Status:</strong> <span class="badge <%= selectedUser.status === 0 ? 'bg-success' : 'bg-danger' %>"><%= selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted' %></span></p>
</div>
</div>
@@ -336,5 +340,133 @@
document.body.removeChild(ta);
});
});
// --- Auto-refresh wallet balances ---
var refreshInterval = null;
var currentUserId = <%= selectedUser ? selectedUser.id : 'null' %>;
var csrfToken = <%- JSON.stringify(csrfToken) %>;
var REFRESH_INTERVAL_MS = 30000;
function formatBalance(val) {
return Number(val || 0).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0');
}
function setRefreshing(active) {
var btn = document.getElementById('btn-refresh-wallets');
var status = document.getElementById('refresh-status');
if (!btn || !status) return;
if (active) {
btn.disabled = true;
btn.textContent = '⏳ Refreshing...';
status.textContent = 'Fetching blockchain data...';
} else {
btn.disabled = false;
btn.textContent = '🔄 Refresh';
}
}
function setLastRefresh(ts) {
var status = document.getElementById('refresh-status');
if (!status) return;
var d = new Date(ts);
status.textContent = 'Last refresh: ' + d.toLocaleTimeString();
}
function setError(msg) {
var status = document.getElementById('refresh-status');
if (!status) return;
status.textContent = '⚠ ' + msg;
status.className = 'text-danger small ms-2';
}
function highlightRow(row) {
row.style.transition = 'background-color 0.3s';
row.style.backgroundColor = '#d4edda';
setTimeout(function() { row.style.backgroundColor = ''; }, 1500);
}
function updateWalletTable(wallets) {
var tbody = document.querySelector('.table tbody');
if (!tbody) return;
var rows = tbody.querySelectorAll('tr');
var walletMap = {};
wallets.forEach(function(w) { walletMap[w.wallet_type] = w; });
rows.forEach(function(row) {
var typeCell = row.cells[0];
var balCell = row.cells[2];
if (!typeCell || !balCell) return;
var type = typeCell.textContent.trim();
var w = walletMap[type];
if (w) {
var newBal = formatBalance(w.balance);
if (balCell.textContent.trim() !== newBal) {
balCell.textContent = newBal;
highlightRow(row);
}
}
});
}
function updateBalances(data) {
var mainEl = document.getElementById('bal-main');
var availEl = document.getElementById('bal-available');
if (mainEl) mainEl.textContent = Number(data.totalBalance || 0).toFixed(2);
if (availEl) {
var bonusText = document.getElementById('bal-bonus');
var bonus = bonusText ? parseFloat(bonusText.textContent) || 0 : 0;
availEl.textContent = (Number(data.totalBalance || 0) + bonus).toFixed(2);
}
}
function fetchBalances() {
if (!currentUserId) return;
setRefreshing(true);
fetch('/wallets/refresh-balances/' + currentUserId, {
headers: { 'x-csrf-token': csrfToken }
})
.then(function(res) { return res.json(); })
.then(function(data) {
setRefreshing(false);
if (data.error) {
setError(data.error);
} else {
updateWalletTable(data.wallets);
updateBalances(data);
setLastRefresh(data.timestamp);
}
})
.catch(function(err) {
setRefreshing(false);
setError('Network error — retrying in ' + (REFRESH_INTERVAL_MS / 1000) + 's');
});
}
function startPolling() {
stopPolling();
if (!currentUserId) return;
fetchBalances();
refreshInterval = setInterval(fetchBalances, REFRESH_INTERVAL_MS);
}
function stopPolling() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
// Manual refresh button
var refreshBtn = document.getElementById('btn-refresh-wallets');
if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
fetchBalances();
});
}
// Start polling if a user is selected
if (currentUserId) {
startPolling();
}
})();
</script>

View File

@@ -432,7 +432,7 @@ export default class UserProductHandler {
${t('products.product_price')}: $${product.price}
${t('products.product_description')}: ${product.description}
${t('products.product_available')}: ${product.quantity_in_stock} pcs
${product.is_mono ? t('products.infinite_stock') : `${t('products.product_available')}: ${product.quantity_in_stock} pcs`}
${t('products.product_category')}: ${product.category_name}
`;
@@ -444,7 +444,10 @@ export default class UserProductHandler {
}
const keyboard = {
inline_keyboard: [
inline_keyboard: product.is_mono ? [
[{ text: t('products.buy_now'), callback_data: `buy_product_${productId}` }],
[{ text: `${t('products.back')} ${product.category_name}`, callback_data: `shop_category_${product.location_id}_${product.category_id}` }]
] : [
[{ text: t('products.buy_now'), callback_data: `buy_product_${productId}` }],
[
{
@@ -628,7 +631,7 @@ export default class UserProductHandler {
throw new Error('Product not found');
}
const quantity = state?.quantity || 1;
const quantity = product.is_mono ? 1 : (state?.quantity || 1);
const totalPrice = product.price * quantity;
// Получение баланса пользователя
@@ -768,8 +771,8 @@ export default class UserProductHandler {
return;
}
// Проверка наличия товара
if (product.quantity_in_stock < quantity) {
// Проверка наличия товара (skip for mono products)
if (!product.is_mono && product.quantity_in_stock < quantity) {
await editOrSendCallback(callbackQuery, t('purchase.not_enough_stock', { count: product.quantity_in_stock }));
return;
}
@@ -777,8 +780,10 @@ export default class UserProductHandler {
// Создаем покупку и получаем её ID
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, quantity, totalPrice);
// Уменьшаем количество товара в базе данных
await ProductService.decreaseProductQuantity(productId, quantity);
// Уменьшаем количество товара в базе данных (skip for mono products)
if (!product.is_mono) {
await ProductService.decreaseProductQuantity(productId, quantity);
}
// Извлекаем данные о локации
const location = await LocationService.getLocationById(product.location_id);

View File

@@ -1,21 +1,14 @@
import QRCode from 'qrcode';
import db from '../../../config/database.js';
import config from '../../../config/config.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
import WalletUtils from '../../../utils/walletUtils.js';
const DEPOSIT_AMOUNTS = [25, 50, 100, 250, 500];
const CHANGENOW_CRYPTO_MAP = {
BTC: 'btc',
ETH: 'eth',
LTC: 'ltc',
USDT: 'usdterc20',
USDC: 'usdcerc20'
};
const CRYPTO_SYMBOLS = {
BTC: '₿',
ETH: 'Ξ',
@@ -72,7 +65,7 @@ export default class DepositHandler {
walletButtons.push([{ text: t('wallet.back'), callback_data: 'back_to_balance' }]);
await bot.editMessageText(
t('wallet.deposit_changenow_select'),
t('wallet.deposit_select_gateway'),
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
@@ -140,28 +133,48 @@ export default class DepositHandler {
return;
}
const changenowTo = CHANGENOW_CRYPTO_MAP[walletType] || walletType.toLowerCase();
const refId = config.CHANGENOW_REF;
const changenowUrl = `https://changenow.io/exchange?from=eur&to=${changenowTo}&fiatMode=true&amount=${amount}${refId ? `&ref_id=${refId}` : ''}`;
const mercuryoUrl = 'https://mercuryo.io/';
let message = `${t('wallet.deposit_title', { type: walletType, amount })}\n\n`;
message += `${t('wallet.deposit_instructions_title')}\n\n`;
message += `${t('wallet.deposit_step1')}\n`;
message += `${t('wallet.deposit_step2', { amount, type: walletType })}\n`;
message += `${t('wallet.deposit_step3')}\n`;
message += `${t('wallet.deposit_step4')}\n`;
message += `${t('wallet.deposit_step5')}\n`;
message += `${t('wallet.deposit_step6')}\n\n`;
message += `${t('wallet.deposit_your_address', { type: walletType })}\n`;
message += `\`${wallet.address}\`\n\n`;
message += `${t('wallet.deposit_important_title')}\n`;
message += `${t('wallet.deposit_important1')}\n`;
message += `${t('wallet.deposit_important2')}\n`;
message += `${t('wallet.deposit_important3')}`;
let message = `${t('wallet.deposit_title', { type: walletType, amount })}
`;
message += `${t('wallet.deposit_instructions_title')}
`;
message += `${t('wallet.deposit_step1')}
`;
message += `${t('wallet.deposit_step2', { amount, type: walletType })}
`;
message += `${t('wallet.deposit_step3')}
`;
message += `${t('wallet.deposit_step4')}
`;
message += `${t('wallet.deposit_step5')}
`;
message += `${t('wallet.deposit_step6')}
`;
message += `${t('wallet.deposit_your_address', { type: walletType })}
`;
message += `\`${wallet.address}\`
`;
message += `${t('wallet.deposit_important_title')}
`;
message += `${t('wallet.deposit_important1')}
`;
message += `${t('wallet.deposit_important2')}
`;
message += `${t('wallet.deposit_important3')}
`;
message += `${t('wallet.deposit_important4')}`;
const keyboard = {
inline_keyboard: [
[{ text: t('wallet.deposit_open_changenow', { amount, type: walletType }), url: changenowUrl }],
[
{ text: t('wallet.deposit_open_mercuryo'), url: mercuryoUrl },
{ text: t('wallet.deposit_pay_crypto'), callback_data: `deposit_crypto_${walletType}_${amount}` }
],
[
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` },
{ text: t('wallet.deposit_change_amount'), callback_data: `deposit_wallet_${walletType}` }
@@ -176,7 +189,7 @@ export default class DepositHandler {
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'MarkdownV2',
parse_mode: 'Markdown',
reply_markup: keyboard
});
} catch (error) {
@@ -210,7 +223,9 @@ export default class DepositHandler {
return;
}
await bot.sendMessage(chatId, `${t('wallet.deposit_wallet_address', { type: walletType })}\n\n\`${wallet.address}\``, {
await bot.sendMessage(chatId, `${t('wallet.deposit_wallet_address', { type: walletType })}
\`${wallet.address}\``, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
@@ -227,4 +242,80 @@ export default class DepositHandler {
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.error_copying_address') });
}
}
static async handleDepositCryptoQR(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const parts = callbackQuery.data.replace('deposit_crypto_', '').split('_');
const walletType = parts[0];
const amount = parts[1];
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const wallet = await db.getAsync(
'SELECT address FROM crypto_wallets WHERE user_id = ? AND wallet_type = ?',
[user.id, walletType]
);
if (!wallet) {
await editOrSendCallback(callbackQuery, t('wallet.wallet_not_found'));
return;
}
let paymentUri;
const upperType = walletType.toUpperCase();
if (upperType === 'BTC') {
const prices = await WalletUtils.getCryptoPrices();
const btcPrice = prices.btc || 0;
const btcAmount = btcPrice > 0 ? (parseFloat(amount) / btcPrice).toFixed(8) : '0';
paymentUri = `bitcoin:${wallet.address}?amount=${btcAmount}`;
} else if (upperType === 'ETH' || upperType === 'USDT' || upperType === 'USDC') {
paymentUri = `ethereum:${wallet.address}`;
} else if (upperType === 'LTC') {
paymentUri = `litecoin:${wallet.address}`;
} else {
paymentUri = wallet.address;
}
const qrBuffer = await QRCode.toBuffer(paymentUri, {
type: 'png',
width: 300,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
});
let caption = `${t('wallet.deposit_crypto_title', { type: walletType })}\n\n`;
caption += `${t('wallet.deposit_crypto_scan_qr')}\n\n`;
caption += `${t('wallet.deposit_crypto_address', { type: walletType })}\n`;
caption += `\`${wallet.address}\`\n\n`;
caption += `${t('wallet.deposit_crypto_instruction', { amount, type: walletType })}`;
await bot.sendPhoto(chatId, qrBuffer, {
caption,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` },
{ text: t('wallet.back_to_deposit'), callback_data: `deposit_amount_${walletType}_${amount}` }
]
]
}
});
await bot.answerCallbackQuery(callbackQuery.id);
} catch (error) {
logger.error({ err: error }, 'Error in handleDepositCryptoQR');
await editOrSendCallback(callbackQuery, t('wallet.error_deposit_instructions'));
}
}
}

View File

@@ -59,15 +59,9 @@ export default class TopUpHandler {
message += `${t('wallet.address')}: \`${wallet.address}\`\n\n`;
}
const walletButtons = cryptoWallets.map(w => ([{
text: t('wallet.deposit', { type: w.wallet_type }),
callback_data: `deposit_wallet_${w.wallet_type}`
}]));
const keyboard = {
inline_keyboard: [
[{ text: t('wallet.deposit_via_changenow'), callback_data: 'deposit_select_wallet' }],
...walletButtons,
[{ text: t('wallet.deposit_via_crypto'), callback_data: 'deposit_select_wallet' }],
[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]
]
};

View File

@@ -53,7 +53,9 @@
"error_loading_districts": "Fehler beim Laden der Bezirke. Bitte versuche es erneut.",
"error_loading_categories": "Fehler beim Laden der Kategorien. Bitte versuche es erneut.",
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü."
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü.",
"mono_product": "📦 Digitales Produkt",
"infinite_stock": "∞ Immer verfügbar"
},
"purchase": {
"summary": "🛒 Kaufübersicht:",
@@ -103,7 +105,7 @@
"address": "Adresse",
"network": "Netzwerk",
"deposit": "💳 {{type}} aufladen",
"deposit_via_changenow": "💳 Über ChangeNOW aufladen",
"deposit_via_crypto": "💳 Über Krypto aufladen",
"select_crypto": "🔐 Wähle eine Kryptowährung zum Wallet erstellen:",
"wallet_generated": "✅ Neues Wallet erfolgreich erstellt!",
"wallet_type": "Typ",
@@ -153,22 +155,28 @@
"next_page": "➡️ Weiter",
"wallet_not_found": "Wallet nicht gefunden. Bitte versuche es erneut.",
"wallet_not_found_short": "Wallet nicht gefunden.",
"deposit_changenow_select": "💳 *Einzahlung über ChangeNOW*\n\nWähle das Wallet zum Aufladen:",
"deposit_select_gateway": "💳 *Einzahlung über Krypto*\n\nWähle das Wallet zum Aufladen:",
"deposit_select_amount": "💳 *{{type}} aufladen*\n\nWähle den Betrag (USD) zum Aufladen:",
"deposit_title": "💳 *{{type}} aufladen — €{{amount}}*",
"deposit_instructions_title": "📋 *Schritt-für-Schritt-Anleitung:*",
"deposit_step1": "1⃣ Tippe auf *Adresse kopieren* unten, um deine Wallet-Adresse zu kopieren",
"deposit_step2": "2Tippe auf *ChangeNOW öffnen* \\— Betrag €{{amount}} und Währung {{type}} sind bereits eingestellt",
"deposit_step3": "3⃣ Füge auf ChangeNOW die kopierte Adresse als Empfangswallet ein",
"deposit_step4": "4Gib deine E-Mail ein und erstelle ein Passwort \\— dies ist *gesetzlich vorgeschrieben* für Kartenzahlungen \\(KYC-Verifizierung\\)\\. Deine Daten sind durch ChangeNOWs Sicherheit geschützt",
"deposit_step5": "5⃣ Bezahle mit deiner Bankkarte \\(Visa\\/Mastercard\\)",
"deposit_step2": "2Öffne Mercuryo über den Button unten",
"deposit_step3": "3⃣ Füge die kopierte Adresse als Empfangswallet ein",
"deposit_step4": "4Wähle die richtige Währung: *{{type}}* und den Betrag €{{amount}}",
"deposit_step5": "5⃣ Bezahle mit deiner Bankkarte",
"deposit_step6": "6⃣ Die Kryptowährung wird innerhalb von 5\\-30 Minuten in deinem Wallet eintreffen",
"deposit_your_address": "🔐 *Deine {{type}} Wallet-Adresse:*",
"deposit_important_title": "⚠️ *Wichtig:*",
"deposit_important1": "• Überprüfe die Wallet-Adresse doppelt vor der Bestätigung",
"deposit_important2": "• E-Mail \\+ Passwort auf ChangeNOW ist ein Standard-Verifizierungsschritt für Kartenzahlungen \\— keine Sorge\\, es ist sicher",
"deposit_important3": "• Wenn die Kryptowährung nicht innerhalb von 30 Minuten eintrifft \\— prüfe den Transaktionsstatus in deiner ChangeNOW E-Mail-Bestätigung",
"deposit_open_changenow": "🌐 ChangeNOW öffnen — €{{amount}} → {{type}}",
"deposit_important2": "• Stelle sicher\\, dass du die richtige Währung in Mercuryo auswählst",
"deposit_important3": "• E-Mail \\+ Telefonnummer können von Mercuryo für die Verifizierung verlangt werden",
"deposit_important4": "• Wenn die Kryptowährung nicht innerhalb von 30 Minuten eintrifft \\— kontaktiere den Mercuryo-Support",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_pay_crypto": "💰 Mit Krypto zahlen",
"deposit_crypto_title": "💳 *{{type}} über Krypto einzahlen*",
"deposit_crypto_address": "🔐 *Deine {{type}} Wallet-Adresse:*",
"deposit_crypto_instruction": "Sende {{amount}} USD in {{type}} an die Adresse oben. Der QR-Code enthält die Zahlungsadresse.",
"deposit_crypto_scan_qr": "📱 Scanne den QR-Code unten, um die Wallet-Adresse zu erhalten",
"deposit_copy_address": "📋 Adresse kopieren",
"deposit_change_amount": "🔄 Betrag ändern",
"deposit_choose_different": "💸 Anderes Wallet wählen",

View File

@@ -53,7 +53,9 @@
"error_loading_districts": "Error loading districts. Please try again.",
"error_loading_categories": "Error loading categories. Please try again.",
"error_loading_product": "Error loading product details. Please try again.",
"not_found": "Location not found. Returning to previous menu."
"not_found": "Location not found. Returning to previous menu.",
"mono_product": "📦 Digital Product",
"infinite_stock": "∞ Always available"
},
"purchase": {
"summary": "🛒 Purchase Summary:",
@@ -103,7 +105,7 @@
"address": "Address",
"network": "Network",
"deposit": "💳 Deposit {{type}}",
"deposit_via_changenow": "💳 Deposit via ChangeNOW",
"deposit_via_crypto": "💳 Deposit via Crypto",
"select_crypto": "🔐 Select cryptocurrency to generate wallet:",
"wallet_generated": "✅ New wallet generated successfully!",
"wallet_type": "Type",
@@ -153,22 +155,28 @@
"next_page": "➡️ Next",
"wallet_not_found": "Wallet not found. Please try again.",
"wallet_not_found_short": "Wallet not found.",
"deposit_changenow_select": "💳 *Deposit via ChangeNOW*\n\nSelect the wallet you want to top up:",
"deposit_select_gateway": "💳 *Deposit via Crypto*\n\nSelect the wallet you want to top up:",
"deposit_select_amount": "💳 *Deposit {{type}}*\n\nSelect the amount (USD) you want to deposit:",
"deposit_title": "💳 *Deposit {{type}} — €{{amount}}*",
"deposit_instructions_title": "📋 *Step\\-by\\-step instructions:*",
"deposit_step1": "1⃣ Tap *Copy Address* below to copy your wallet address",
"deposit_step2": "2Tap *Open ChangeNOW* \\— the amount €{{amount}} and currency {{type}} are already set",
"deposit_step3": "3On ChangeNOW\\, paste the copied address as the receiving wallet",
"deposit_step4": "4Enter your email and create a password when prompted \\— this is *required by law* for card payments \\(KYC verification\\)\\. Your data is protected by ChangeNOW\\'s security",
"deposit_step5": "5Pay with your bank card \\(Visa\\/Mastercard\\)",
"deposit_step2": "2Open Mercuryo via the button below",
"deposit_step3": "3Paste the copied address as the receiving wallet",
"deposit_step4": "4Select the correct currency: *{{type}}* and the amount €{{amount}}",
"deposit_step5": "5Complete the payment with your bank card",
"deposit_step6": "6⃣ Crypto will arrive in your wallet within 5\\-30 minutes",
"deposit_your_address": "🔐 *Your {{type}} wallet address:*",
"deposit_important_title": "⚠️ *Important:*",
"deposit_important1": "• Double\\-check the wallet address before confirming",
"deposit_important2": "• Email \\+ password on ChangeNOW is a standard verification step for card payments \\— don\\'t worry\\, it\\'s safe",
"deposit_important3": "• If crypto doesn\\'t arrive within 30 min \\— check the transaction status in your ChangeNOW email confirmation",
"deposit_open_changenow": "🌐 Open ChangeNOW — €{{amount}} → {{type}}",
"deposit_important2": "• Make sure you select the correct currency in Mercuryo",
"deposit_important3": "• Email \\+ phone may be required by Mercuryo for verification",
"deposit_important4": "• If crypto doesn't arrive within 30 min \\— contact Mercuryo support",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_pay_crypto": "💰 Pay with Crypto",
"deposit_crypto_title": "💳 *Deposit {{type}} via Crypto*",
"deposit_crypto_address": "🔐 *Your {{type}} wallet address:*",
"deposit_crypto_instruction": "Send {{amount}} USD in {{type}} to the address above. The QR code contains the payment address.",
"deposit_crypto_scan_qr": "📱 Scan the QR code below to get the wallet address",
"deposit_copy_address": "📋 Copy Address",
"deposit_change_amount": "🔄 Change Amount",
"deposit_choose_different": "💸 Choose Different Wallet",

View File

@@ -53,7 +53,9 @@
"error_loading_districts": "Error al cargar distritos. Inténtalo de nuevo.",
"error_loading_categories": "Error al cargar categorías. Inténtalo de nuevo.",
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
"not_found": "Ubicación no encontrada. Volviendo al menú anterior."
"not_found": "Ubicación no encontrada. Volviendo al menú anterior.",
"mono_product": "📦 Producto Digital",
"infinite_stock": "∞ Siempre disponible"
},
"purchase": {
"summary": "🛒 Resumen de compra:",
@@ -103,7 +105,7 @@
"address": "Dirección",
"network": "Red",
"deposit": "💳 Depositar {{type}}",
"deposit_via_changenow": "💳 Depositar vía ChangeNOW",
"deposit_via_crypto": "💳 Depositar vía Cripto",
"select_crypto": "🔐 Selecciona criptomoneda para generar billetera:",
"wallet_generated": "✅ ¡Nueva billetera generada exitosamente!",
"wallet_type": "Tipo",
@@ -153,22 +155,28 @@
"next_page": "➡️ Siguiente",
"wallet_not_found": "Billetera no encontrada. Inténtalo de nuevo.",
"wallet_not_found_short": "Billetera no encontrada.",
"deposit_changenow_select": "💳 *Depositar vía ChangeNOW*\n\nSelecciona la billetera que quieres recargar:",
"deposit_select_gateway": "💳 *Depositar vía Cripto*\n\nSelecciona la billetera que quieres recargar:",
"deposit_select_amount": "💳 *Depositar {{type}}*\n\nSelecciona la cantidad (USD) que quieres depositar:",
"deposit_title": "💳 *Depositar {{type}} — €{{amount}}*",
"deposit_instructions_title": "📋 *Instrucciones paso a paso:*",
"deposit_step1": "1⃣ Toca *Copiar dirección* abajo para copiar tu dirección de billetera",
"deposit_step2": "2Toca *Abrir ChangeNOW* \\— la cantidad €{{amount}} y la moneda {{type}} ya están configuradas",
"deposit_step3": "3En ChangeNOW\\, pega la dirección copiada como billetera receptora",
"deposit_step4": "4Ingresa tu correo y crea una contraseña cuando se solicite \\— esto es *requerido por ley* para pagos con tarjeta \\(verificación KYC\\)\\. Tus datos están protegidos por la seguridad de ChangeNOW",
"deposit_step5": "5⃣ Paga con tu tarjeta bancaria \\(Visa\\/Mastercard\\)",
"deposit_step2": "2Abre Mercuryo con el botón de abajo",
"deposit_step3": "3Pega la dirección copiada como billetera receptora",
"deposit_step4": "4Selecciona la moneda correcta: *{{type}}* y la cantidad €{{amount}}",
"deposit_step5": "5⃣ Paga con tu tarjeta bancaria",
"deposit_step6": "6⃣ Las criptomonedas llegarán a tu billetera en 5\\-30 minutos",
"deposit_your_address": "🔐 *Tu dirección de billetera {{type}}:*",
"deposit_important_title": "⚠️ *Importante:*",
"deposit_important1": "• Verifica dos veces la dirección de la billetera antes de confirmar",
"deposit_important2": "• Correo \\+ contraseña en ChangeNOW es un paso de verificación estándar para pagos con tarjeta \\— no te preocupes\\, es seguro",
"deposit_important3": "• Si las criptomonedas no llegan en 30 min \\— verifica el estado de la transacción en tu confirmación por correo de ChangeNOW",
"deposit_open_changenow": "🌐 Abrir ChangeNOW — €{{amount}} → {{type}}",
"deposit_important2": "• Asegúrate de seleccionar la moneda correcta en Mercuryo",
"deposit_important3": "• Correo \\+ teléfono pueden ser requeridos por Mercuryo para verificación",
"deposit_important4": "• Si las criptomonedas no llegan en 30 min \\— contacta al soporte de Mercuryo",
"deposit_open_mercuryo": "💳 Mercuryo",
"deposit_pay_crypto": "💰 Pagar con Cripto",
"deposit_crypto_title": "💳 *Depositar {{type}} vía Cripto*",
"deposit_crypto_address": "🔐 *Tu dirección de billetera {{type}}:*",
"deposit_crypto_instruction": "Envía {{amount}} USD en {{type}} a la dirección de arriba. El código QR contiene la dirección de pago.",
"deposit_crypto_scan_qr": "📱 Escanea el código QR de abajo para obtener la dirección de la billetera",
"deposit_copy_address": "📋 Copiar dirección",
"deposit_change_amount": "🔄 Cambiar cantidad",
"deposit_choose_different": "💸 Elegir otra billetera",

View File

@@ -0,0 +1,15 @@
import logger from '../utils/logger.js';
export default async function migration010(db, checkColumnExists) {
if (await checkColumnExists('products', 'is_mono')) {
logger.info('Migration 010: is_mono column already exists, skipping');
return;
}
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync(`ALTER TABLE products ADD COLUMN is_mono INTEGER DEFAULT 0`);
await db.runAsync('COMMIT');
logger.info('Migration 010: Added is_mono column to products table');
}

View File

@@ -44,6 +44,7 @@ export async function runMigrations() {
(await import('./007_commission_payments.js')).default,
(await import('./008_user_language.js')).default,
(await import('./009_user_language_set.js')).default,
(await import('./010_is_mono_product.js')).default,
];
for (let i = currentVersion; i < migrations.length; i++) {

View File

@@ -130,6 +130,10 @@ export function registerRoutes() {
logDebug(cq.data, 'handleDepositCopyAddress');
await DepositHandler.handleDepositCopyAddress(cq);
});
callbackRouter.registerPrefix('deposit_crypto_', async (cq) => {
logDebug(cq.data, 'handleDepositCryptoQR');
await DepositHandler.handleDepositCryptoQR(cq);
});
callbackRouter.registerExact('wallet_history', async (cq) => {
logDebug(cq.data, 'handleWalletHistory');
await userWalletsHandler.handleWalletHistory(cq);

View File

@@ -35,10 +35,10 @@ class ProductService {
throw new Error('Invalid location or category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
`SELECT id, name, price, description, quantity_in_stock, photo_url, is_mono
FROM products
WHERE location_id = ? AND category_id = ?
AND quantity_in_stock > 0
AND (quantity_in_stock > 0 OR is_mono = 1)
ORDER BY name`,
[locationId, categoryId]
);
@@ -49,10 +49,10 @@ class ProductService {
throw new Error('Invalid category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
`SELECT id, name, price, description, quantity_in_stock, photo_url, is_mono
FROM products
WHERE category_id = ?
AND quantity_in_stock > 0
AND (quantity_in_stock > 0 OR is_mono = 1)
ORDER BY name`,
[categoryId]
);

View File

@@ -55,7 +55,7 @@ class PurchaseService {
}
const product = await db.getAsync(
'SELECT id, quantity_in_stock FROM products WHERE id = ? AND quantity_in_stock >= ?',
'SELECT id, quantity_in_stock, is_mono FROM products WHERE id = ? AND (quantity_in_stock >= ? OR is_mono = 1)',
[productId, quantity]
);
if (!product) {
@@ -94,12 +94,14 @@ class PurchaseService {
sourceWalletType += sourceWalletType ? `, crypto_${usedCrypto}` : `crypto_${usedCrypto}`;
}
const stockResult = await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ? AND quantity_in_stock >= ?',
[quantity, productId, quantity]
);
if (stockResult.changes === 0) {
throw new Error('Insufficient stock');
if (!product.is_mono) {
const stockResult = await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ? AND quantity_in_stock >= ?',
[quantity, productId, quantity]
);
if (stockResult.changes === 0) {
throw new Error('Insufficient stock');
}
}
const txHash = crypto.randomUUID();