Implement product multi-buys and variants system
Major restructuring of product variations: - Renamed ProductVariation to ProductMultiBuy for quantity-based pricing (e.g., "3 for £25") - Added new ProductVariant model for string-based options (colors, flavors) - Complete separation of multi-buy pricing from variant selection Features implemented: - Multi-buy deals with automatic price-per-unit calculation - Product variants for colors/flavors/sizes with stock tracking - TeleBot checkout supports both multi-buys and variant selection - Shopping cart correctly calculates multi-buy bundle prices - Order system tracks selected variants and multi-buy choices - Real-time bot activity monitoring with SignalR - Public bot directory page with QR codes for Telegram launch - Admin dashboard shows multi-buy and variant metrics Technical changes: - Updated all DTOs, services, and controllers - Fixed cart total calculation for multi-buy bundles - Comprehensive test coverage for new functionality - All existing tests passing with new features Database changes: - Migrated ProductVariations to ProductMultiBuys - Added ProductVariants table - Updated OrderItems to track variants 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
152
LittleShop/Controllers/BotDirectoryController.cs
Normal file
152
LittleShop/Controllers/BotDirectoryController.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using QRCoder;
|
||||
using LittleShop.Data;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Enums;
|
||||
|
||||
namespace LittleShop.Controllers;
|
||||
|
||||
public class BotDirectoryController : Controller
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly ILogger<BotDirectoryController> _logger;
|
||||
|
||||
public BotDirectoryController(LittleShopContext context, ILogger<BotDirectoryController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET: /bots
|
||||
[HttpGet("/bots")]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var bots = await _context.Bots
|
||||
.Where(b => b.Status == BotStatus.Active && b.IsActive)
|
||||
.OrderBy(b => b.Name)
|
||||
.Select(b => new BotDirectoryDto
|
||||
{
|
||||
Id = b.Id,
|
||||
Name = b.Name,
|
||||
Description = b.Description,
|
||||
Type = b.Type.ToString(),
|
||||
PersonalityName = b.PersonalityName,
|
||||
TelegramUsername = b.Settings.Contains("\"telegram_username\":")
|
||||
? ExtractTelegramUsername(b.Settings)
|
||||
: b.PlatformUsername,
|
||||
LastSeenAt = b.LastSeenAt,
|
||||
CreatedAt = b.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return View(bots);
|
||||
}
|
||||
|
||||
// GET: /bots/qr/{id}
|
||||
[HttpGet("/bots/qr/{id}")]
|
||||
public async Task<IActionResult> GetQRCode(Guid id)
|
||||
{
|
||||
var bot = await _context.Bots
|
||||
.Where(b => b.Id == id && b.Status == BotStatus.Active)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (bot == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Extract Telegram username from settings JSON or use platform username
|
||||
string? telegramUsername = ExtractTelegramUsername(bot.Settings) ?? bot.PlatformUsername;
|
||||
|
||||
if (string.IsNullOrEmpty(telegramUsername))
|
||||
{
|
||||
return NotFound("Bot does not have a Telegram username configured");
|
||||
}
|
||||
|
||||
// Generate Telegram deep link
|
||||
string telegramUrl = $"https://t.me/{telegramUsername}";
|
||||
|
||||
// Generate QR code
|
||||
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
|
||||
{
|
||||
QRCodeData qrCodeData = qrGenerator.CreateQrCode(telegramUrl, QRCodeGenerator.ECCLevel.Q);
|
||||
using (PngByteQRCode qrCode = new PngByteQRCode(qrCodeData))
|
||||
{
|
||||
byte[] qrCodeImage = qrCode.GetGraphic(20);
|
||||
return File(qrCodeImage, "image/png");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractTelegramUsername(string settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = System.Text.Json.JsonDocument.Parse(settings);
|
||||
if (json.RootElement.TryGetProperty("telegram_username", out var username))
|
||||
{
|
||||
return username.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// JSON parsing failed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public class BotDirectoryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string PersonalityName { get; set; } = string.Empty;
|
||||
public string? TelegramUsername { get; set; }
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public string GetBadgeColor()
|
||||
{
|
||||
return Type.ToLower() switch
|
||||
{
|
||||
"sales" => "primary",
|
||||
"support" => "success",
|
||||
"marketing" => "warning",
|
||||
"technical" => "info",
|
||||
_ => "secondary"
|
||||
};
|
||||
}
|
||||
|
||||
public string GetStatusBadge()
|
||||
{
|
||||
if (!LastSeenAt.HasValue)
|
||||
return "Never Connected";
|
||||
|
||||
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
|
||||
if (timeSinceLastSeen.TotalMinutes < 5)
|
||||
return "Online";
|
||||
else if (timeSinceLastSeen.TotalHours < 1)
|
||||
return "Recently Active";
|
||||
else if (timeSinceLastSeen.TotalDays < 1)
|
||||
return "Active Today";
|
||||
else
|
||||
return $"Last seen {timeSinceLastSeen.Days} days ago";
|
||||
}
|
||||
|
||||
public string GetStatusColor()
|
||||
{
|
||||
if (!LastSeenAt.HasValue)
|
||||
return "secondary";
|
||||
|
||||
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
|
||||
if (timeSinceLastSeen.TotalMinutes < 5)
|
||||
return "success";
|
||||
else if (timeSinceLastSeen.TotalHours < 1)
|
||||
return "info";
|
||||
else
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ public class DevController : ControllerBase
|
||||
_productService = productService;
|
||||
}
|
||||
|
||||
[HttpPost("variations")]
|
||||
public async Task<ActionResult<ProductVariationDto>> CreateVariationForDev(CreateProductVariationDto createVariationDto)
|
||||
[HttpPost("multibuys")]
|
||||
public async Task<ActionResult<ProductMultiBuyDto>> CreateMultiBuyForDev(CreateProductMultiBuyDto createMultiBuyDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
|
||||
return CreatedAtAction("GetProductVariation", "ProductVariations", new { id = variation.Id }, variation);
|
||||
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
|
||||
return CreatedAtAction("GetProductMultiBuy", "ProductMultiBuys", new { id = multiBuy.Id }, multiBuy);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -37,7 +37,8 @@ public class DevController : ControllerBase
|
||||
id = p.Id,
|
||||
name = p.Name,
|
||||
price = p.Price,
|
||||
variationCount = p.Variations.Count
|
||||
multiBuyCount = p.MultiBuys.Count,
|
||||
variantCount = p.Variants.Count
|
||||
});
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
81
LittleShop/Controllers/ProductMultiBuysController.cs
Normal file
81
LittleShop/Controllers/ProductMultiBuysController.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Services;
|
||||
|
||||
namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
public class ProductMultiBuysController : ControllerBase
|
||||
{
|
||||
private readonly IProductService _productService;
|
||||
|
||||
public ProductMultiBuysController(IProductService productService)
|
||||
{
|
||||
_productService = productService;
|
||||
}
|
||||
|
||||
[HttpGet("product/{productId}")]
|
||||
public async Task<ActionResult<IEnumerable<ProductMultiBuyDto>>> GetProductMultiBuys(Guid productId)
|
||||
{
|
||||
var multiBuys = await _productService.GetProductMultiBuysAsync(productId);
|
||||
return Ok(multiBuys);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductMultiBuyDto>> GetProductMultiBuy(Guid id)
|
||||
{
|
||||
var multiBuy = await _productService.GetProductMultiBuyByIdAsync(id);
|
||||
if (multiBuy == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(multiBuy);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<ProductMultiBuyDto>> CreateProductMultiBuy(CreateProductMultiBuyDto createMultiBuyDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
|
||||
return CreatedAtAction(nameof(GetProductMultiBuy), new { id = multiBuy.Id }, multiBuy);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdateProductMultiBuy(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productService.UpdateProductMultiBuyAsync(id, updateMultiBuyDto);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeleteProductMultiBuy(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productService.DeleteProductMultiBuyAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
LittleShop/Controllers/ProductVariantsController.cs
Normal file
81
LittleShop/Controllers/ProductVariantsController.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Services;
|
||||
|
||||
namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
public class ProductVariantsController : ControllerBase
|
||||
{
|
||||
private readonly IProductService _productService;
|
||||
|
||||
public ProductVariantsController(IProductService productService)
|
||||
{
|
||||
_productService = productService;
|
||||
}
|
||||
|
||||
[HttpGet("product/{productId}")]
|
||||
public async Task<ActionResult<IEnumerable<ProductVariantDto>>> GetProductVariants(Guid productId)
|
||||
{
|
||||
var variants = await _productService.GetProductVariantsAsync(productId);
|
||||
return Ok(variants);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductVariantDto>> GetProductVariant(Guid id)
|
||||
{
|
||||
var variant = await _productService.GetProductVariantByIdAsync(id);
|
||||
if (variant == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(variant);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<ProductVariantDto>> CreateProductVariant(CreateProductVariantDto createVariantDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var variant = await _productService.CreateProductVariantAsync(createVariantDto);
|
||||
return CreatedAtAction(nameof(GetProductVariant), new { id = variant.Id }, variant);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdateProductVariant(Guid id, UpdateProductVariantDto updateVariantDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productService.UpdateProductVariantAsync(id, updateVariantDto);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeleteProductVariant(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productService.DeleteProductVariantAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.DTOs;
|
||||
using LittleShop.Services;
|
||||
|
||||
namespace LittleShop.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||
public class ProductVariationsController : ControllerBase
|
||||
{
|
||||
private readonly IProductService _productService;
|
||||
|
||||
public ProductVariationsController(IProductService productService)
|
||||
{
|
||||
_productService = productService;
|
||||
}
|
||||
|
||||
[HttpGet("product/{productId}")]
|
||||
public async Task<ActionResult<IEnumerable<ProductVariationDto>>> GetProductVariations(Guid productId)
|
||||
{
|
||||
var variations = await _productService.GetProductVariationsAsync(productId);
|
||||
return Ok(variations);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductVariationDto>> GetProductVariation(Guid id)
|
||||
{
|
||||
var variation = await _productService.GetProductVariationByIdAsync(id);
|
||||
if (variation == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(variation);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<ActionResult<ProductVariationDto>> CreateProductVariation(CreateProductVariationDto createVariationDto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
|
||||
return CreatedAtAction(nameof(GetProductVariation), new { id = variation.Id }, variation);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> UpdateProductVariation(Guid id, UpdateProductVariationDto updateVariationDto)
|
||||
{
|
||||
var success = await _productService.UpdateProductVariationAsync(id, updateVariationDto);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> DeleteProductVariation(Guid id)
|
||||
{
|
||||
var success = await _productService.DeleteProductVariationAsync(id);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user