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:
SysAdmin 2025-09-24 23:09:33 +01:00
parent 5530f9e4f5
commit f12f35cc48
5 changed files with 383 additions and 17 deletions

View File

@ -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");
}
}
}

View File

@ -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

View File

@ -92,6 +92,7 @@ namespace TeleBot.Models
BrowsingCategories,
ViewingProducts,
ViewingProduct,
SelectingVariants,
ViewingCart,
CheckoutFlow,
ViewingOrders,

View File

@ -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}";

View File

@ -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