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:
2025-09-21 00:30:12 +01:00
parent 7683b7dfe5
commit 034b8facee
46 changed files with 3190 additions and 332 deletions

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "AdminOnly")]
public class ActivityController : Controller
{
private readonly IBotActivityService _activityService;
private readonly ILogger<ActivityController> _logger;
public ActivityController(IBotActivityService activityService, ILogger<ActivityController> logger)
{
_activityService = activityService;
_logger = logger;
}
// GET: /Admin/Activity
public IActionResult Index()
{
return View();
}
// GET: /Admin/Activity/Live
public IActionResult Live()
{
return View();
}
// API endpoint for initial data load
[HttpGet]
public async Task<IActionResult> GetSummary()
{
var summary = await _activityService.GetLiveActivitySummaryAsync();
return Json(summary);
}
// API endpoint for activity stats
[HttpGet]
public async Task<IActionResult> GetStats(int hoursBack = 24)
{
var stats = await _activityService.GetActivityTypeStatsAsync(hoursBack);
return Json(stats);
}
// API endpoint for recent activities
[HttpGet]
public async Task<IActionResult> GetRecent(int minutesBack = 5)
{
var activities = await _activityService.GetRecentActivitiesAsync(minutesBack);
return Json(activities);
}
}

View File

@@ -35,7 +35,8 @@ public class DashboardController : Controller
ViewData["TotalRevenue"] = orders.Where(o => o.PaidAt.HasValue).Sum(o => o.TotalAmount).ToString("F2");
// Enhanced metrics
ViewData["TotalVariations"] = products.Sum(p => p.Variations.Count);
ViewData["TotalMultiBuys"] = products.Sum(p => p.MultiBuys.Count);
ViewData["TotalVariants"] = products.Sum(p => p.Variants.Count);
ViewData["PendingOrders"] = orders.Count(o => o.Status == LittleShop.Enums.OrderStatus.PendingPayment);
ViewData["ShippedOrders"] = orders.Count(o => o.Status == LittleShop.Enums.OrderStatus.Shipped);
ViewData["TotalStock"] = products.Sum(p => p.StockQuantity);

View File

