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>
344 lines
14 KiB
C#
344 lines
14 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|