telegram-shop-api / src /scenes /user /checkout.js
Deploy Bot
Feat: Stability Update 1.7
edea4a9
const { Scenes, Markup } = require('telegraf');
const Order = require('../../models/Order');
const Product = require('../../models/Product');
const User = require('../../models/User');
const config = require('../../config');
const userController = require('../../controllers/userController');
const formatPrice = (price) => {
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
};
// Common Keyboard
const navKeyboard = (ctx, i18n, extras = []) => {
const back = i18n.btn_back_nav || "⬅️ Back";
const cancel = i18n.btn_cancel || "❌ Cancel";
const k = [[back, cancel]];
if (extras.length) k.unshift(...extras);
return Markup.keyboard(k).resize().oneTime();
};
const checkNav = (ctx) => {
if (!ctx.message || !ctx.message.text) return null;
const text = ctx.message.text;
const i18n = ctx.i18n || require('../../locales').uz;
const btnCancel = i18n.btn_cancel || "❌ Cancel";
const btnBack = i18n.btn_back_nav || "⬅️ Back";
// Robust checking against localised strings
if (text === btnCancel || text === '❌ Bekor qilish' || text === '❌ ΠžΡ‚ΠΌΠ΅Π½Π°' || text === '❌ Cancel') {
ctx.scene.leave();
ctx.reply(i18n.cancel_process || "Cancelled", Markup.removeKeyboard());
return 'STOP';
}
if (text === btnBack || text === '⬅️ Ortga' || text === '⬅️ Назад' || text === '⬅️ Back') {
return 'BACK';
}
return null;
};
async function finishOrder(ctx) {
try {
const state = ctx.wizard.state;
const cart = ctx.session.cart || [];
const i18n = ctx.i18n || require('../../locales').uz;
for (const item of cart) {
await Product.updateOne({ id: item.id }, { $inc: { quantity: -item.count } });
}
// Save Address if New and Delivery (Yandex)
if (state.deliveryMethod === i18n.delivery_yandex && state.isNewAddress && state.location) {
await User.updateOne(
{ id: ctx.from.id },
{ $push: { addresses: { name: `Manzil ${new Date().toLocaleDateString()}`, latitude: state.location.latitude, longitude: state.location.longitude } } }
);
}
const orderData = {
id: Date.now(),
userId: ctx.from.id,
user: state.fullName || ctx.from.first_name,
phone: state.phone,
deliveryMethod: state.deliveryMethod,
location: state.location,
addressText: state.addressText,
deliveryTime: state.deliveryTime || 'Standard',
comment: state.comment,
items: cart,
total: state.total,
paymentMethod: state.paymentMethod,
status: 'new'
};
const newOrder = new Order(orderData);
await newOrder.save();
// Update User Phone if new
await User.updateOne({ id: ctx.from.id }, { phone: state.phone });
ctx.reply(`${i18n.confirm_order}\n#${newOrder.id}\n${i18n.order_details_pay} ${state.paymentMethod}`, Markup.removeKeyboard());
// Notify Admins
let locInfo = "";
if (state.deliveryMethod === i18n.delivery_bts) {
locInfo = `πŸ“ <b>BTS:</b>\n${state.addressText}\nπŸ‘€ <b>F.I.O:</b> ${state.fullName}`;
} else if (state.location) {
locInfo = `πŸ“ <a href="https://www.google.com/maps?q=${state.location.latitude},${state.location.longitude}">Lokatsiya</a>`;
} else {
locInfo = "πŸƒ Olib ketish";
}
const adminText = `πŸ†• <b>Yangi buyurtma!</b>\n#${newOrder.id}\nπŸ‘€: ${newOrder.user}\nπŸ“ž: ${newOrder.phone}\n🚚: ${state.deliveryMethod}\n${locInfo}\nπŸ•’: ${state.deliveryTime || '-'}\nπŸ’¬: ${state.comment || "Yo'q"}\nπŸ’°: ${state.paymentMethod}\n\n` +
cart.map(item => `- ${item.name} (${item.count}x)`).join('\n') +
`\n\nJami: ${formatPrice(newOrder.total)} ${i18n.currency}`;
const btns = Markup.inlineKeyboard([Markup.button.callback("πŸ‘ Ko'rish", `admin_order_${newOrder.id}`)]);
for (const adminId of config.ADMIN_IDS) {
try {
await ctx.telegram.sendMessage(adminId, adminText, { parse_mode: 'HTML', ...btns });
if (state.location) await ctx.telegram.sendLocation(adminId, state.location.latitude, state.location.longitude);
if (state.paymentProof) await ctx.telegram.sendPhoto(adminId, state.paymentProof, { caption: `🧾 Chek (#${newOrder.id})` });
} catch (e) { console.error(e); }
}
ctx.session.cart = [];
return ctx.scene.leave();
} catch (err) {
console.error("ORDER_CREATION_ERROR:", err);
// Handle Duplicate Key Error (Self-Healing Attempt)
if (err.code === 11000) {
ctx.reply("⚠️ Tizimda qisqa nosozlik (ID Duplicate). Iltimos, qayta 'Buyurtma berish' tugmasini bosing.");
// Ideally we retry, but for now asking user is safer than infinite loop
} else {
const errorMsg = (err.message || "").substring(0, 50);
ctx.reply(`❌ Tizim xatoligi yuz berdi: ${errorMsg}\n\nIltimos, adminga xabar bering.`);
// Notify admin about the crash
for (const adminId of config.ADMIN_IDS) {
ctx.telegram.sendMessage(adminId, `🚨 **CRITICAL CHECKOUT ERROR**:\nUser: ${ctx.from.id}\nError: ${err.message}`, { parse_mode: 'Markdown' }).catch(e => console.error(e));
}
}
return ctx.scene.leave();
}
}
const checkoutScene = new Scenes.WizardScene(
'CHECKOUT_SCENE',
// Step 0: Pre-flight Check & Phone
async (ctx) => {
const i18n = ctx.i18n || require('../../locales').uz;
const cart = ctx.session.cart || [];
ctx.wizard.state.i18n = i18n; // Save specifically for wizard reuse
// 1. Stock Validation
const conflicts = [];
for (const item of cart) {
const product = await Product.findOne({ id: item.id });
if (!product || product.quantity < item.count) {
conflicts.push(`${item.name} (${product ? product.quantity : 0})`);
}
}
if (conflicts.length > 0) {
ctx.reply(`⚠️ ${i18n.cart_stock_err.replace('{item}', conflicts.join(', ')).replace('{stock}', '...')} \n\nIltimos savatni yangilang.`);
ctx.scene.leave();
return; // Stop checkout
}
// 2. Phone Input
const user = await User.findOne({ id: ctx.from.id });
ctx.wizard.state.user = user;
// NUCLEAR DEFENSIVE FIX
const btnPhone = (i18n && i18n.phone_button) ? i18n.phone_button : "πŸ“± Raqamni yuborish";
const btnCancel = (i18n && i18n.btn_cancel) ? i18n.btn_cancel : "❌ Bekor qilish";
const msgText = (i18n && i18n.phone_prompt) ? i18n.phone_prompt : "Telefon raqamingizni yuboring:";
console.log("CHECKOUT_DEBUG: Initializing Step 0 with safe keys:", { btnPhone, btnCancel });
let keyboardRows = [];
// Add Saved Phone if exists
if (user && user.phone) {
const savedTemplate = (i18n && i18n.checkout_phone_saved) ? i18n.checkout_phone_saved : "πŸ“± Mening raqamim: {phone}";
const savedBtnText = savedTemplate.replace('{phone}', user.phone || "");
if (savedBtnText) keyboardRows.push([Markup.button.text(savedBtnText)]);
}
// Add Contact Request Button
if (btnPhone) keyboardRows.push([Markup.button.contactRequest(btnPhone)]);
// Add Cancel Button
if (btnCancel) keyboardRows.push([Markup.button.text(btnCancel)]);
// Filter any potentially empty rows just in case
keyboardRows = keyboardRows.filter(row => row && row.length > 0 && row[0]);
try {
ctx.reply(msgText, Markup.keyboard(keyboardRows).resize().oneTime());
} catch (e) {
console.error("CHECKOUT_DEBUG_CRASH:", e);
ctx.reply("Checkout Error (Keyboard). Type /start");
}
return ctx.wizard.next();
},
// Step 1: Handle Phone -> Delivery
(ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
let phone = '';
if (ctx.message.contact) {
phone = ctx.message.contact.phone_number;
} else if (ctx.message.text) {
// Check if user clicked "My Saved Phone"
const savedText = i18n.checkout_phone_saved.replace('{phone}', '');
if (ctx.message.text.includes(savedText.trim()) && ctx.wizard.state.user && ctx.wizard.state.user.phone) {
phone = ctx.wizard.state.user.phone;
} else {
if (!/^\+?[0-9]{9,15}$/.test(ctx.message.text.replace(/\s/g, ''))) {
ctx.reply(i18n.error_phone, navKeyboard(ctx, i18n));
return;
}
phone = ctx.message.text;
}
} else {
ctx.reply(i18n.phone_prompt, navKeyboard(ctx, i18n)); return;
}
ctx.wizard.state.phone = phone;
ctx.reply(i18n.delivery_select, Markup.keyboard([
[i18n.delivery_yandex, i18n.delivery_bts],
[i18n.delivery_pickup],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return ctx.wizard.next();
},
// Step 2: Handle Delivery Selection
async (ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') {
ctx.wizard.selectStep(0);
return await ctx.wizard.steps[0](ctx);
}
const text = ctx.message.text.trim(); // Trim input
ctx.wizard.state.deliveryMethod = text;
if (text.includes('BTS') || text.includes('ΠŸΠΎΡ‡Ρ‚Π°')) {
ctx.wizard.state.deliveryMethod = i18n.delivery_bts;
ctx.reply("πŸ“ " + i18n.settings_addr, navKeyboard(ctx, i18n));
return ctx.wizard.next(); // Step 3
}
if (text.includes('Yandex') || text.includes('Taxi') || text.includes('Вакси')) {
ctx.wizard.state.deliveryMethod = i18n.delivery_yandex;
// Yandex Explicit Check
const user = ctx.wizard.state.user;
const btns = [];
if (user && user.addresses && user.addresses.length > 0) {
user.addresses.forEach((addr, i) => {
btns.push([i18n.checkout_location_saved.replace('{name}', addr.name || 'Saved Address')]);
});
}
btns.push([Markup.button.locationRequest(i18n.location_button)]);
btns.push([i18n.btn_back_nav, i18n.btn_cancel]);
ctx.reply(i18n.address_prompt, Markup.keyboard(btns).resize().oneTime());
return ctx.wizard.selectStep(5); // Step 5
}
// Pickup Check (Robust)
if (text.includes('Olib ketish') || text.includes('Π‘Π°ΠΌΠΎΠ²Ρ‹Π²ΠΎΠ·') || text.includes('Pickup')) {
ctx.wizard.state.deliveryMethod = i18n.delivery_pickup;
ctx.wizard.state.location = null;
const Settings = require('../../models/Settings');
const storeSettings = await Settings.findOne({ key: 'store_location' }); // Optimized query placement
if (storeSettings && storeSettings.value) {
const { text, latitude, longitude } = storeSettings.value;
await ctx.reply(`πŸ“ **Bizning manzil:**\n${text}`, { parse_mode: 'Markdown' });
await ctx.replyWithLocation(latitude, longitude);
} else {
await ctx.reply("πŸ“ **Bizning manzil:**\nToshkent sh... (Default)");
}
ctx.reply("πŸ•’ Iltimos, vaqtni yozing (Masalan: 14:30):", Markup.keyboard([
["πŸ“… Bugun", "πŸ“… Ertaga"],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return ctx.wizard.selectStep(6);
}
// Unknown Input
ctx.reply(i18n.delivery_select, Markup.keyboard([
[i18n.delivery_yandex, i18n.delivery_bts],
[i18n.delivery_pickup],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return; // Stay in step
},
// Step 3: BTS Address
(ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') {
ctx.reply(i18n.delivery_select, Markup.keyboard([
[i18n.delivery_yandex, i18n.delivery_bts],
[i18n.delivery_pickup],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return ctx.wizard.selectStep(2);
}
ctx.wizard.state.addressText = ctx.message.text;
ctx.reply("πŸ‘€ F.I.O (To'liq):", navKeyboard(ctx, i18n));
return ctx.wizard.next();
},
// Step 4: BTS Name
(ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') return ctx.wizard.selectStep(3);
ctx.wizard.state.fullName = ctx.message.text;
ctx.wizard.state.paymentMethod = i18n.payment_card; // BTS is usually Card only logic from previous Code
ctx.reply("πŸ“ Izoh (Comment)?", Markup.keyboard([
["Yo'q"],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize());
return ctx.wizard.selectStep(7);
},
// Step 5: Yandex Address
async (ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') {
ctx.reply(i18n.delivery_select, Markup.keyboard([
[i18n.delivery_yandex, i18n.delivery_bts],
[i18n.delivery_pickup],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return ctx.wizard.selectStep(2);
}
let location = null;
if (ctx.message.location) {
location = ctx.message.location;
ctx.wizard.state.isNewAddress = true;
} else if (ctx.message.text) {
// Find in saved
const user = ctx.wizard.state.user;
if (user && user.addresses) {
// Fuzzy match or exact match depending on format
// We formatted buttons as: i18n.checkout_location_saved.replace('{name}', addr.name)
// Let's try to match by name
const savedFormat = i18n.checkout_location_saved.replace('{name}', '');
const name = ctx.message.text.replace(savedFormat, '').trim();
// This might be tricky if "Saved: " is empty.
// Simple loop match
const found = user.addresses.find(a => ctx.message.text.includes(a.name));
if (found) location = { latitude: found.latitude, longitude: found.longitude };
}
}
if (!location) {
ctx.reply("⚠️ " + i18n.settings_loc, navKeyboard(ctx, i18n));
return;
}
ctx.wizard.state.location = location;
ctx.reply("πŸ•’ Iltimos, vaqtni yozing (Masalan: 15:00):", Markup.keyboard([
["πŸ“… Bugun", "πŸ“… Ertaga"],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize().oneTime());
return ctx.wizard.next(); // To Step 6
},
// Step 6: Time
(ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') {
// Logic to go back to Address
return ctx.wizard.selectStep(5);
}
ctx.wizard.state.deliveryTime = ctx.message.text;
ctx.reply("πŸ“ Izoh (Comment)?", Markup.keyboard([
["Yo'q"],
[i18n.btn_back_nav, i18n.btn_cancel]
]).resize());
return ctx.wizard.next();
},
// Step 7: Comment & Total Calc
(ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') return ctx.wizard.selectStep(6);
const text = ctx.message.text;
ctx.wizard.state.comment = text === "Yo'q" ? "" : text;
const cart = ctx.session.cart || [];
const total = cart.reduce((acc, item) => acc + (item.price * item.count), 0);
ctx.wizard.state.total = total;
// BTS is strictly Card (handled via pre-set or just flow)
if (ctx.wizard.state.deliveryMethod === i18n.delivery_bts) {
// BTS flow usually requires Card payment proof, so we treat it same as Card
// But existing code jumped to Step 9 directly?
// Line 358 in original code: return ctx.wizard.selectStep(9);
// We keep that behavior or merge it.
// Original: 358: if (ctx.wizard.state.deliveryMethod === i18n.delivery_bts) ... selectStep(9)
// Let's keep existing BTS logic if it was working, but here we are replacing the block.
// The original code handled BTS specifically at line 358.
// Let's replicate that logic first.
ctx.reply(`πŸ“¨ **BTS Pay**\n\n8600 ...`, navKeyboard(ctx, i18n));
return ctx.wizard.selectStep(9);
}
// Conditional Payment Buttons
let paymentButtons = [];
if (ctx.wizard.state.deliveryMethod === i18n.delivery_pickup) {
// Pickup -> Cash AND Card
paymentButtons.push([i18n.payment_cash, i18n.payment_card]);
} else {
// Yandex (Delivery) -> Card Only
paymentButtons.push([i18n.payment_card]);
}
paymentButtons.push([i18n.btn_back_nav, i18n.btn_cancel]);
ctx.reply(i18n.payment_select, Markup.keyboard(paymentButtons).resize().oneTime());
return ctx.wizard.next();
},
// Step 8: Payment Method
async (ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') return ctx.wizard.selectStep(7);
const choice = ctx.message.text;
// Validation: Verify if choice is allowed for current delivery method
// Pickup Logic: Allow both Cash and Card (Relaxed)
// Delivery Logic: Strict Card Only
if (ctx.wizard.state.deliveryMethod === i18n.delivery_yandex && choice !== i18n.payment_card) {
return ctx.reply("⚠️ Yetkazib berish uchun faqat Karta orqali to'lov mavjud.", Markup.keyboard([[i18n.payment_card], [i18n.btn_back_nav, i18n.btn_cancel]]).resize());
}
if (choice === i18n.payment_cash) {
ctx.wizard.state.paymentMethod = 'Naqd';
return await finishOrder(ctx);
} else if (choice === i18n.payment_card) {
ctx.wizard.state.paymentMethod = 'Karta';
ctx.reply(`πŸ’³ **Humo:** 9860 ...\n\nChek yuboring:`, navKeyboard(ctx, i18n));
return ctx.wizard.next();
} else {
ctx.reply("Tanlang.");
}
},
// Step 9: Proof
async (ctx) => {
const i18n = ctx.wizard.state.i18n || ctx.i18n;
const nav = checkNav(ctx);
if (nav === 'STOP') return;
if (nav === 'BACK') return ctx.wizard.selectStep(8);
if (ctx.message.photo) {
ctx.wizard.state.paymentProof = ctx.message.photo[ctx.message.photo.length - 1].file_id;
return await finishOrder(ctx);
} else {
ctx.reply("⚠️ Chek rasmini yuboring.");
}
}
);
module.exports = checkoutScene;