Add product variants system and live bot activity dashboard

FEATURES IMPLEMENTED:
1. Product Multi-Buys (renamed from Variations for clarity)
   - Quantity-based pricing deals (e.g., 1 for £10, 3 for £25)
   - Renamed UI to "Multi-Buys" with tags icon for better understanding

2. Product Variants (NEW)
   - Support for colors, flavors, sizes, and other product options
   - Separate from multi-buys - these are the actual variations customers choose
   - Admin UI for managing variants per product
   - Updated OrderItem model to store selected variants as JSON array

3. Live Bot Activity Dashboard
   - Real-time view of customer interactions across all bots
   - Shows active users (last 5 minutes)
   - Live activity feed with user actions
   - Statistics including today's activities and trending products
   - Auto-refreshes every 5 seconds for live updates
   - Accessible via "Live Activity" menu item

TECHNICAL CHANGES:
- Modified OrderItem.SelectedVariant to SelectedVariants (JSON array)
- Added BotActivityController for dashboard endpoints
- Created views for variant management (ProductVariants, CreateVariant, EditVariant)
- Updated Products Index to show separate buttons for Multi-Buys and Variants
- Fixed duplicate DTO definitions (removed duplicate files)
- Fixed ApplicationDbContext reference (changed to LittleShopContext)

UI IMPROVEMENTS:
- Multi-Buys: Tags icon, labeled as "pricing deals"
- Variants: Palette icon, labeled as "colors/flavors"
- Live dashboard with animated activity feed
- Visual indicators for active users and trending products
- Mobile-responsive dashboard layout

This update provides the foundation for:
- Customers selecting variants during checkout
- Real-time monitoring of bot usage patterns
- Better understanding of popular products and user behavior

Next steps: Implement variant selection in TeleBot checkout flow

🤖 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:00:20 +01:00
parent 94b6bd421d
commit 5530f9e4f5
10 changed files with 910 additions and 9 deletions

View File

@ -0,0 +1,142 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.Models;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "AdminOnly")]
public class BotActivityController : Controller
{
private readonly LittleShopContext _context;
public BotActivityController(LittleShopContext context)
{
_context = context;
}
public IActionResult LiveView()
{
return View();
}
[HttpGet]
public async Task<IActionResult> GetRecentActivities(int count = 50)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Include(a => a.Product)
.OrderByDescending(a => a.Timestamp)
.Take(count)
.Select(a => new
{
a.Id,
a.UserDisplayName,
a.ActivityType,
a.ActivityDescription,
a.ProductName,
a.CategoryName,
a.Value,
a.Quantity,
a.Platform,
a.Location,
a.Timestamp,
BotName = a.Bot.Name,
TimeAgo = GetTimeAgo(a.Timestamp)
})
.ToListAsync();
return Json(activities);
}
[HttpGet]
public async Task<IActionResult> GetActiveUsers()
{
var cutoff = DateTime.UtcNow.AddMinutes(-5); // Users active in last 5 minutes
var activeUsers = await _context.BotActivities
.Where(a => a.Timestamp >= cutoff)
.GroupBy(a => new { a.SessionIdentifier, a.UserDisplayName })
.Select(g => new
{
SessionId = g.Key.SessionIdentifier,
UserName = g.Key.UserDisplayName,
ActivityCount = g.Count(),
LastActivity = g.Max(a => a.Timestamp),
LastAction = g.OrderByDescending(a => a.Timestamp).FirstOrDefault()!.ActivityDescription,
TotalValue = g.Sum(a => a.Value ?? 0)
})
.OrderByDescending(u => u.LastActivity)
.ToListAsync();
return Json(activeUsers);
}
[HttpGet]
public async Task<IActionResult> GetStatistics()
{
var today = DateTime.UtcNow.Date;
var yesterday = today.AddDays(-1);
var stats = new
{
TodayActivities = await _context.BotActivities
.Where(a => a.Timestamp >= today)
.CountAsync(),
YesterdayActivities = await _context.BotActivities
.Where(a => a.Timestamp >= yesterday && a.Timestamp < today)
.CountAsync(),
UniqueUsersToday = await _context.BotActivities
.Where(a => a.Timestamp >= today)
.Select(a => a.SessionIdentifier)
.Distinct()
.CountAsync(),
PopularProducts = await _context.BotActivities
.Where(a => a.ProductId != null && a.Timestamp >= today.AddDays(-7))
.GroupBy(a => new { a.ProductId, a.ProductName })
.Select(g => new
{
ProductName = g.Key.ProductName,
ViewCount = g.Count(a => a.ActivityType == "ViewProduct"),
AddToCartCount = g.Count(a => a.ActivityType == "AddToCart")
})
.OrderByDescending(p => p.ViewCount + p.AddToCartCount)
.Take(5)
.ToListAsync(),
ActivityByHour = await _context.BotActivities
.Where(a => a.Timestamp >= today)
.GroupBy(a => a.Timestamp.Hour)
.Select(g => new
{
Hour = g.Key,
Count = g.Count()
})
.OrderBy(h => h.Hour)
.ToListAsync()
};
return Json(stats);
}
private static string GetTimeAgo(DateTime timestamp)
{
var span = DateTime.UtcNow - timestamp;
if (span.TotalSeconds < 60)
return "just now";
if (span.TotalMinutes < 60)
return $"{(int)span.TotalMinutes}m ago";
if (span.TotalHours < 24)
return $"{(int)span.TotalHours}h ago";
if (span.TotalDays < 7)
return $"{(int)span.TotalDays}d ago";
return timestamp.ToString("MMM dd");
}
}

