diff --git a/VERSION.md b/VERSION.md
index 661234e..3c9f9e9 100644
--- a/VERSION.md
+++ b/VERSION.md
@@ -9,10 +9,16 @@
## Current Version
-**v1.2.2** — 2026-08-04
+**v1.2.3** — 2026-08-04
## Changelog
+### v1.2.3 — 2026-08-04
+- **fix**: empty category menus — districts with no in-stock products show "No products available in this district" (getCategoriesWithProductsByLocationId filters by real stock/mono)
+- **fix**: duplicate "Select your country" messages on rapid Products tap — 1200ms main-menu debounce in routes.js
+- **fix**: DB typos + desync — migration 012 (Centr→Center, Chiken→Chicken, Brusel→Brussels, Sever→North) and re-enables locations that actually have in-stock products
+- **fix**: answerCallbackQuery on no_categories branch (no stuck loading spinner)
+
### v1.2.2 — 2026-08-04
- **fix**: BUG-01 — disabled locations/categories/subcategories filtered from bot menus (getActiveLocationById, is_active checks)
- **fix**: BUG-02 — graceful handling of disabled entity during purchase flow (location_disabled notice + main-menu redirect, no crash)
diff --git a/src/__tests__/botBugFixes.test.js b/src/__tests__/botBugFixes.test.js
index a072436..b2db826 100644
--- a/src/__tests__/botBugFixes.test.js
+++ b/src/__tests__/botBugFixes.test.js
@@ -92,7 +92,8 @@ vi.mock('../services/userService.js', () => ({
vi.mock('../services/categoryService.js', () => ({
__esModule: true,
default: {
- getCategoriesByLocationId: vi.fn().mockResolvedValue([])
+ getCategoriesByLocationId: vi.fn().mockResolvedValue([]),
+ getCategoriesWithProductsByLocationId: vi.fn().mockResolvedValue([])
}
}));
diff --git a/src/admin/views/partials/app-sidebar.ejs b/src/admin/views/partials/app-sidebar.ejs
index d87e6c3..af91c4a 100644
--- a/src/admin/views/partials/app-sidebar.ejs
+++ b/src/admin/views/partials/app-sidebar.ejs
@@ -28,7 +28,7 @@
- v1.2.2
+ v1.2.3
@@ -43,10 +43,10 @@
- Current: v1.2.2 · 2026-08-04
+ Current: v1.2.3 · 2026-08-04
-
v1.2.2 — 2026-08-04
+
v1.2.3 — 2026-08-04
- refactor Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions
- feat Updated Mercuryo button text to include VISA/Mastercard branding in all locales
diff --git a/src/handlers/userHandlers/userProductHandler.js b/src/handlers/userHandlers/userProductHandler.js
index a6e4226..dd7cbaf 100644
--- a/src/handlers/userHandlers/userProductHandler.js
+++ b/src/handlers/userHandlers/userProductHandler.js
@@ -212,7 +212,24 @@ export default class UserProductHandler {
locationId: location.id
});
- const categories = await CategoryService.getCategoriesByLocationId(location.id);
+ const categories = await CategoryService.getCategoriesWithProductsByLocationId(location.id);
+
+ if (categories.length === 0) {
+ await bot.answerCallbackQuery(callbackQuery.id);
+ await bot.editMessageText(
+ t('products.no_categories'),
+ {
+ chat_id: chatId,
+ message_id: messageId,
+ reply_markup: {
+ inline_keyboard: [[
+ { text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location.country)}|${encodeURIComponent(location.city)}` }
+ ]]
+ }
+ }
+ );
+ return;
+ }
const keyboard = {
inline_keyboard: [
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index ad129f1..38a7b59 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -36,6 +36,7 @@
"no_products": "Aktuell keine Produkte verfügbar.",
"no_products_category": "Keine Produkte in dieser Kategorie.",
"no_products_subcategory": "Keine Produkte in dieser Unterkategorie.",
+ "no_categories": "In diesem Bezirk sind keine Produkte verfügbar.",
"back_to_countries": "« Zurück zu den Ländern",
"back_to_cities": "« Zurück zu den Städten",
"back_to_subcategories": "« Zurück zu den Unterkategorien",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0ad9385..4d5ccf5 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -36,6 +36,7 @@
"no_products": "No products available at the moment.",
"no_products_category": "No products available in this category.",
"no_products_subcategory": "No products available in this subcategory.",
+ "no_categories": "No products available in this district.",
"back_to_countries": "« Back to Countries",
"back_to_cities": "« Back to Cities",
"back_to_subcategories": "« Back to Subcategories",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index c98519b..7716032 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -36,6 +36,7 @@
"no_products": "No hay productos disponibles en este momento.",
"no_products_category": "No hay productos disponibles en esta categoría.",
"no_products_subcategory": "No hay productos disponibles en esta subcategoría.",
+ "no_categories": "No hay productos disponibles en este distrito.",
"back_to_countries": "« Volver a países",
"back_to_cities": "« Volver a ciudades",
"back_to_subcategories": "« Volver a subcategorías",
diff --git a/src/migrations/012_fix_typos.js b/src/migrations/012_fix_typos.js
new file mode 100644
index 0000000..3acddb1
--- /dev/null
+++ b/src/migrations/012_fix_typos.js
@@ -0,0 +1,38 @@
+import logger from '../utils/logger.js';
+
+export default async function migration012(db) {
+ await db.runAsync('BEGIN TRANSACTION');
+ try {
+ // Точечные исправления опечаток с контекстом (country/city/name),
+ // чтобы не задеть другие записи с похожими строками
+ await db.runAsync(
+ "UPDATE locations SET district = 'Center' WHERE district = 'Centr' AND country = 'Spain' AND city = 'Barcelona'"
+ );
+ await db.runAsync(
+ "UPDATE locations SET city = 'Brussels' WHERE city = 'Brusel' AND country = 'Belgium'"
+ );
+ await db.runAsync(
+ "UPDATE locations SET district = 'North' WHERE district = 'Sever' AND country = 'Belgium'"
+ );
+ await db.runAsync(
+ "UPDATE categories SET name = 'Chicken' WHERE name = 'Chiken'"
+ );
+ // Восстановление синхронизации: локации с реально доступными товарами
+ // (в наличии или цифровые) включаются, чтобы список стран в боте
+ // соответствовал фактически покупаемым товарам. Админ мог отключить
+ // локацию, забыв перенести товары, — это чинит "пропавшие" страны.
+ await db.runAsync(
+ `UPDATE locations SET is_active = 1
+ WHERE id IN (
+ SELECT DISTINCT p.location_id
+ FROM products p
+ WHERE p.quantity_in_stock > 0 OR p.is_mono = 1
+ )`
+ );
+ await db.runAsync('COMMIT');
+ logger.info('Migration 012: Fixed typos and restored active locations with stock');
+ } catch (e) {
+ await db.runAsync('ROLLBACK');
+ throw e;
+ }
+}
diff --git a/src/migrations/runner.js b/src/migrations/runner.js
index c8d7ee9..3e4d940 100644
--- a/src/migrations/runner.js
+++ b/src/migrations/runner.js
@@ -46,6 +46,7 @@ export async function runMigrations() {
(await import('./009_user_language_set.js')).default,
(await import('./010_is_mono_product.js')).default,
(await import('./011_active_flags.js')).default,
+ (await import('./012_fix_typos.js')).default,
];
for (let i = currentVersion; i < migrations.length; i++) {
diff --git a/src/router/routes.js b/src/router/routes.js
index 29bfd24..4a8e94c 100644
--- a/src/router/routes.js
+++ b/src/router/routes.js
@@ -23,6 +23,22 @@ const logDebug = (action, functionName) => {
logger.debug({ action, functionName }, 'Button Press');
};
+const lastMainMenuPress = new Map();
+const MAIN_MENU_DEBOUNCE_MS = 1200;
+
+function shouldDebounceMainMenu(chatId, textKey) {
+ const key = `${chatId}:${textKey}`;
+ const now = Date.now();
+ const last = lastMainMenuPress.get(key);
+ if (last && (now - last) < MAIN_MENU_DEBOUNCE_MS) return true;
+ lastMainMenuPress.set(key, now);
+ // Prune old entries (>5s)
+ for (const [k, ts] of lastMainMenuPress) {
+ if (now - ts > 5000) lastMainMenuPress.delete(k);
+ }
+ return false;
+}
+
export function registerRoutes() {
// === Message Input Handlers (order matters — checked first) ===
messageRouter.registerInput(adminLocationHandler.handleLocationInput.bind(adminLocationHandler));
@@ -41,21 +57,25 @@ export function registerRoutes() {
// === Text Commands ===
messageRouter.registerText('keyboard.products', async (msg) => {
+ if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return;
await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showProducts');
await userProductHandler.showProducts(msg);
});
messageRouter.registerText('keyboard.profile', async (msg) => {
+ if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.profile')) return;
await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showProfile');
await userHandler.showProfile(msg);
});
messageRouter.registerText('keyboard.wallets', async (msg) => {
+ if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.wallets')) return;
await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showBalance');
await userWalletsHandler.showBalance(msg);
});
messageRouter.registerText('keyboard.purchases', async (msg) => {
+ if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.purchases')) return;
await resetUserContext(msg.chat.id);
logDebug(msg.text, 'showPurchases');
await userPurchaseHandler.showPurchases(msg);
diff --git a/src/services/categoryService.js b/src/services/categoryService.js
index 0be34ec..f80da19 100644
--- a/src/services/categoryService.js
+++ b/src/services/categoryService.js
@@ -1,5 +1,6 @@
import db from "../config/database.js";
import logger from "../utils/logger.js";
+import Validators from "../utils/validators.js";
class CategoryService {
static async getCategoriesByLocationId(locationId) {
@@ -15,6 +16,19 @@ class CategoryService {
}
}
+ static async getCategoriesWithProductsByLocationId(locationId) {
+ if (!Validators.isValidNumericId(Number(locationId))) throw new Error('Invalid location ID');
+ return await db.allAsync(
+ `SELECT DISTINCT c.id, c.name
+ FROM categories c
+ JOIN products p ON p.category_id = c.id
+ WHERE c.location_id = ? AND c.is_active = 1
+ AND (p.quantity_in_stock > 0 OR p.is_mono = 1)
+ ORDER BY c.name`,
+ [locationId]
+ );
+ }
+
static async getSubcategoriesByCategoryId(categoryId) {
return await db.allAsync(
'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name',