const { Markup } = require('telegraf');
const Product = require('../models/Product');
const Category = require('../models/Category');
const Order = require('../models/Order');
const User = require('../models/User'); // Fixed Missing Import
const config = require('../config');
// Main Menu
exports.start = (ctx, text) => {
const userId = ctx.from.id.toString();
const isAdmin = config.ADMIN_IDS.includes(userId);
// Ensure i18n is available (fallback if direct call)
const i18n = ctx.i18n || require('../locales').uz;
// Ensure proper buttons with fallbacks
const btn = (key, fallback) => key || fallback;
let buttons = [];
if (isAdmin) {
buttons = [
[btn(i18n.btn_catalog, "π Katalog"), btn(i18n.btn_search, "π Qidiruv")],
[btn(i18n.btn_orders, "π¦ Buyurtmalarim"), btn(i18n.btn_cart, "π Savat")],
["π¨βπΌ Admin Panel", btn(i18n.btn_contact, "π Aloqa")], // JOINED
[btn(i18n.btn_channel, "π’ Kanal"), btn(i18n.btn_group, "π₯ Guruh")],
[btn(i18n.btn_invite, "π₯ Taklif"), btn(i18n.btn_lang, "π Til")]
];
} else {
buttons = [
[btn(i18n.btn_catalog, "π Katalog"), btn(i18n.btn_search, "π Qidiruv")],
[btn(i18n.btn_orders, "π¦ Buyurtmalarim"), btn(i18n.btn_cart, "π Savat")],
[btn(i18n.btn_channel, "π’ Kanal"), btn(i18n.btn_group, "π₯ Guruh")],
[btn(i18n.btn_contact, "π Aloqa"), btn(i18n.btn_lang, "π Til")],
[btn(i18n.btn_invite, "π₯ Taklif")]
];
}
// Filter out any undefined or empty strings just in case
buttons = buttons.map(row => row.filter(b => b));
const msg = text || i18n.menu_main || "Menu";
return ctx.replyWithHTML(msg, Markup.keyboard(buttons).resize());
};
// Handle Referral Payload specifically (Called from bot.js)
exports.handleReferral = async (ctx, referrerId) => {
if (!referrerId) return;
// Prevent self-referral
if (referrerId === ctx.from.id.toString()) return;
const user = await User.findOne({ id: ctx.from.id });
if (user && !user.referredBy) {
// Only if user exists and hasn't been referred yet?
// Actually, usually referrals are for NEW users.
// If user is already in DB, maybe we don't count it?
// But "full logic" implies we adhere to standard rules: New users only.
// Let's assume we tracked it when creating the user.
// But the user creation happens in middleware or bot.js start handler.
// Let's update it here.
user.referredBy = referrerId;
await user.save();
// Notify Referrer
try {
// Need to get referrer language, but might be expensive. Assume Uzbek or use simple text?
// "Maximal Logic" implies we should respect referrer's language if possible.
// But we don't have referrer's context (ctx) here, only ID.
// We can fetch referrer user.
const referrerUser = await User.findOne({ id: referrerId });
if (referrerUser) {
// Increment Count
referrerUser.referralCount = (referrerUser.referralCount || 0) + 1;
await referrerUser.save();
const locales = require('../locales');
const rLang = locales[referrerUser.language] || locales.uz;
await ctx.telegram.sendMessage(referrerId, `${rLang.invite_referral} ${ctx.from.first_name}!\n${rLang.invite_stats.replace('{count}', referrerUser.referralCount)}`);
}
} catch (e) {
// Referrer might have blocked bot
}
}
};
// Invite Friends Feature
exports.inviteFriends = async (ctx) => {
const botUsername = ctx.botInfo.username;
const inviteLink = `https://t.me/${botUsername}?start=${ctx.from.id}`;
// Get Stats
const user = await User.findOne({ id: ctx.from.id });
const count = user ? (user.referralCount || 0) : 0;
const statsText = ctx.i18n.invite_stats.replace('{count}', count);
const text = `${ctx.i18n.invite_text}\n\n${statsText}\n\nπ ${inviteLink}\n\nπ`;
const forwardText = `${ctx.i18n.invite_forward}${inviteLink}`;
await ctx.replyWithHTML(text);
await ctx.replyWithHTML(forwardText);
};
// Settings are already localized in previous steps
exports.changeName = async (ctx) => {
ctx.reply(ctx.i18n.name_change_prompt, Markup.keyboard([[ctx.i18n.btn_cancel]]).resize());
ctx.session.isChangingName = true;
};
exports.handleNameChange = async (ctx) => {
if (!ctx.session.isChangingName) return false;
const newName = ctx.message.text;
// Check for cancel using localization keys manually (or just hardcode known cancels)
// Better to use universal check if possible, or just standard "Cancel" text from i18n
if (newName === ctx.i18n.btn_cancel || newName === 'β Bekor qilish' || newName === 'β ΠΡΠΌΠ΅Π½Π°' || newName === 'β Cancel') {
ctx.session.isChangingName = false;
exports.start(ctx, ctx.i18n.name_change_cancel);
return true;
}
await User.updateOne({ id: ctx.from.id }, { first_name: newName });
ctx.session.isChangingName = false;
ctx.reply(`${ctx.i18n.name_change_success} ${newName}`);
exports.start(ctx); // Return to menu
return true;
};
// Show Categories (Root: parent is null)
exports.showCategories = async (ctx) => {
try {
const categories = await Category.find({ parent: null }); // Only Root
if (categories.length === 0) return ctx.reply(ctx.i18n.no_cats);
const buttons = [];
for (const c of categories) {
// Find all subcategories to count correctly
const subCats = await Category.find({ parent: c.id });
const subCatNames = subCats.map(sub => sub.name);
const targetCategories = [...subCatNames, c.name];
const countNew = await Product.countDocuments({
category: { $in: targetCategories },
$or: [{ condition: 'new' }, { condition: { $exists: false } }]
});
const countUsed = await Product.countDocuments({
category: { $in: targetCategories },
condition: 'used'
});
// If empty, show nothing? Or generic? Let's show if > 0
if (countNew > 0) {
buttons.push([Markup.button.callback(`π ${c.name} Yangi (${countNew})`, `cat_${c.id}_new`)]);
}
if (countUsed > 0) {
buttons.push([Markup.button.callback(`β»οΈ ${c.name} B/U (${countUsed})`, `cat_${c.id}_used`)]);
}
// Fallback if truly empty but category exists (show generic to avoid hidden cats)
if (countNew === 0 && countUsed === 0) {
buttons.push([Markup.button.callback(`${c.name} (0)`, `cat_${c.id}_all`)]);
}
}
buttons.push([Markup.button.callback(ctx.i18n.btn_back || "π Orqaga", "back_to_menu")]);
await ctx.reply(ctx.i18n.cat_select, Markup.inlineKeyboard(buttons));
} catch (err) {
console.error(err);
ctx.reply("Error");
}
};
// Show Subcategories
exports.showSubCategories = async (ctx, parentId, inheritedCondition = null) => {
try {
const parent = await Category.findOne({ id: parentId });
const subCats = await Category.find({ parent: parentId });
if (!subCats || subCats.length === 0) {
// Fallback to products if no subcategories
// Pass the condition if we have one
return exports.browseCategory(ctx, `cat_${parentId}`, 0, 0, inheritedCondition || 'all');
}
const buttons = [];
for (const c of subCats) {
// Count products in this subcategory
// Use similar logic: filter by condition if inherited, or show split if not?
// User request: "har bita kataloga alohida... sorasin"
// If I selected "New" at root, I should probably only see "New" subcategories or items.
let queryNew = { category: c.name, $or: [{ condition: 'new' }, { condition: { $exists: false } }] };
let queryUsed = { category: c.name, condition: 'used' };
const countNew = await Product.countDocuments(queryNew);
const countUsed = await Product.countDocuments(queryUsed);
if (inheritedCondition === 'new') {
if (countNew > 0) buttons.push([Markup.button.callback(`π ${c.name} (${countNew})`, `cat_${c.id}_new`)]);
} else if (inheritedCondition === 'used') {
if (countUsed > 0) buttons.push([Markup.button.callback(`β»οΈ ${c.name} (${countUsed})`, `cat_${c.id}_used`)]);
} else {
// No filter yet, show split (or mixed?)
// If deep browsing, maybe better to show split again
if (countNew > 0) buttons.push([Markup.button.callback(`π ${c.name} Yangi (${countNew})`, `cat_${c.id}_new`)]);
if (countUsed > 0) buttons.push([Markup.button.callback(`β»οΈ ${c.name} B/U (${countUsed})`, `cat_${c.id}_used`)]);
if (countNew === 0 && countUsed === 0) buttons.push([Markup.button.callback(`${c.name} (0)`, `cat_${c.id}_all`)]);
}
}
buttons.push([Markup.button.callback(ctx.i18n.btn_back, "back_to_cats")]);
await ctx.editMessageText(`${ctx.i18n.cat_select}\n**${parent.name}**\n${inheritedCondition ? (inheritedCondition === 'new' ? 'π Yangi' : 'β»οΈ B/U') : ''}`,
{ parse_mode: 'Markdown', ...Markup.inlineKeyboard(buttons) }
).catch(() => ctx.reply(ctx.i18n.cat_select, Markup.inlineKeyboard(buttons)));
} catch (e) {
console.error(e);
ctx.reply("Error");
}
};
// Show Products (Smart Condition Check)
exports.showProducts = async (ctx, catId) => {
try {
const id = catId.replace('cat_', '');
const category = await Category.findOne({ id: id });
if (!category) return ctx.reply(ctx.i18n.no_cats);
// Smart Check: Count products
// We treat missing condition as 'new'
const countNew = await Product.countDocuments({
category: category.name,
$or: [{ condition: 'new' }, { condition: { $exists: false } }]
});
const countUsed = await Product.countDocuments({
category: category.name,
condition: 'used'
});
if (countNew === 0 && countUsed === 0) {
return ctx.reply("β Bu kategoriyada mahsulotlar yo'q.");
}
// If only NEW exist -> Show New directly
if (countUsed === 0) {
return exports.browseCategory(ctx, catId, 0, 0, 'new');
}
// If only USED exist -> Show Used directly
if (countNew === 0) {
return exports.browseCategory(ctx, catId, 0, 0, 'used');
}
// If BOTH exist -> Ask user
const buttons = [
[
Markup.button.callback(`π Yangi (${countNew})`, `cond_${id}_new`),
Markup.button.callback(`β»οΈ B/U (${countUsed})`, `cond_${id}_used`)
],
[Markup.button.callback(`π Barchasi (${countNew + countUsed})`, `cond_${id}_all`)],
[Markup.button.callback(ctx.i18n.btn_back_nav || "β¬
οΈ Orqaga", "back_to_cats")]
];
// Try edit, fallback to reply
await ctx.editMessageText("π Qaysi turdagi mahsulotlarni ko'rmoqchisiz?", Markup.inlineKeyboard(buttons))
.catch(() => ctx.reply("π Qaysi turdagi mahsulotlarni ko'rmoqchisiz?", Markup.inlineKeyboard(buttons)));
} catch (e) {
console.error("Smart Filter Error:", e);
ctx.reply("Error in Catalog");
}
};
// Filter Handler
exports.filterByCondition = async (ctx, catId, condition) => {
return exports.browseCategory(ctx, catId, 0, 0, condition);
};
// Browse Category (Carousel Logic)
exports.browseCategory = async (ctx, catId, prodIndex, mediaIndex = 0, conditionFilter = 'all') => {
try {
const id = catId.replace('cat_', '');
const category = await Category.findOne({ id: id });
if (!category) return ctx.reply("Kategoriya topilmadi.");
let query = { category: category.name };
if (conditionFilter === 'new') {
// Treat missing as new
query.$or = [{ condition: 'new' }, { condition: { $exists: false } }];
} else if (conditionFilter === 'used') {
query.condition = 'used';
}
// If 'all', no condition filter needed
const products = await Product.find(query);
if (!products || products.length === 0) return ctx.reply("β Bu kategoriyada mahsulotlar yo'q.");
// Handle Product Index Bounds
let page = parseInt(prodIndex);
if (page < 0) page = products.length - 1;
if (page >= products.length) page = 0;
const product = products[page];
// Handle Media Index Bounds
let mPage = parseInt(mediaIndex);
if (!product.media || product.media.length === 0) mPage = 0;
else {
if (mPage < 0) mPage = product.media.length - 1;
if (mPage >= product.media.length) mPage = 0;
}
const formatPrice = (p) => p.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const stockText = product.quantity > 0 ? `${ctx.i18n.stock_yes}: ${product.quantity}` : ctx.i18n.stock_no;
// Calculate Rating
const reviews = product.reviews || [];
const avgRating = reviews.length > 0 ? (reviews.reduce((a, b) => a + b.rating, 0) / reviews.length).toFixed(1) : "0.0";
// Prepare Message Content
// Price formatting with discount
let priceText = `${formatPrice(product.price)} so'm`;
if (product.discountPercent > 0 && product.originalPrice) {
priceText = `β ${formatPrice(product.originalPrice)} | β
${formatPrice(product.price)} so'm (-${product.discountPercent}% π₯)`;
}
const caption = `${product.name}\n\n${product.description}\n\n${ctx.i18n.price}: ${priceText}\n${stockText}\nβοΈ ${ctx.i18n.rating}: ${avgRating} (${reviews.length})\n\nπ ${category.name} (${page + 1}/${products.length})`;
// Buttons
// Navigation: prev/next product. Media index resets to 0.
// Format: br_catId_prodIndex_mediaIndex_condition
let navigationRow = [
Markup.button.callback("β¬
οΈ", `br_${id}_${page - 1}_0_${conditionFilter}`),
Markup.button.callback(`${page + 1} / ${products.length}`, "noop"),
Markup.button.callback("β‘οΈ", `br_${id}_${page + 1}_0_${conditionFilter}`)
];
let mediaRow = [];
if (product.media && product.media.length > 1) {
mediaRow.push(Markup.button.callback(`πΈ ${mPage + 1}/${product.media.length}`, `br_${id}_${page}_${mPage + 1}_${conditionFilter}`));
}
let actionRow = [];
if (product.quantity > 0) {
actionRow.push(Markup.button.callback(ctx.i18n.add_to_cart, `add_cart_${product.id}`));
} else {
actionRow.push(Markup.button.callback(ctx.i18n.stock_no, `noop`));
}
let ratingRow = [Markup.button.callback(ctx.i18n.rate_ask, `rate_ask_${product.id}`)];
let buttons = [
navigationRow,
...(mediaRow.length ? [mediaRow] : []),
actionRow,
ratingRow,
[Markup.button.callback(ctx.i18n.back_to_cats, "back_to_cats")]
];
// Admin Edit Button... (keeping existing logic)
const config = require('../config');
if (config.ADMIN_IDS.includes(ctx.from.id.toString())) {
buttons.push([Markup.button.callback("βοΈ Tahrirlash (Admin)", `edit_prod_${product.id}`)]);
}
// Send or Edit Message
const media = (product.media && product.media.length > 0) ? product.media[mPage] : null;
try {
await ctx.deleteMessage().catch(() => { });
if (media) {
if (media.type === 'video') {
await ctx.replyWithVideo(media.file_id, { caption: caption, parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) });
} else {
await ctx.replyWithPhoto(media.file_id, { caption: caption, parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) });
}
} else {
await ctx.replyWithHTML(caption, Markup.inlineKeyboard(buttons));
}
} catch (e) {
console.error("Error sending carousel:", e);
ctx.reply("Xatolik bo'ldi");
}
} catch (err) {
console.error(err);
ctx.reply("Xatolik yuz berdi.");
}
};
// Rating System
exports.askRating = (ctx, prodId) => {
ctx.answerCbQuery();
const id = prodId; // parse if needed, but rate_ask_ID passes ID string usually
// Show 1-5 buttons
ctx.reply("Ushbu mahsulotni baholang:", Markup.inlineKeyboard([
[1, 2, 3, 4, 5].map(n => Markup.button.callback(n.toString(), `rate_save_${id}_${n}`))
]));
};
exports.saveRating = async (ctx, prodId, rating) => {
try {
const id = parseInt(prodId);
const r = parseInt(rating);
await Product.updateOne(
{ id: id },
{ $push: { reviews: { userId: ctx.from.id, userName: ctx.from.first_name, rating: r } } }
);
ctx.deleteMessage(); // remove rating buttons
ctx.reply(`β
Rahmat! Siz ${r} baho berdingiz.`);
} catch (e) {
console.error(e);
ctx.reply("Xatolik");
}
};
// Show Product Details (Direct Link - Keep this for Search results or specific links)
exports.showProduct = async (ctx, prodId) => {
// ... Existing logic for single product view (from search) ...
// Reuse existing logic but maybe simplify or keep as is.
// The user instruction was to "replace showProducts" logic.
// I need to keep `showProduct` for when they click a search result.
// I will replace `showProducts` from the file but I need to preserve `showProduct` below it if I am not targeting it.
// The Tool "ReplacementContent" replaces a BLOCK.
// I will define `showProduct` again to be safe.
try {
const id = parseInt(prodId.replace('prod_', ''));
const product = await Product.findOne({ id: id });
if (!product) return ctx.reply("Mahsulot topilmadi.");
// Price formatting with discount
let priceText = `${product.price} so'm`;
if (product.discountPercent > 0 && product.originalPrice) {
priceText = `β ${product.originalPrice} | β
${product.price} so'm (-${product.discountPercent}% π₯)`;
}
const caption = `${product.name}\n\n${product.description}\n\nNarxi: ${priceText}`;
let buttons = [[Markup.button.callback("π Savatga qo'shish", `add_cart_${product.id}`)]];
// Admin Edit Button
const config = require('../config');
if (config.ADMIN_IDS.includes(ctx.from.id.toString())) {
buttons.push([Markup.button.callback("βοΈ Tahrirlash", `edit_prod_${product.id}`)]);
}
buttons.push([Markup.button.callback("π Asosiy menyu", "back_to_menu")]);
if (product.media && product.media.length > 0) {
const m = product.media[0];
if (m.type === 'video') await ctx.replyWithVideo(m.file_id, { caption, parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) });
else await ctx.replyWithPhoto(m.file_id, { caption, parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) });
} else {
await ctx.replyWithHTML(caption, Markup.inlineKeyboard(buttons));
}
} catch (err) {
console.error(err);
ctx.reply("Xatolik: " + err.message);
}
};
// Helper: Format Price
const formatPrice = (price) => {
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
};
// ... (In showProducts/browseCategory/showProduct - I will do this via multiple replacements or just one big one if they are close, but they are scattered.)
// Let's stick to adding helper and updating addToCart first.
// Add to Cart
exports.addToCart = async (ctx, prodId) => {
try {
const id = parseInt(prodId.replace('add_cart_', ''));
const product = await Product.findOne({ id: id });
if (!product) return ctx.answerCbQuery("Mahsulot topilmadi");
if (!ctx.session.cart) ctx.session.cart = [];
// Check stock
const currentInCart = ctx.session.cart.find(item => item.id === id);
const currentCount = currentInCart ? currentInCart.count : 0;
if (currentCount + 1 > (product.quantity || 0)) {
return ctx.answerCbQuery(`β οΈ Omborda bor-yo'g'i ${product.quantity} ta qoldi!`, { show_alert: true });
}
if (currentInCart) {
currentInCart.count++;
} else {
const prodObj = product.toObject();
ctx.session.cart.push({ ...prodObj, count: 1 });
}
// Calculate Cart Total for Feedback
const total = ctx.session.cart.reduce((acc, item) => acc + (item.price * item.count), 0);
ctx.answerCbQuery(`β
Qo'shildi!\nSavatda: ${formatPrice(total)} so'm`, { show_alert: false }); // show_alert false = toast
} catch (err) {
console.error(err);
}
};
// Show Contact Info
exports.showContact = (ctx) => {
// Ensure i18n
const i18n = ctx.i18n || require('../locales').uz;
// Reply with text and TWO buttons as requested
ctx.reply(i18n.contact_info, Markup.inlineKeyboard([
[Markup.button.url(i18n.admin_contact_prod, "https://t.me/isfandiyor_3")],
[Markup.button.url(i18n.admin_contact_bot, "https://t.me/IBROHM_7")]
]));
};
// Show Cart (Interactive)
exports.showCart = (ctx) => {
const i18n = ctx.i18n || require('../locales').uz;
const cart = ctx.session.cart || [];
if (cart.length === 0) {
// If message to edit exists
if (ctx.callbackQuery) {
return ctx.editMessageText(i18n.cart_empty).catch(() => ctx.reply(i18n.cart_empty));
}
return ctx.reply(i18n.cart_empty);
}
const formatPrice = (price) => price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
let text = `${i18n.cart_title}\n\n`;
let total = 0;
const buttons = [];
cart.forEach((item, index) => {
const itemTotal = item.price * item.count;
total += itemTotal;
text += `${index + 1}. ${item.name}\n${item.count} x ${formatPrice(item.price)} = ${formatPrice(itemTotal)} ${i18n.currency}\n\n`;
// Interactive Row: [ - ] [ 1 ] [ + ] [ π ]
buttons.push([
Markup.button.callback("β", `cart_decr_${index}`),
Markup.button.callback(`${item.count} ta`, `noop`),
Markup.button.callback("β", `cart_incr_${index}`),
Markup.button.callback("π", `cart_del_${index}`)
]);
});
text += `${i18n.cart_total} ${formatPrice(total)} ${i18n.currency}`;
// Control Actions
buttons.push([
Markup.button.callback(i18n.btn_order, "checkout"),
Markup.button.callback(i18n.btn_clear, "clear_cart")
]);
buttons.push([Markup.button.callback(i18n.back, "back_to_menu")]);
const keyboard = Markup.inlineKeyboard(buttons);
if (ctx.callbackQuery) {
ctx.editMessageText(text, { parse_mode: 'HTML', ...keyboard }).catch(() => ctx.replyWithHTML(text, keyboard));
} else {
ctx.replyWithHTML(text, keyboard);
}
};
// Cart Actions Handling
exports.cartAction = async (ctx, action, indexStr) => {
const index = parseInt(indexStr);
const cart = ctx.session.cart || [];
const i18n = ctx.i18n || require('../locales').uz;
if (!cart[index]) return ctx.answerCbQuery(i18n.error || "Xatolik", { show_alert: true });
const item = cart[index];
const product = await Product.findOne({ id: item.id });
if (action === 'incr') {
const stock = product ? product.quantity : 0;
if (item.count >= stock) {
return ctx.answerCbQuery(
i18n.cart_stock_err.replace('{item}', item.name).replace('{stock}', stock),
{ show_alert: true }
);
}
item.count++;
} else if (action === 'decr') {
if (item.count > 1) {
item.count--;
} else {
// Ask to remove? Or just remove? Let's just remove logic or do nothing
// Usually decrementing 1 -> 0 should remove or stop at 1. Let's stop at 1. User can click trash.
return ctx.answerCbQuery();
}
} else if (action === 'del') {
cart.splice(index, 1);
}
ctx.session.cart = cart;
// exports.showCart(ctx); // Recursive refresh
// Instead of calling showCart (which creates new scope), let's just re-run the logic or call a refresh helper.
// showCart is designed to handle edit, so calling it is fine.
exports.showCart(ctx);
};
// Clear Cart
exports.clearCart = (ctx) => {
const i18n = ctx.i18n || require('../locales').uz;
ctx.session.cart = [];
ctx.answerCbQuery(i18n.cart_empty);
if (ctx.callbackQuery) {
ctx.editMessageText(i18n.cart_empty).catch(() => ctx.reply(i18n.cart_empty));
} else {
ctx.reply(i18n.cart_empty);
}
};
// Checkout Steps
exports.startCheckout = (ctx) => {
const cart = ctx.session.cart || [];
if (cart.length === 0) return ctx.answerCbQuery("Savat bo'sh!");
ctx.deleteMessage(); // Clean up cart view
ctx.reply("π Iltimos, aloqa uchun telefon raqamingizni yuboring:", Markup.keyboard([
Markup.button.contactRequest("π± Telefon raqamni yuborish")
]).resize().oneTime());
};
// --- NEW FEATURES ---
// 1. Search Functionality
exports.startSearch = (ctx) => {
ctx.reply("π Mahsulot nomini yozib yuboring:", Markup.keyboard([['β Bekor qilish']]).resize());
// Note: We need a way to track that user is in "search mode".
// For simplicity, we can use session state.
ctx.session.isSearching = true;
};
exports.handleSearch = async (ctx) => {
if (!ctx.session.isSearching) return false; // Not in search mode
const query = ctx.message.text;
// Exit search on specific commands
if (query === '/start' || query === 'π Orqaga' || query === 'β Bekor qilish') {
ctx.session.isSearching = false;
exports.start(ctx, "Qidiruv bekor qilindi.");
return true;
}
try {
// Case-insensitive regex search
const products = await Product.find({ name: { $regex: query, $options: 'i' } }).limit(10);
if (products.length === 0) {
ctx.reply(ctx.i18n.search_empty);
return true;
}
const buttons = products.map(p => {
const cond = p.condition === 'used' ? 'β»οΈ' : 'π';
// Show price in button too
return [Markup.button.callback(`${cond} ${p.name}`, `prod_${p.id}`)];
});
ctx.reply(`${ctx.i18n.search_results} "${query}"`, Markup.inlineKeyboard(buttons));
ctx.session.isSearching = false; // Reset after successful search result
// Or keep it true to allow multiple searches? Let's reset to be safe and they can clear menu.
// Actually, better UX: Don't reset, allow them to search again if not found?
// Let's reset here so keyboard returns to normal if they click a product.
} catch (err) {
console.error(err);
ctx.reply("Qidiruvda xatolik.");
}
return true; // We handled the message
};
// 2. Order History
exports.showOrderHistory = async (ctx) => {
try {
const orders = await Order.find({ userId: ctx.from.id }).sort({ createdAt: -1 }).limit(10);
if (!orders || orders.length === 0) {
return ctx.reply(ctx.i18n.no_orders);
}
let text = `${ctx.i18n.order_history_title}\n\n${ctx.i18n.order_history}`;
// Create a list of buttons for orders like: π¦ #1234 | 120 000 | β
const buttons = orders.map(o => {
let statusIcon = 'π¦';
switch (o.status) {
case 'new': statusIcon = ctx.i18n.status_new; break;
case 'accepted': statusIcon = ctx.i18n.status_accepted; break;
case 'canceled': statusIcon = ctx.i18n.status_canceled; break;
case 'shipping': statusIcon = ctx.i18n.status_shipping; break;
case 'delivered': statusIcon = ctx.i18n.status_delivered; break;
default: statusIcon = o.status;
}
return [Markup.button.callback(`${statusIcon} | #${o.id} | ${formatPrice(o.total)}`, `my_order_${o.id}`)];
});
buttons.push([Markup.button.callback(ctx.i18n.back_to_menu || "π Asosiy menyu", "back_to_menu")]);
ctx.replyWithHTML(text, Markup.inlineKeyboard(buttons));
} catch (err) {
console.error(err);
ctx.reply(ctx.i18n.error_generic);
}
};
exports.showOrderDetails = async (ctx, orderId) => {
try {
const id = parseInt(orderId);
const order = await Order.findOne({ id: id, userId: ctx.from.id }); // Security check: must match userId
if (!order) return ctx.answerCbQuery(ctx.i18n.error_generic);
let statusText = order.status;
switch (order.status) {
case 'new': statusText = ctx.i18n.status_new; break;
case 'accepted': statusText = ctx.i18n.status_accepted; break;
case 'canceled': statusText = ctx.i18n.status_canceled; break;
case 'shipping': statusText = ctx.i18n.status_shipping; break;
case 'delivered': statusText = ctx.i18n.status_delivered; break;
}
let text = `${ctx.i18n.order_title}${order.id}**\n` +
`${ctx.i18n.date}: ${new Date(order.createdAt).toLocaleString()}\n` +
`${ctx.i18n.order_details_pay} ${order.paymentMethod || ctx.i18n.payment_cash}\n` +
`${ctx.i18n.order_details_status} ${statusText}\n\n` +
`${ctx.i18n.items}\n`;
order.items.forEach(i => {
text += `- ${i.name} (${i.count}x) - ${formatPrice(i.price)} ${ctx.i18n.currency}\n`;
});
text += `\n${ctx.i18n.total_label} ${formatPrice(order.total)} ${ctx.i18n.currency}`;
const buttons = [];
// Allow cancellation only if status is 'new'
if (order.status === 'new') {
buttons.push([Markup.button.callback(ctx.i18n.btn_reject, `user_cancel_order_${order.id}`)]);
}
buttons.push([Markup.button.callback(ctx.i18n.back, "my_orders_list")]);
ctx.editMessageText(text, { parse_mode: 'HTML', ...Markup.inlineKeyboard(buttons) })
.catch(() => ctx.replyWithHTML(text, Markup.inlineKeyboard(buttons)));
} catch (err) {
console.error(err);
ctx.reply(ctx.i18n.error_generic);
}
};
// User Cancel Order (Restock)
exports.cancelUserOrder = async (ctx, orderId) => {
try {
const id = parseInt(orderId);
const order = await Order.findOne({ id: id, userId: ctx.from.id });
if (!order) return ctx.answerCbQuery(ctx.i18n.error_generic);
if (order.status !== 'new') return ctx.answerCbQuery(ctx.i18n.error_generic);
// Restock Logic
const Product = require('../models/Product');
for (const item of order.items) {
await Product.updateOne({ id: item.id }, { $inc: { quantity: item.count } });
}
await Order.updateOne({ id: id }, { status: 'canceled' });
ctx.answerCbQuery(ctx.i18n.alert_canceled);
// Notify Admin about cancellation (Optional but good logic)
const config = require('../config');
for (const adminId of config.ADMIN_IDS) {
ctx.telegram.sendMessage(adminId, `β οΈ Buyurtma bekor qilindi!\n#${id} bekor qilindi (Foydalanuvchi tomonidan).`, { parse_mode: 'HTML' }).catch(e => { });
}
// Refresh View
exports.showOrderDetails(ctx, id);
} catch (err) {
console.error(err);
ctx.reply(ctx.i18n.error_generic);
}
};