@@ -156,7 +156,7 @@ public class ProductsController : Controller
return NotFound();
ViewData["Product"] = product;
var variations = await _productService.GetProductVariationsAsync(id);
var variations = await _productService.GetProductMultiBuysAsync(id);
return View(variations);
}
@@ -174,15 +174,15 @@ public class ProductsController : Controller
ViewData["Product"] = product;
// Get existing quantities to help user avoid duplicates
var existingQuantities = await _productService.GetProductVariationsAsync(productId);
var existingQuantities = await _productService.GetProductMultiBuysAsync(productId);
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
return View(new CreateProductVariationDto { ProductId = productId });
return View(new CreateProductMultiBuyDto { ProductId = productId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateVariation(CreateProductVariationDto model)
public async Task<IActionResult> CreateVariation(CreateProductMultiBuyDto model)
{
// Debug form data
Console.WriteLine("=== FORM DEBUG ===");
@@ -210,7 +210,7 @@ public class ProductsController : Controller
ViewData["Product"] = product;
// Re-populate existing quantities for error display
var existingQuantities = await _productService.GetProductVariationsAsync(model.ProductId);
var existingQuantities = await _productService.GetProductMultiBuysAsync(model.ProductId);
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
return View(model);
@@ -218,7 +218,7 @@ public class ProductsController : Controller
try
{
await _productService.CreateProductVariationAsync(model);
await _productService.CreateProductMultiBuyAsync(model);
return RedirectToAction(nameof(Variations), new { id = model.ProductId });
}
catch (ArgumentException ex)
@@ -237,7 +237,7 @@ public class ProductsController : Controller
ViewData["Product"] = product;
// Re-populate existing quantities for error display
var existingQuantities = await _productService.GetProductVariationsAsync(model.ProductId);
var existingQuantities = await _productService.GetProductMultiBuysAsync(model.ProductId);
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
return View(model);
@@ -246,14 +246,14 @@ public class ProductsController : Controller
public async Task<IActionResult> EditVariation(Guid id)
{
var variation = await _productService.GetProductVariationByIdAsync(id);
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
if (variation == null)
return NotFound();
var product = await _productService.GetProductByIdAsync(variation.ProductId);
ViewData["Product"] = product;
var model = new UpdateProductVariationDto
var model = new UpdateProductMultiBuyDto
{
Name = variation.Name,
Description = variation.Description,
@@ -268,21 +268,21 @@ public class ProductsController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditVariation(Guid id, UpdateProductVariationDto model)
public async Task<IActionResult> EditVariation(Guid id, UpdateProductMultiBuyDto model)
{
if (!ModelState.IsValid)
{
var variation = await _productService.GetProductVariationByIdAsync(id);
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
var product = await _productService.GetProductByIdAsync(variation!.ProductId);
ViewData["Product"] = product;
return View(model);
}
var success = await _productService.UpdateProductVariationAsync(id, model);
var success = await _productService.UpdateProductMultiBuyAsync(id, model);
if (!success)
return NotFound();
var variationToRedirect = await _productService.GetProductVariationByIdAsync(id);
var variationToRedirect = await _productService.GetProductMultiBuyByIdAsync(id);
return RedirectToAction(nameof(Variations), new { id = variationToRedirect!.ProductId });
}
@@ -290,11 +290,11 @@ public class ProductsController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteVariation(Guid id)
{
var variation = await _productService.GetProductVariationByIdAsync(id);
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
if (variation == null)
return NotFound();
await _productService.DeleteProductVariationAsync(id);
await _productService.DeleteProductMultiBuyAsync(id);
return RedirectToAction(nameof(Variations), new { id = variation.ProductId });
}

View File

@@ -0,0 +1,317 @@
@{
ViewData["Title"] = "Live Bot Activity";
}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<h2><i class="bi bi-activity"></i> Live Bot Activity Monitor</h2>
</div>
</div>
<!-- Live Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">Active Users</h5>
<h2 class="display-4" id="activeUsers">0</h2>
<small>Right now</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-info">
<div class="card-body">
<h5 class="card-title">Product Views</h5>
<h2 class="display-4" id="productViews">0</h2>
<small>Last 5 minutes</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-warning">
<div class="card-body">
<h5 class="card-title">Active Carts</h5>
<h2 class="display-4" id="activeCarts">0</h2>
<small>With items</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Cart Value</h5>
<h2 class="display-4">£<span id="cartValue">0</span></h2>
<small>Total in carts</small>
</div>
</div>
</div>
</div>
<!-- Active Users List -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-people-fill"></i> Currently Active Users</h5>
</div>
<div class="card-body">
<div id="activeUsersList" class="d-flex flex-wrap gap-2">
<!-- User badges will be inserted here -->
</div>
</div>
</div>
</div>
</div>
<!-- Live Activity Feed -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-lightning-fill"></i> Real-Time Activity Feed</h5>
<span class="badge bg-success pulse" id="connectionStatus">
<i class="bi bi-wifi"></i> Connected
</span>
</div>
<div class="card-body">
<div id="activityFeed" class="activity-feed" style="max-height: 500px; overflow-y: auto;">
<!-- Activities will be inserted here -->
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.activity-item {
padding: 10px;
border-left: 3px solid #007bff;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 4px;
animation: slideIn 0.3s ease;
}
.activity-item.new {
animation: highlight 1s ease;
}
@@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@@keyframes highlight {
0% {
background-color: #fff3cd;
}
100% {
background-color: #f8f9fa;
}
}
@@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.pulse {
animation: pulse 2s infinite;
}
.user-badge {
display: inline-block;
padding: 5px 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
animation: fadeIn 0.5s ease;
}
@@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.activity-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin-right: 8px;
}
.type-viewproduct {
background-color: #e3f2fd;
color: #1976d2;
}
.type-addtocart {
background-color: #fff3e0;
color: #f57c00;
}
.type-checkout {
background-color: #e8f5e9;
color: #388e3c;
}
.type-browse {
background-color: #f3e5f5;
color: #7b1fa2;
}
</style>
@section Scripts {
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
<script>
const connection = new signalR.HubConnectionBuilder()
.withUrl("/activityHub")
.configureLogging(signalR.LogLevel.Information)
.build();
let activityCount = 0;
const maxActivities = 50;
// Connection status handling
connection.onclose(() => {
document.getElementById('connectionStatus').innerHTML = '<i class="bi bi-wifi-off"></i> Disconnected';
document.getElementById('connectionStatus').classList.remove('bg-success');
document.getElementById('connectionStatus').classList.add('bg-danger');
setTimeout(() => startConnection(), 5000);
});
// Handle initial summary
connection.on("InitialSummary", (summary) => {
updateStats(summary);
updateActivityFeed(summary.recentActivities);
});
// Handle new activity
connection.on("NewActivity", (activity) => {
addActivity(activity, true);
});
// Handle summary updates
connection.on("SummaryUpdate", (summary) => {
updateStats(summary);
});
function updateStats(summary) {
document.getElementById('activeUsers').textContent = summary.activeUsers;
document.getElementById('productViews').textContent = summary.productViewsLast5Min;
document.getElementById('activeCarts').textContent = summary.cartsActiveNow;
document.getElementById('cartValue').textContent = summary.totalValueInCartsNow.toFixed(2);
// Update active users list
const usersList = document.getElementById('activeUsersList');
usersList.innerHTML = '';
summary.activeUserNames.forEach(name => {
const badge = document.createElement('span');
badge.className = 'user-badge';
badge.innerHTML = `<i class="bi bi-person-circle"></i> ${name}`;
usersList.appendChild(badge);
});
}
function updateActivityFeed(activities) {
const feed = document.getElementById('activityFeed');
feed.innerHTML = '';
activities.forEach(activity => addActivity(activity, false));
}
function addActivity(activity, isNew) {
const feed = document.getElementById('activityFeed');
// Create activity element
const item = document.createElement('div');
item.className = 'activity-item' + (isNew ? ' new' : '');
const typeClass = 'type-' + activity.activityType.toLowerCase().replace(/\s+/g, '');
const typeBadge = `<span class="activity-type-badge ${typeClass}">${activity.activityType}</span>`;
const time = new Date(activity.timestamp).toLocaleTimeString();
let icon = getActivityIcon(activity.activityType);
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
${typeBadge}
<strong>${icon} ${activity.userDisplayName}</strong>
<span class="text-muted">- ${activity.activityDescription}</span>
${activity.productName ? `<br><small class="text-info">Product: ${activity.productName}</small>` : ''}
${activity.value ? `<br><small class="text-success">Value: £${activity.value.toFixed(2)}</small>` : ''}
</div>
<small class="text-muted">${time}</small>
</div>
`;
// Add to top of feed
feed.insertBefore(item, feed.firstChild);
// Limit number of activities shown
activityCount++;
if (activityCount > maxActivities) {
feed.removeChild(feed.lastChild);
activityCount--;
}
}
function getActivityIcon(type) {
const icons = {
'ViewProduct': '👁️',
'AddToCart': '🛒',
'Checkout': '💳',
'Browse': '🔍',
'RemoveFromCart': '❌',
'UpdateCart': '✏️',
'OrderComplete': '✅',
'StartSession': '👋',
'EndSession': '👋'
};
return icons[type] || '📍';
}
async function startConnection() {
try {
await connection.start();
document.getElementById('connectionStatus').innerHTML = '<i class="bi bi-wifi"></i> Connected';
document.getElementById('connectionStatus').classList.remove('bg-danger');
document.getElementById('connectionStatus').classList.add('bg-success');
} catch (err) {
console.error(err);
setTimeout(() => startConnection(), 5000);
}
}
// Start the connection
startConnection();
</script>
}

View File

@@ -129,9 +129,9 @@
@foreach (var item in order.Items.Take(2))
{
<div>@item.Quantity× @item.ProductName</div>
@if (!string.IsNullOrEmpty(item.ProductVariationName))
@if (!string.IsNullOrEmpty(item.ProductMultiBuyName))
{
<small class="text-muted">(@item.ProductVariationName)</small>
<small class="text-muted">(@item.ProductMultiBuyName)</small>
}
}
@if (order.Items.Count > 2)
@@ -276,9 +276,9 @@
{
var firstItem = order.Items.First();
<text> - @firstItem.Quantity x @firstItem.ProductName</text>
@if (!string.IsNullOrEmpty(firstItem.ProductVariationName))
@if (!string.IsNullOrEmpty(firstItem.ProductMultiBuyName))
{
<span class="text-muted">(@firstItem.ProductVariationName)</span>
<span class="text-muted">(@firstItem.ProductMultiBuyName)</span>
}
@if (order.Items.Count > 1)
{

View File

@@ -1,4 +1,4 @@
@model LittleShop.DTOs.CreateProductVariationDto
@model LittleShop.DTOs.CreateProductMultiBuyDto
@{
ViewData["Title"] = "Create Product Variation";

View File

@@ -1,4 +1,4 @@
@model LittleShop.DTOs.UpdateProductVariationDto
@model LittleShop.DTOs.UpdateProductMultiBuyDto
@{
ViewData["Title"] = "Edit Product Variation";

View File

@@ -91,9 +91,9 @@
@product.StockQuantity
</td>
<td>
@if (product.Variations.Any())
@if (product.MultiBuys.Any())
{
<span class="badge bg-info">@product.Variations.Count variations</span>
<span class="badge bg-info">@product.MultiBuys.Count variations</span>
}
else
{

View File

@@ -69,11 +69,15 @@
<strong>£@product.Price</strong>
</td>
<td>
@if (product.Variations.Any())
@if (product.MultiBuys.Any())
{
<span class="badge bg-info">@product.Variations.Count() variations</span>
<span class="badge bg-info">@product.MultiBuys.Count() multi-buys</span>
}
else
@if (product.Variants.Any())
{
<span class="badge bg-success">@product.Variants.Count() variants</span>
}
@if (!product.MultiBuys.Any() && !product.Variants.Any())
{
<span class="text-muted">None</span>
}

View File

@@ -1,4 +1,4 @@
@model IEnumerable<LittleShop.DTOs.ProductVariationDto>
@model IEnumerable<LittleShop.DTOs.ProductMultiBuyDto>
@{
ViewData["Title"] = "Product Variations";