View File

@ -298,6 +298,93 @@ public class ProductsController : Controller
return RedirectToAction(nameof(Variations), new { id = variation.ProductId });
}
// Product Variants (Colors/Flavors)
public async Task<IActionResult> Variants(Guid id)
{
var product = await _productService.GetProductByIdAsync(id);
if (product == null)
return NotFound();
ViewData["Product"] = product;
var variants = await _productService.GetProductVariantsAsync(id);
return View("ProductVariants", variants);
}
public async Task<IActionResult> CreateVariant(Guid productId)
{
var product = await _productService.GetProductByIdAsync(productId);
if (product == null)
return NotFound();
ViewData["Product"] = product;
return View("CreateVariant", new CreateProductVariantDto { ProductId = productId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateVariant(CreateProductVariantDto model)
{
if (!ModelState.IsValid)
{
var product = await _productService.GetProductByIdAsync(model.ProductId);
ViewData["Product"] = product;
return View("CreateVariant", model);
}
await _productService.CreateProductVariantAsync(model);
return RedirectToAction(nameof(Variants), new { id = model.ProductId });
}
public async Task<IActionResult> EditVariant(Guid id)
{
var variant = await _productService.GetProductVariantByIdAsync(id);
if (variant == null)
return NotFound();
var product = await _productService.GetProductByIdAsync(variant.ProductId);
ViewData["Product"] = product;
var model = new UpdateProductVariantDto
{
Name = variant.Name,
VariantType = variant.VariantType,
StockLevel = variant.StockLevel,
SortOrder = variant.SortOrder,
IsActive = variant.IsActive
};
return View("EditVariant", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditVariant(Guid id, UpdateProductVariantDto model)
{
if (!ModelState.IsValid)
{
var variant = await _productService.GetProductVariantByIdAsync(id);
var product = await _productService.GetProductByIdAsync(variant!.ProductId);
ViewData["Product"] = product;
return View("EditVariant", model);
}
await _productService.UpdateProductVariantAsync(id, model);
var variantToRedirect = await _productService.GetProductVariantByIdAsync(id);
return RedirectToAction(nameof(Variants), new { id = variantToRedirect!.ProductId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteVariant(Guid id)
{
var variant = await _productService.GetProductVariantByIdAsync(id);
if (variant == null)
return NotFound();
await _productService.DeleteProductVariantAsync(id);
return RedirectToAction(nameof(Variants), new { id = variant.ProductId });
}
// Product Import/Export
public IActionResult Import()
{

View File

@ -0,0 +1,362 @@
@{
ViewData["Title"] = "Live Bot Activity";
Layout = "_Layout";
}
<style>
.activity-feed {
max-height: 600px;
overflow-y: auto;
}
.activity-item {
transition: all 0.3s ease;
border-left: 3px solid transparent;
}
.activity-item:hover {
background-color: rgba(0, 123, 255, 0.05);
border-left-color: #007bff;
}
.activity-item.new {
animation: slideIn 0.5s ease;
background-color: rgba(40, 167, 69, 0.1);
}
@@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.user-bubble {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 0.85rem;
}
.stats-card {
transition: transform 0.2s;
}
.stats-card:hover {
transform: translateY(-5px);
}
.activity-type-badge {
font-size: 0.75rem;
padding: 2px 8px;
}
.pulse {
animation: pulse 2s infinite;
}
@@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(40, 167, 69, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(40, 167, 69, 0);
}
}
</style>
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-satellite-dish"></i> Live Bot Activity</h1>
<p class="text-muted">Real-time view of customer interactions across all bots</p>
</div>
<div class="col-auto">
<div class="d-flex align-items-center">
<span class="badge bg-success pulse me-2">LIVE</span>
<small class="text-muted">Updates every 5 seconds</small>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stats-card border-primary">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted">Active Users</h6>
<h2 class="mb-0" id="activeUsersCount">0</h2>
</div>
<div class="text-primary">
<i class="fas fa-users fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card border-success">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted">Today's Activities</h6>
<h2 class="mb-0" id="todayActivitiesCount">0</h2>
</div>
<div class="text-success">
<i class="fas fa-chart-line fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card border-info">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted">Unique Visitors</h6>
<h2 class="mb-0" id="uniqueVisitorsCount">0</h2>
</div>
<div class="text-info">
<i class="fas fa-fingerprint fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted">Cart Actions</h6>
<h2 class="mb-0" id="cartActionsCount">0</h2>
</div>
<div class="text-warning">
<i class="fas fa-shopping-cart fa-2x"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Active Users Panel -->
<div class="col-md-4">
<div class="card">
<div class="card-header bg-primary text-white">
<i class="fas fa-user-clock"></i> Active Users (Last 5 min)
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<div class="list-group list-group-flush" id="activeUsersList">
<!-- Active users will be populated here -->
</div>
</div>
</div>
<!-- Popular Products -->
<div class="card mt-3">
<div class="card-header bg-success text-white">
<i class="fas fa-fire"></i> Trending Products (7 days)
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush" id="popularProductsList">
<!-- Popular products will be populated here -->
</div>
</div>
</div>
</div>
<!-- Activity Feed -->
<div class="col-md-8">
<div class="card">
<div class="card-header bg-dark text-white">
<i class="fas fa-stream"></i> Live Activity Feed
</div>
<div class="card-body activity-feed p-0" id="activityFeed">
<!-- Activities will be populated here -->
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
let lastActivityId = '';
function getActivityIcon(type) {
const icons = {
'Browse': 'fa-eye',
'ViewProduct': 'fa-search',
'AddToCart': 'fa-cart-plus',
'RemoveFromCart': 'fa-cart-arrow-down',
'Checkout': 'fa-cash-register',
'Search': 'fa-search',
'Register': 'fa-user-plus',
'Login': 'fa-sign-in-alt'
};
return icons[type] || 'fa-circle';
}
function getActivityColor(type) {
const colors = {
'Browse': 'info',
'ViewProduct': 'primary',
'AddToCart': 'success',
'RemoveFromCart': 'warning',
'Checkout': 'danger',
'Search': 'secondary'
};
return colors[type] || 'secondary';
}
function updateActivities() {
// Get recent activities
$.get('@Url.Action("GetRecentActivities")', { count: 30 }, function(activities) {
const feed = $('#activityFeed');
activities.forEach(function(activity) {
const existingItem = $(`#activity-${activity.id}`);
if (existingItem.length === 0) {
const isNew = lastActivityId && activity.id !== lastActivityId;
const html = `
<div id="activity-${activity.id}" class="activity-item ${isNew ? 'new' : ''} p-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex">
<div class="me-3 text-${getActivityColor(activity.activityType)}">
<i class="fas ${getActivityIcon(activity.activityType)} fa-lg"></i>
</div>
<div>
<div class="mb-1">
<span class="user-bubble">${activity.userDisplayName}</span>
<span class="activity-type-badge badge bg-${getActivityColor(activity.activityType)} ms-2">
${activity.activityType}
</span>
</div>
<div class="text-dark">
${activity.activityDescription}
</div>
${activity.productName ? `
<div class="text-muted small mt-1">
<i class="fas fa-box"></i> ${activity.productName}
${activity.value ? ` - £${activity.value.toFixed(2)}` : ''}
</div>
` : ''}
</div>
</div>
<div class="text-muted small">
${activity.timeAgo}
</div>
</div>
</div>
`;
feed.prepend(html);
// Limit feed to 30 items
while (feed.children().length > 30) {
feed.children().last().remove();
}
}
});
if (activities.length > 0) {
lastActivityId = activities[0].id;
}
});
}
function updateActiveUsers() {
$.get('@Url.Action("GetActiveUsers")', function(users) {
const list = $('#activeUsersList');
list.empty();
$('#activeUsersCount').text(users.length);
users.forEach(function(user) {
const html = `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${user.userName}</h6>
<small class="text-muted">${user.lastAction}</small>
</div>
<div class="text-end">
<span class="badge bg-primary">${user.activityCount} actions</span>
${user.totalValue > 0 ? `<br><small class="text-success">£${user.totalValue.toFixed(2)}</small>` : ''}
</div>
</div>
</div>
`;
list.append(html);
});
if (users.length === 0) {
list.append('<div class="list-group-item text-muted text-center">No active users</div>');
}
});
}
function updateStatistics() {
$.get('@Url.Action("GetStatistics")', function(stats) {
$('#todayActivitiesCount').text(stats.todayActivities);
$('#uniqueVisitorsCount').text(stats.uniqueUsersToday);
// Update popular products
const productsList = $('#popularProductsList');
productsList.empty();
stats.popularProducts.forEach(function(product) {
const html = `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0">${product.productName}</h6>
</div>
<div>
<span class="badge bg-info">${product.viewCount} views</span>
<span class="badge bg-success">${product.addToCartCount} carts</span>
</div>
</div>
</div>
`;
productsList.append(html);
});
// Calculate cart actions
let cartActions = 0;
$('#activityFeed .activity-item').each(function() {
const text = $(this).text();
if (text.includes('AddToCart') || text.includes('Checkout')) {
cartActions++;
}
});
$('#cartActionsCount').text(cartActions);
});
}
// Initial load and periodic updates
$(document).ready(function() {
updateActivities();
updateActiveUsers();
updateStatistics();
// Update every 5 seconds
setInterval(updateActivities, 5000);
setInterval(updateActiveUsers, 10000);
setInterval(updateStatistics, 15000);
});
</script>
}

View File

@ -0,0 +1,112 @@
@model LittleShop.DTOs.CreateProductVariantDto
@{
ViewData["Title"] = "Create Variant";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Variants", new { id = product?.Id })">@product?.Name - Variants</a></li>
<li class="breadcrumb-item active" aria-current="page">Add Variant</li>
</ol>
</nav>
<h1><i class="fas fa-plus-circle"></i> Add Product Variant</h1>
<p class="text-muted">Add a new color, flavor, or other option for <strong>@product?.Name</strong></p>
</div>
</div>
<div class="row">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-body">
<form method="post" action="@Url.Action("CreateVariant")">
@Html.AntiForgeryToken()
<input type="hidden" name="ProductId" value="@Model.ProductId" />
<div class="mb-3">
<label asp-for="Name" class="form-label required"></label>
<input asp-for="Name" class="form-control" placeholder="e.g., Red, Blue, Vanilla, Chocolate" />
<span asp-validation-for="Name" class="text-danger"></span>
<small class="form-text text-muted">The specific variant option (color, flavor, size, etc.)</small>
</div>
<div class="mb-3">
<label asp-for="VariantType" class="form-label"></label>
<select asp-for="VariantType" class="form-select">
<option value="Standard">Standard</option>
<option value="Color">Color</option>
<option value="Flavor">Flavor</option>
<option value="Size">Size</option>
<option value="Material">Material</option>
<option value="Style">Style</option>
</select>
<span asp-validation-for="VariantType" class="text-danger"></span>
<small class="form-text text-muted">Categorize this variant for better organization</small>
</div>
<div class="mb-3">
<label asp-for="StockLevel" class="form-label"></label>
<input asp-for="StockLevel" type="number" class="form-control" min="0" />
<span asp-validation-for="StockLevel" class="text-danger"></span>
<small class="form-text text-muted">Track inventory for this specific variant (optional)</small>
</div>
<div class="mb-3">
<label asp-for="SortOrder" class="form-label"></label>
<input asp-for="SortOrder" type="number" class="form-control" />
<span asp-validation-for="SortOrder" class="text-danger"></span>
<small class="form-text text-muted">Controls the display order of variants</small>
</div>
<div class="mb-3">
<div class="form-check">
<input asp-for="IsActive" class="form-check-input" type="checkbox" checked />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
<small class="form-text text-muted">Active variants are available for selection</small>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-check"></i> Create Variant
</button>
<a href="@Url.Action("Variants", new { id = Model.ProductId })" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4 col-lg-6">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-info-circle"></i> About Variants</h5>
<p class="card-text">
Variants allow customers to choose specific options for a product, such as:
</p>
<ul>
<li><strong>Colors:</strong> Red, Blue, Green, Black, White</li>
<li><strong>Flavors:</strong> Vanilla, Chocolate, Strawberry</li>
<li><strong>Sizes:</strong> Small, Medium, Large, XL</li>
<li><strong>Materials:</strong> Cotton, Polyester, Leather</li>
</ul>
<p class="card-text">
When customers order this product (single item or multi-buy), they'll be able to select from the available variants.
</p>
<div class="alert alert-info mt-3">
<strong>Multi-Buy Variants:</strong> For multi-buy deals (e.g., 3 for £25), customers can choose different variants for each item in the bundle.
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,83 @@
@model LittleShop.DTOs.UpdateProductVariantDto
@{
ViewData["Title"] = "Edit Variant";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Variants", new { id = product?.Id })">@product?.Name - Variants</a></li>
<li class="breadcrumb-item active" aria-current="page">Edit Variant</li>
</ol>
</nav>
<h1><i class="fas fa-edit"></i> Edit Product Variant</h1>
<p class="text-muted">Update variant details for <strong>@product?.Name</strong></p>
</div>
</div>
<div class="row">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-body">
<form method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label asp-for="Name" class="form-label required"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="VariantType" class="form-label"></label>
<select asp-for="VariantType" class="form-select">
<option value="Standard">Standard</option>
<option value="Color">Color</option>
<option value="Flavor">Flavor</option>
<option value="Size">Size</option>
<option value="Material">Material</option>
<option value="Style">Style</option>
</select>
<span asp-validation-for="VariantType" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="StockLevel" class="form-label"></label>
<input asp-for="StockLevel" type="number" class="form-control" min="0" />
<span asp-validation-for="StockLevel" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="SortOrder" class="form-label"></label>
<input asp-for="SortOrder" type="number" class="form-control" />
<span asp-validation-for="SortOrder" class="text-danger"></span>
</div>
<div class="mb-3">
<div class="form-check">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label"></label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Changes
</button>
<a href="@Url.Action("Variants", new { id = product?.Id })" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -110,8 +110,11 @@
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product">
<i class="fas fa-edit"></i>
</a>
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Variations">
<i class="fas fa-list"></i>
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Multi-Buys">
<i class="fas fa-tags"></i>
</a>
<a href="@Url.Action("Variants", new { id = product.Id })" class="btn btn-outline-success" title="Manage Variants">
<i class="fas fa-palette"></i>
</a>
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this product?')">

View File

@ -0,0 +1,107 @@
@model IEnumerable<LittleShop.DTOs.ProductVariantDto>
@{
ViewData["Title"] = "Product Variants";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item active" aria-current="page">@product?.Name - Variants</li>
</ol>
</nav>
<h1><i class="fas fa-palette"></i> Product Variants</h1>
<p class="text-muted">Manage color, flavor, or other options for <strong>@product?.Name</strong></p>
</div>
<div class="col-auto">
<a href="@Url.Action("CreateVariant", new { productId = product?.Id })" class="btn btn-success">
<i class="fas fa-plus"></i> Add Variant
</a>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Stock Level</th>
<th>Sort Order</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var variant in Model.OrderBy(v => v.SortOrder).ThenBy(v => v.Name))
{
<tr>
<td>
<strong>@variant.Name</strong>
</td>
<td>
<span class="badge bg-info">@variant.VariantType</span>
</td>
<td>
@if (variant.StockLevel > 0)
{
<span class="badge bg-success">@variant.StockLevel in stock</span>
}
else
{
<span class="badge bg-warning text-dark">Out of stock</span>
}
</td>
<td>@variant.SortOrder</td>
<td>
@if (variant.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="@Url.Action("EditVariant", new { id = variant.Id })" class="btn btn-outline-primary">
<i class="fas fa-edit"></i> Edit
</a>
<form method="post" action="@Url.Action("DeleteVariant", new { id = variant.Id })" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this variant?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> No variants have been added for this product yet.
<a href="@Url.Action("CreateVariant", new { productId = product?.Id })">Add your first variant</a>
</div>
}
</div>
</div>
<div class="mt-3">
<a href="@Url.Action("Index")" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Products
</a>
</div>

View File

@ -1,7 +1,7 @@
@model IEnumerable<LittleShop.DTOs.ProductMultiBuyDto>
@{
ViewData["Title"] = "Product Variations";
ViewData["Title"] = "Product Multi-Buys";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
@ -10,15 +10,15 @@
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item active" aria-current="page">@product?.Name - Variations</li>
<li class="breadcrumb-item active" aria-current="page">@product?.Name - Multi-Buys</li>
</ol>
</nav>
<h1><i class="fas fa-list"></i> Product Variations</h1>
<p class="text-muted">Manage quantity-based pricing for <strong>@product?.Name</strong></p>
<h1><i class="fas fa-tags"></i> Product Multi-Buys</h1>
<p class="text-muted">Manage quantity-based pricing deals for <strong>@product?.Name</strong></p>
</div>
<div class="col-auto">
<a href="@Url.Action("CreateVariation", new { productId = product?.Id })" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Variation
<i class="fas fa-plus"></i> Add Multi-Buy
</a>
</div>
</div>

View File

@ -92,6 +92,11 @@
<i class="fas fa-robot"></i> Bots
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("LiveView", "BotActivity", new { area = "Admin" })">
<i class="fas fa-satellite-dish"></i> Live Activity
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "SystemSettings", new { area = "Admin" })">
<i class="fas fa-cog"></i> Settings

View File

@ -14,8 +14,8 @@ public class OrderItem
public Guid? ProductMultiBuyId { get; set; } // Nullable for backward compatibility
[StringLength(100)]
public string? SelectedVariant { get; set; } // The variant chosen (e.g., "Red", "Vanilla")
[StringLength(500)]
public string? SelectedVariants { get; set; } // JSON array of variants chosen (e.g., ["Red", "Blue", "Green"] for multi-buy)
public int Quantity { get; set; }