diff --git a/package-lock.json b/package-lock.json index 0f4bb52..814b6bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4bb52d4..1b7a2ae 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/admin/routes/catalog.js b/src/admin/routes/catalog.js index ae81377..5f54409 100644 --- a/src/admin/routes/catalog.js +++ b/src/admin/routes/catalog.js @@ -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,'"'); } -function buildTreeHtml(tree) { +function buildTreeHtml(tree, csrf) { + const csrfInput = ``; let treeHtml = ''; let accordionIndex = 0; for (const [country, cdata] of Object.entries(tree)) { @@ -35,10 +37,12 @@ function buildTreeHtml(tree) {
+ ${csrfInput}
+ ${csrfInput}
@@ -57,6 +61,7 @@ function buildTreeHtml(tree) {
${subHtml}
+ ${csrfInput}
@@ -76,11 +81,13 @@ function buildTreeHtml(tree) {
${catHtml}
+ ${csrfInput}
+ ${csrfInput}
@@ -100,6 +107,7 @@ function buildTreeHtml(tree) {
${districtHtml}
+ ${csrfInput} @@ -121,6 +129,7 @@ function buildTreeHtml(tree) {
${cityHtml} + ${csrfInput} @@ -136,123 +145,144 @@ function buildProductFormHtml(action, catOptions, locations) { const isEdit = action.includes('/edit'); const title = isEdit ? 'Edit Product' : 'Add Product'; return ` -
-
-

${title}

-
- -
- -
-
- + + +
+
+

${title}

+
+ +
+ +
+
+ +
+
+ +
+
+ +
-
- +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+ +
-
- +
+ +
+ + +
+
+ + +
+ + + + + +
+ + +
+ +
+
+
Public Photo
+
+
+ + +
+
+ + +
+ +
+
+
Hidden Content (shown after purchase)
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
- -
- - -
- -
- - -
-
- - -
- -
- - -
-
- - -
- - - -
- - -
- -
-
-
Public Photo
-
-
- - -
-
- - -
- -
-
-
Hidden Content (shown after purchase)
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - -
-
-
`; +`; } -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 => ``).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; diff --git a/src/admin/routes/catalogProducts.js b/src/admin/routes/catalogProducts.js index e8f3c4a..2bacaa5 100644 --- a/src/admin/routes/catalogProducts.js +++ b/src/admin/routes/catalogProducts.js @@ -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'); })); diff --git a/src/admin/routes/products.js b/src/admin/routes/products.js index bbbeaa0..8ada844 100644 --- a/src/admin/routes/products.js +++ b/src/admin/routes/products.js @@ -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; diff --git a/src/admin/routes/wallets.js b/src/admin/routes/wallets.js index 27edbc1..64ad84c 100644 --- a/src/admin/routes/wallets.js +++ b/src/admin/routes/wallets.js @@ -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; \ No newline at end of file diff --git a/src/admin/views/catalog.ejs b/src/admin/views/catalog.ejs index 8331d15..141f06f 100644 --- a/src/admin/views/catalog.ejs +++ b/src/admin/views/catalog.ejs @@ -18,6 +18,7 @@
+ @@ -72,10 +73,11 @@ <%= p.cn || '' %> <%= p.sn || '' %> $<%= Number(p.price || 0).toFixed(2) %> - <%= p.quantity_in_stock || 0 %> + <%= p.is_mono ? '∞' : (p.quantity_in_stock || 0) %> +
@@ -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 = ''; 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 = ''; + 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 = ''; + } +} diff --git a/src/admin/views/catalog.js b/src/admin/views/catalog.js index 925b793..02ac189 100644 --- a/src/admin/views/catalog.js +++ b/src/admin/views/catalog.js @@ -167,7 +167,7 @@ export function renderCatalog(tree, products, filter, categories, subcategories, ${esc(p.cn||'')} ${esc(p.sn||'')} $${(p.price||0).toFixed(2)} - ${p.quantity_in_stock||0} + ${p.is_mono ? '∞' : (p.quantity_in_stock||0)}
@@ -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 = ''; 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 = ''; + 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 = ''; + } +} `; return layout('Catalog', content, 'catalog'); } diff --git a/src/admin/views/catalogProduct.js b/src/admin/views/catalogProduct.js index a527767..75ac989 100644 --- a/src/admin/views/catalogProduct.js +++ b/src/admin/views/catalogProduct.js @@ -39,7 +39,14 @@ export function renderProductEditForm(action, catOptions, subcatJson, locations)
- + +
+ +
+
+ + +
@@ -66,6 +73,17 @@ export function renderProductEditForm(action, catOptions, subcatJson, locations)
+ +
diff --git a/src/admin/views/product-edit.ejs b/src/admin/views/product-edit.ejs index 99766c4..f3bd9f0 100644 --- a/src/admin/views/product-edit.ejs +++ b/src/admin/views/product-edit.ejs @@ -5,6 +5,27 @@
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+
@@ -13,13 +34,19 @@
$ - +
+
+
+ > + +
+
@@ -13,13 +14,19 @@
$ - +
+
+
+ + +
+
@@ -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 = ''; + } + } diff --git a/src/admin/views/products.js b/src/admin/views/products.js index b695512..a51aeec 100644 --- a/src/admin/views/products.js +++ b/src/admin/views/products.js @@ -16,7 +16,7 @@ export function renderProductList(products, categories, subcategories) { ${escapeHtml(p.category_name) || '-'} ${escapeHtml(p.subcategory_name) || '-'} $${(p.price || 0).toFixed(2)} - ${p.quantity_in_stock || 0} + ${p.is_mono ? '∞' : (p.quantity_in_stock || 0)} Edit
@@ -48,6 +48,12 @@ export function renderProductList(products, categories, subcategories) {
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
@@ -160,6 +204,12 @@ export function renderProductEdit(product, categories, subcategories) {
+
+
+ + +
+