Spaces:
Paused
Paused
| 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 = `❌ <s>${formatPrice(product.originalPrice)}</s> | ✅ ${formatPrice(product.price)} so'm (-${product.discountPercent}% 🔥)`; | |
| } | |
| const caption = `<b>${product.name}</b>\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 = `❌ <s>${product.originalPrice}</s> | ✅ ${product.price} so'm (-${product.discountPercent}% 🔥)`; | |
| } | |
| const caption = `<b>${product.name}</b>\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}. <b>${item.name}</b>\n${item.count} x ${formatPrice(item.price)} = <b>${formatPrice(itemTotal)}</b> ${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, `⚠️ <b>Buyurtma bekor qilindi!</b>\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); | |
| } | |
| }; | |