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:
238
package-lock.json
generated
238
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 %>)">✎</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">✕</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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' }]
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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": "2️⃣ Tippe 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": "4️⃣ Gib 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": "4️⃣ Wä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",
|
||||
|
||||
@@ -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": "2️⃣ Tap *Open ChangeNOW* \\— the amount €{{amount}} and currency {{type}} are already set",
|
||||
"deposit_step3": "3️⃣ On ChangeNOW\\, paste the copied address as the receiving wallet",
|
||||
"deposit_step4": "4️⃣ Enter 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": "5️⃣ Pay with your bank card \\(Visa\\/Mastercard\\)",
|
||||
"deposit_step2": "2️⃣ Open Mercuryo via the button below",
|
||||
"deposit_step3": "3️⃣ Paste the copied address as the receiving wallet",
|
||||
"deposit_step4": "4️⃣ Select the correct currency: *{{type}}* and the amount €{{amount}}",
|
||||
"deposit_step5": "5️⃣ Complete 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",
|
||||
|
||||
@@ -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": "2️⃣ Toca *Abrir ChangeNOW* \\— la cantidad €{{amount}} y la moneda {{type}} ya están configuradas",
|
||||
"deposit_step3": "3️⃣ En ChangeNOW\\, pega la dirección copiada como billetera receptora",
|
||||
"deposit_step4": "4️⃣ Ingresa 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": "2️⃣ Abre Mercuryo con el botón de abajo",
|
||||
"deposit_step3": "3️⃣ Pega la dirección copiada como billetera receptora",
|
||||
"deposit_step4": "4️⃣ Selecciona 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",
|
||||
|
||||
15
src/migrations/010_is_mono_product.js
Normal file
15
src/migrations/010_is_mono_product.js
Normal 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');
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user