littleshop/TeleBot/TeleBot/Services/ProductCarouselService.cs
SysAdmin f12f35cc48 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>
2025-09-24 23:09:33 +01:00

344 lines
14 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using LittleShop.Client.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
// InputFiles namespace no longer exists in newer Telegram.Bot versions
// using Telegram.Bot.Types.InputFiles;
using Telegram.Bot.Types.ReplyMarkups;
using TeleBot.UI;
namespace TeleBot.Services
{
public interface IProductCarouselService
{
Task SendProductCarouselAsync(ITelegramBotClient botClient, long chatId, PagedResult<Product> products, string? categoryName = null, int currentPage = 1);
Task SendSingleProductWithImageAsync(ITelegramBotClient botClient, long chatId, Product product);
Task<InputFile?> GetProductImageAsync(Product product);
Task<bool> IsImageUrlValidAsync(string imageUrl);
}
public class ProductCarouselService : IProductCarouselService
{
private readonly IConfiguration _configuration;
private readonly ILogger<ProductCarouselService> _logger;
private readonly HttpClient _httpClient;
private readonly string _imageCachePath;
public ProductCarouselService(
IConfiguration configuration,
ILogger<ProductCarouselService> logger,
HttpClient httpClient)
{
_configuration = configuration;
_logger = logger;
_httpClient = httpClient;
_imageCachePath = Path.Combine(Environment.CurrentDirectory, "image_cache");
// Ensure cache directory exists
Directory.CreateDirectory(_imageCachePath);
}
public async Task SendProductCarouselAsync(ITelegramBotClient botClient, long chatId, PagedResult<Product> products, string? categoryName = null, int currentPage = 1)
{
try
{
if (!products.Items.Any())
{
await botClient.SendTextMessageAsync(
chatId,
"No products available in this category.",
replyMarkup: MenuBuilder.CategoryNavigationMenu(null)
);
return;
}
// Send products as media group (carousel) - max 10 items per group
var productBatches = products.Items.Chunk(10);
foreach (var batch in productBatches)
{
var mediaGroup = new List<InputMediaPhoto>();
var productButtons = new List<InlineKeyboardButton[]>();
foreach (var product in batch)
{
// Get product image
var image = await GetProductImageAsync(product);
if (image != null)
{
// Create photo with caption
var caption = FormatProductCaption(product);
var photo = new InputMediaPhoto(image)
{
Caption = caption,
ParseMode = Telegram.Bot.Types.Enums.ParseMode.Markdown
};
mediaGroup.Add(photo);
}
else
{
// If no image, send as text message instead
await botClient.SendTextMessageAsync(
chatId,
FormatProductCaption(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.SingleProductMenu(product.Id)
);
}
// Add button for this product
productButtons.Add(new[]
{
InlineKeyboardButton.WithCallbackData(
$"🛒 {product.Name} - ${product.Price:F2}",
$"product:{product.Id}"
)
});
}
// Send media group if we have images
if (mediaGroup.Any())
{
try
{
await botClient.SendMediaGroupAsync(chatId, mediaGroup);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send media group, falling back to individual messages");
// Fallback: send individual messages
foreach (var product in batch)
{
await SendSingleProductWithImageAsync(botClient, chatId, product);
}
}
}
// Send navigation buttons
var navigationButtons = new List<InlineKeyboardButton[]>();
// Add product buttons
navigationButtons.AddRange(productButtons);
// Add pagination if needed
if (products.TotalPages > 1)
{
var paginationButtons = new List<InlineKeyboardButton>();
if (products.HasPreviousPage)
{
paginationButtons.Add(InlineKeyboardButton.WithCallbackData(
"⬅️ Previous",
$"products:page:{currentPage - 1}"
));
}
paginationButtons.Add(InlineKeyboardButton.WithCallbackData(
$"Page {currentPage}/{products.TotalPages}",
"noop"
));
if (products.HasNextPage)
{
paginationButtons.Add(InlineKeyboardButton.WithCallbackData(
"Next ➡️",
$"products:page:{currentPage + 1}"
));
}
navigationButtons.Add(paginationButtons.ToArray());
}
// Add main navigation
navigationButtons.Add(new[]
{
InlineKeyboardButton.WithCallbackData("🛒 View Cart", "cart"),
InlineKeyboardButton.WithCallbackData("⬅️ Back to Categories", "browse")
});
navigationButtons.Add(new[]
{
InlineKeyboardButton.WithCallbackData("🏠 Main Menu", "menu")
});
var replyMarkup = new InlineKeyboardMarkup(navigationButtons);
await botClient.SendTextMessageAsync(
chatId,
$"📦 *Products in {categoryName ?? "All Categories"}*\n\n" +
$"Showing {batch.Count()} products (Page {currentPage} of {products.TotalPages})",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: replyMarkup
);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending product carousel");
// Fallback to text-only product list
await botClient.SendTextMessageAsync(
chatId,
MessageFormatter.FormatProductList(products, categoryName),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductListMenu(products, null, currentPage)
);
}
}
public async Task SendSingleProductWithImageAsync(ITelegramBotClient botClient, long chatId, Product product)
{
try
{
var image = await GetProductImageAsync(product);
if (image != null)
{
await botClient.SendPhotoAsync(
chatId,
image,
caption: FormatProductCaption(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
);
}
else
{
// Fallback to text message
await botClient.SendTextMessageAsync(
chatId,
MessageFormatter.FormatProductDetail(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending single product with image");
// Fallback to text message
await botClient.SendTextMessageAsync(
chatId,
MessageFormatter.FormatProductDetail(product),
parseMode: Telegram.Bot.Types.Enums.ParseMode.Markdown,
replyMarkup: MenuBuilder.ProductDetailMenu(product)
);
}
}
public async Task<InputFile?> GetProductImageAsync(Product product)
{
try
{
if (!product.Photos.Any())
{
return null;
}
// Get the first photo
var photo = product.Photos.First();
var imageUrl = photo.Url;
if (string.IsNullOrEmpty(imageUrl) || !await IsImageUrlValidAsync(imageUrl))
{
return null;
}
// Check if image is already cached
var cacheKey = $"{product.Id}_{photo.Id}";
var cachedPath = Path.Combine(_imageCachePath, $"{cacheKey}.jpg");
if (System.IO.File.Exists(cachedPath))
{
return InputFile.FromStream(System.IO.File.OpenRead(cachedPath), $"{product.Name}.jpg");
}
// Download and cache the image
var imageBytes = await _httpClient.GetByteArrayAsync(imageUrl);
await System.IO.File.WriteAllBytesAsync(cachedPath, imageBytes);
return InputFile.FromStream(System.IO.File.OpenRead(cachedPath), $"{product.Name}.jpg");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get product image for product {ProductId}", product.Id);
return null;
}
}
public async Task<bool> IsImageUrlValidAsync(string imageUrl)
{
try
{
if (string.IsNullOrEmpty(imageUrl))
return false;
using var request = new HttpRequestMessage(HttpMethod.Head, imageUrl);
var response = await _httpClient.SendAsync(request);
return response.IsSuccessStatusCode &&
response.Content.Headers.ContentType?.MediaType?.StartsWith("image/") == true;
}
catch
{
return false;
}
}
private string FormatProductCaption(Product product)
{
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) + "..."
: product.Description;
caption += $"\n\n_{desc}_";
}
if (!string.IsNullOrEmpty(product.CategoryName))
{
caption += $"\n\n📁 {product.CategoryName}";
}
return caption;
}
}
}