Implement product variant selection in TeleBot
FEATURES IMPLEMENTED: 1. Enhanced Product Display: - Shows multi-buy deals with pricing (e.g., "3 for £25") - Displays available variants grouped by type (Color, Flavor, etc.) - Clear visual separation between multi-buys and variants 2. Variant Selection Flow: - Single item: Select one variant from available options - Multi-buy bundles: Select individual variants for each item - Example: 3-pack allows choosing Red, Blue, Green individually - Visual feedback with checkmarks and counters 3. Smart Cart Management: - Tracks selected variants for each cart item - Supports both single variant (regular items) and multiple variants (multi-buys) - Unique cart entries based on product + variant combination - Prevents duplicate multi-buy bundles 4. User Experience Improvements: - Clear "Select Color/Flavor" prompts - Progress indicator for multi-item selection - Confirm button appears when selection complete - Clear selection option for multi-buys - Back navigation preserves context TECHNICAL CHANGES: - ProductCarouselService: Enhanced caption formatting with variants/multi-buys - MenuBuilder: New VariantSelectionMenu with dynamic button generation - CallbackHandler: Added handlers for selectvar, setvariant, addvariant, confirmvar - ShoppingCart: New AddItem overload accepting Product and variant list - CartItem: Added SelectedVariants list for multi-buy support - UserSession: Added SelectingVariants state This update enables customers to: - See all available product options at a glance - Choose specific variants when ordering - Mix and match variants in multi-buy deals - Get exactly what they want with clear visual feedback Next steps: Add bot activity tracking for live dashboard 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5530f9e4f5
commit
f12f35cc48
@ -170,7 +170,23 @@ namespace TeleBot.Handlers
|
||||
case "cancel_support":
|
||||
await HandleCancelSupport(bot, callbackQuery, session);
|
||||
break;
|
||||
|
||||
|
||||
case "selectvar":
|
||||
await HandleSelectVariant(bot, callbackQuery.Message, session, data);
|
||||
break;
|
||||
|
||||
case "setvariant":
|
||||
await HandleSetVariant(bot, callbackQuery.Message, session, data);
|
||||
break;
|
||||
|
||||
case "addvariant":
|
||||
await HandleAddVariant(bot, callbackQuery.Message, session, data);
|
||||
break;
|
||||
|
||||
case "confirmvar":
|
||||
await HandleConfirmVariant(bot, callbackQuery, session, data);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Unknown callback action: {Action}", action);
|
||||
break;
|
||||
@ -1126,5 +1142,122 @@ namespace TeleBot.Handlers
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSelectVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
|
||||
{
|
||||
// Format: selectvar:productId:quantity:multiBuyId
|
||||
var productId = Guid.Parse(data[1]);
|
||||
var quantity = int.Parse(data[2]);
|
||||
var multiBuyId = data.Length > 3 && data[3] != "null" ? data[3] : null;
|
||||
|
||||
var product = await _shopService.GetProductAsync(productId);
|
||||
if (product == null)
|
||||
{
|
||||
await bot.SendTextMessageAsync(message.Chat.Id, "Product not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any previous selections
|
||||
session.TempData["selected_variants"] = new List<string>();
|
||||
session.TempData["current_product"] = productId;
|
||||
session.TempData["current_quantity"] = quantity;
|
||||
session.TempData["current_multibuy"] = multiBuyId;
|
||||
|
||||
await bot.EditMessageReplyMarkupAsync(
|
||||
message.Chat.Id,
|
||||
message.MessageId,
|
||||
replyMarkup: MenuBuilder.VariantSelectionMenu(product, quantity, multiBuyId)
|
||||
);
|
||||
|
||||
session.State = SessionState.SelectingVariants;
|
||||
}
|
||||
|
||||
private async Task HandleSetVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
|
||||
{
|
||||
// Format: setvariant:productId:variantName (for single item)
|
||||
var productId = Guid.Parse(data[1]);
|
||||
var variantName = data[2];
|
||||
|
||||
var product = await _shopService.GetProductAsync(productId);
|
||||
if (product == null) return;
|
||||
|
||||
// For single item, replace selection
|
||||
var selectedVariants = new List<string> { variantName };
|
||||
session.TempData["selected_variants"] = selectedVariants;
|
||||
|
||||
var quantity = session.TempData.ContainsKey("current_quantity")
|
||||
? (int)session.TempData["current_quantity"]
|
||||
: 1;
|
||||
|
||||
var multiBuyId = session.TempData.ContainsKey("current_multibuy")
|
||||
? session.TempData["current_multibuy"] as string
|
||||
: null;
|
||||
|
||||
await bot.EditMessageReplyMarkupAsync(
|
||||
message.Chat.Id,
|
||||
message.MessageId,
|
||||
replyMarkup: MenuBuilder.VariantSelectionMenu(product, quantity, multiBuyId, selectedVariants)
|
||||
);
|
||||
}
|
||||
|
||||
private async Task HandleAddVariant(ITelegramBotClient bot, Message message, UserSession session, string[] data)
|
||||
{
|
||||
// Format: addvariant:productId:quantity:variantName:multiBuyId (for multi-buy)
|
||||
var productId = Guid.Parse(data[1]);
|
||||
var quantity = int.Parse(data[2]);
|
||||
var variantName = data[3];
|
||||
var multiBuyId = data.Length > 4 && data[4] != "null" ? data[4] : null;
|
||||
|
||||
var product = await _shopService.GetProductAsync(productId);
|
||||
if (product == null) return;
|
||||
|
||||
// Get current selections
|
||||
var selectedVariants = session.TempData.ContainsKey("selected_variants")
|
||||
? (List<string>)session.TempData["selected_variants"]
|
||||
: new List<string>();
|
||||
|
||||
// Add variant if not at quantity limit
|
||||
if (selectedVariants.Count < quantity)
|
||||
{
|
||||
selectedVariants.Add(variantName);
|
||||
session.TempData["selected_variants"] = selectedVariants;
|
||||
}
|
||||
|
||||
await bot.EditMessageReplyMarkupAsync(
|
||||
message.Chat.Id,
|
||||
message.MessageId,
|
||||
replyMarkup: MenuBuilder.VariantSelectionMenu(product, quantity, multiBuyId, selectedVariants)
|
||||
);
|
||||
}
|
||||
|
||||
private async Task HandleConfirmVariant(ITelegramBotClient bot, CallbackQuery callbackQuery, UserSession session, string[] data)
|
||||
{
|
||||
// Format: confirmvar:productId:quantity:multiBuyId:variantList
|
||||
var productId = Guid.Parse(data[1]);
|
||||
var quantity = int.Parse(data[2]);
|
||||
var multiBuyId = data[3] != "null" ? Guid.Parse(data[3]) : (Guid?)null;
|
||||
var variantString = data[4];
|
||||
var selectedVariants = variantString.Split(',').ToList();
|
||||
|
||||
var product = await _shopService.GetProductAsync(productId);
|
||||
if (product == null) return;
|
||||
|
||||
// Add to cart with selected variants
|
||||
var cartItem = session.Cart.AddItem(product, quantity, multiBuyId, selectedVariants);
|
||||
|
||||
// Show success message
|
||||
await bot.AnswerCallbackQueryAsync(
|
||||
callbackQuery.Id,
|
||||
$"✅ Added {quantity}x {product.Name} with {string.Join(", ", selectedVariants)} to cart!",
|
||||
showAlert: true
|
||||
);
|
||||
|
||||
// Return to product view
|
||||
await HandleProductDetail(bot, callbackQuery.Message!, session, productId);
|
||||
|
||||
// Clear temp data
|
||||
session.TempData.Remove("selected_variants");
|
||||
session.TempData.Remove("current_multibuy");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LittleShop.Client.Models;
|
||||
|
||||
namespace TeleBot.Models
|
||||
{
|
||||
@ -11,6 +12,64 @@ namespace TeleBot.Models
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// New method that accepts Product and variants list
|
||||
public CartItem AddItem(Product product, int quantity = 1, Guid? multiBuyId = null, List<string>? selectedVariants = null)
|
||||
{
|
||||
decimal price = product.Price;
|
||||
|
||||
// If multi-buy selected, get the multi-buy price
|
||||
if (multiBuyId.HasValue && product.MultiBuys != null)
|
||||
{
|
||||
var multiBuy = product.MultiBuys.FirstOrDefault(mb => mb.Id == multiBuyId.Value);
|
||||
if (multiBuy != null)
|
||||
{
|
||||
price = multiBuy.Price;
|
||||
quantity = multiBuy.Quantity; // Use the multi-buy quantity
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for comparison
|
||||
var variantKey = selectedVariants != null && selectedVariants.Any()
|
||||
? string.Join(",", selectedVariants.OrderBy(v => v))
|
||||
: null;
|
||||
|
||||
// Check if item already exists
|
||||
var existingItem = Items.FirstOrDefault(i =>
|
||||
i.ProductId == product.Id &&
|
||||
i.MultiBuyId == multiBuyId &&
|
||||
(i.SelectedVariant == variantKey ||
|
||||
(i.SelectedVariants != null && string.Join(",", i.SelectedVariants.OrderBy(v => v)) == variantKey)));
|
||||
|
||||
if (existingItem != null)
|
||||
{
|
||||
// For multi-buys, we don't add quantities - each multi-buy is a separate bundle
|
||||
if (!multiBuyId.HasValue)
|
||||
{
|
||||
existingItem.Quantity += quantity;
|
||||
existingItem.UpdateTotalPrice();
|
||||
}
|
||||
return existingItem;
|
||||
}
|
||||
else
|
||||
{
|
||||
var newItem = new CartItem
|
||||
{
|
||||
ProductId = product.Id,
|
||||
MultiBuyId = multiBuyId,
|
||||
SelectedVariant = selectedVariants?.Count == 1 ? selectedVariants[0] : null,
|
||||
SelectedVariants = selectedVariants ?? new List<string>(),
|
||||
ProductName = product.Name,
|
||||
UnitPrice = price,
|
||||
Quantity = quantity
|
||||
};
|
||||
newItem.UpdateTotalPrice();
|
||||
Items.Add(newItem);
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
return newItem;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the old method for backward compatibility
|
||||
public void AddItem(Guid productId, string productName, decimal price, int quantity = 1, Guid? multiBuyId = null, string? selectedVariant = null)
|
||||
{
|
||||
var existingItem = Items.FirstOrDefault(i =>
|
||||
@ -96,7 +155,8 @@ namespace TeleBot.Models
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public Guid? MultiBuyId { get; set; } // For quantity pricing (e.g., 3 for £25)
|
||||
public string? SelectedVariant { get; set; } // For color/flavor selection
|
||||
public string? SelectedVariant { get; set; } // For single items - one variant
|
||||
public List<string> SelectedVariants { get; set; } = new(); // For multi-buys - multiple variants
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public int Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; } // For multi-buys, this is the bundle price; for regular items, it's per-unit
|
||||
|
||||
@ -92,6 +92,7 @@ namespace TeleBot.Models
|
||||
BrowsingCategories,
|
||||
ViewingProducts,
|
||||
ViewingProduct,
|
||||
SelectingVariants,
|
||||
ViewingCart,
|
||||
CheckoutFlow,
|
||||
ViewingOrders,
|
||||
|
||||
@ -298,15 +298,40 @@ namespace TeleBot.Services
|
||||
{
|
||||
var caption = $"🛍️ *{product.Name}*\n";
|
||||
caption += $"💰 *${product.Price:F2}*\n";
|
||||
|
||||
|
||||
// Show multi-buy deals if available
|
||||
if (product.MultiBuys != null && product.MultiBuys.Any(mb => mb.IsActive))
|
||||
{
|
||||
caption += "\n🏷️ *Special Offers:*\n";
|
||||
foreach (var multibuy in product.MultiBuys.Where(mb => mb.IsActive).OrderBy(mb => mb.Quantity))
|
||||
{
|
||||
caption += $" • {multibuy.Name}: ${multibuy.Price:F2} (${multibuy.PricePerUnit:F2} each)\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Show available variants
|
||||
if (product.Variants != null && product.Variants.Any(v => v.IsActive))
|
||||
{
|
||||
var variantTypes = product.Variants
|
||||
.Where(v => v.IsActive)
|
||||
.GroupBy(v => v.VariantType)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in variantTypes)
|
||||
{
|
||||
var variantNames = string.Join(", ", group.OrderBy(v => v.SortOrder).Select(v => v.Name));
|
||||
caption += $"\n🎨 *{group.Key}:* {variantNames}";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(product.Description))
|
||||
{
|
||||
var desc = product.Description.Length > 200
|
||||
? product.Description.Substring(0, 197) + "..."
|
||||
var desc = product.Description.Length > 200
|
||||
? product.Description.Substring(0, 197) + "..."
|
||||
: product.Description;
|
||||
caption += $"\n_{desc}_";
|
||||
caption += $"\n\n_{desc}_";
|
||||
}
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(product.CategoryName))
|
||||
{
|
||||
caption += $"\n\n📁 {product.CategoryName}";
|
||||
|
||||
@ -94,30 +94,42 @@ namespace TeleBot.UI
|
||||
{
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
|
||||
// Check if product has variants
|
||||
bool hasVariants = product.Variants?.Any(v => v.IsActive) == true;
|
||||
|
||||
// Show multi-buy options if available
|
||||
if (product.MultiBuys?.Any() == true)
|
||||
if (product.MultiBuys?.Any(mb => mb.IsActive) == true)
|
||||
{
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData("💰 Multi-Buy Options:", "noop")
|
||||
InlineKeyboardButton.WithCallbackData("💰 Multi-Buy Deals:", "noop")
|
||||
});
|
||||
|
||||
foreach (var multiBuy in product.MultiBuys.OrderBy(mb => mb.Quantity))
|
||||
foreach (var multiBuy in product.MultiBuys.Where(mb => mb.IsActive).OrderBy(mb => mb.Quantity))
|
||||
{
|
||||
var label = $"{multiBuy.Name} - £{multiBuy.Price:F2}";
|
||||
var label = $"{multiBuy.Name} - ${multiBuy.Price:F2}";
|
||||
if (multiBuy.Quantity > 1)
|
||||
label += $" (£{multiBuy.PricePerUnit:F2}/each)";
|
||||
label += $" (${multiBuy.PricePerUnit:F2}/each)";
|
||||
|
||||
// If has variants, need variant selection first
|
||||
var callbackData = hasVariants
|
||||
? $"selectvar:{product.Id}:{multiBuy.Quantity}:{multiBuy.Id}"
|
||||
: $"add:{product.Id}:{multiBuy.Quantity}:{multiBuy.Id}";
|
||||
|
||||
buttons.Add(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData(label, $"add:{product.Id}:{multiBuy.Quantity}:{multiBuy.Id}")
|
||||
InlineKeyboardButton.WithCallbackData(label, callbackData)
|
||||
});
|
||||
}
|
||||
|
||||
// Add regular single item option
|
||||
var singleCallbackData = hasVariants
|
||||
? $"selectvar:{product.Id}:1"
|
||||
: $"add:{product.Id}:1";
|
||||
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"🛒 Single Item - £{product.Price:F2}",
|
||||
$"add:{product.Id}:1"
|
||||
$"🛒 Single Item - ${product.Price:F2}",
|
||||
singleCallbackData
|
||||
)
|
||||
});
|
||||
}
|
||||
@ -134,14 +146,38 @@ namespace TeleBot.UI
|
||||
buttons.Add(quantityButtons.ToArray());
|
||||
|
||||
// Add to cart button
|
||||
var addCallbackData = hasVariants
|
||||
? $"selectvar:{product.Id}:{quantity}"
|
||||
: $"add:{product.Id}:{quantity}";
|
||||
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"🛒 Add to Cart - £{product.Price:F2}",
|
||||
$"add:{product.Id}:{quantity}"
|
||||
$"🛒 Add to Cart - ${product.Price * quantity:F2}",
|
||||
addCallbackData
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Show variant info if available
|
||||
if (hasVariants)
|
||||
{
|
||||
var variantTypes = product.Variants
|
||||
.Where(v => v.IsActive)
|
||||
.GroupBy(v => v.VariantType)
|
||||
.Select(g => $"{g.Key}: {g.Count()} options")
|
||||
.ToList();
|
||||
|
||||
if (variantTypes.Any())
|
||||
{
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"🎨 Variants: {string.Join(", ", variantTypes)}",
|
||||
"noop"
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
buttons.Add(new[] { InlineKeyboardButton.WithCallbackData("⬅️ Back to Products", "browse") });
|
||||
|
||||
@ -294,6 +330,117 @@ namespace TeleBot.UI
|
||||
return new InlineKeyboardMarkup(buttons);
|
||||
}
|
||||
|
||||
public static InlineKeyboardMarkup VariantSelectionMenu(Product product, int quantity, string? multiBuyId = null, List<string>? selectedVariants = null)
|
||||
{
|
||||
var buttons = new List<InlineKeyboardButton[]>();
|
||||
selectedVariants ??= new List<string>();
|
||||
|
||||
if (product.Variants?.Any(v => v.IsActive) != true)
|
||||
{
|
||||
// No variants, just add to cart
|
||||
var callbackData = multiBuyId != null
|
||||
? $"add:{product.Id}:{quantity}:{multiBuyId}"
|
||||
: $"add:{product.Id}:{quantity}";
|
||||
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData($"🛒 Add to Cart", callbackData)
|
||||
});
|
||||
buttons.Add(new[] { InlineKeyboardButton.WithCallbackData("⬅️ Back", $"product:{product.Id}") });
|
||||
return new InlineKeyboardMarkup(buttons);
|
||||
}
|
||||
|
||||
// Group variants by type
|
||||
var variantGroups = product.Variants
|
||||
.Where(v => v.IsActive)
|
||||
.GroupBy(v => v.VariantType)
|
||||
.ToList();
|
||||
|
||||
// Show selection status for multi-buy
|
||||
if (quantity > 1)
|
||||
{
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"📦 Selecting for {quantity} items ({selectedVariants.Count} selected)",
|
||||
"noop"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// For each variant type, show options
|
||||
foreach (var group in variantGroups)
|
||||
{
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData($"Select {group.Key}:", "noop")
|
||||
});
|
||||
|
||||
var variantButtons = new List<InlineKeyboardButton>();
|
||||
foreach (var variant in group.OrderBy(v => v.SortOrder))
|
||||
{
|
||||
// For multi-buy, allow multiple selections
|
||||
if (quantity > 1)
|
||||
{
|
||||
var count = selectedVariants.Count(v => v == variant.Name);
|
||||
var buttonText = count > 0 ? $"{variant.Name} ({count})" : variant.Name;
|
||||
|
||||
variantButtons.Add(InlineKeyboardButton.WithCallbackData(
|
||||
buttonText,
|
||||
$"addvariant:{product.Id}:{quantity}:{variant.Name}:{multiBuyId ?? "null"}"
|
||||
));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single item, select one variant
|
||||
var isSelected = selectedVariants.Contains(variant.Name);
|
||||
var buttonText = isSelected ? $"✅ {variant.Name}" : variant.Name;
|
||||
|
||||
variantButtons.Add(InlineKeyboardButton.WithCallbackData(
|
||||
buttonText,
|
||||
$"setvariant:{product.Id}:{variant.Name}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add variant buttons in rows of 3
|
||||
for (int i = 0; i < variantButtons.Count; i += 3)
|
||||
{
|
||||
buttons.Add(variantButtons.Skip(i).Take(3).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
// Add confirm button when selections are complete
|
||||
bool canConfirm = quantity == 1 ? selectedVariants.Count == 1 : selectedVariants.Count == quantity;
|
||||
|
||||
if (canConfirm)
|
||||
{
|
||||
var variantString = string.Join(",", selectedVariants);
|
||||
var callbackData = multiBuyId != null
|
||||
? $"confirmvar:{product.Id}:{quantity}:{multiBuyId}:{variantString}"
|
||||
: $"confirmvar:{product.Id}:{quantity}:null:{variantString}";
|
||||
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
$"✅ Confirm & Add to Cart",
|
||||
callbackData
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection button for multi-buy
|
||||
if (quantity > 1 && selectedVariants.Any())
|
||||
{
|
||||
buttons.Add(new[] {
|
||||
InlineKeyboardButton.WithCallbackData(
|
||||
"🔄 Clear Selections",
|
||||
$"selectvar:{product.Id}:{quantity}:{multiBuyId ?? "null"}"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
buttons.Add(new[] { InlineKeyboardButton.WithCallbackData("⬅️ Back", $"product:{product.Id}") });
|
||||
|
||||
return new InlineKeyboardMarkup(buttons);
|
||||
}
|
||||
|
||||
private static string GetCurrencyEmoji(string currency)
|
||||
{
|
||||
return currency.ToUpper() switch
|
||||
|
||||
Loading…
Reference in New Issue
Block a user