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:
parent
94b6bd421d
commit
5530f9e4f5
142
LittleShop/Areas/Admin/Controllers/BotActivityController.cs
Normal file
142
LittleShop/Areas/Admin/Controllers/BotActivityController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
{
|
||||
|
||||
362
LittleShop/Areas/Admin/Views/BotActivity/LiveView.cshtml
Normal file
362
LittleShop/Areas/Admin/Views/BotActivity/LiveView.cshtml
Normal 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>
|
||||
}
|
||||
112
LittleShop/Areas/Admin/Views/Products/CreateVariant.cshtml
Normal file
112
LittleShop/Areas/Admin/Views/Products/CreateVariant.cshtml
Normal 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" />
|
||||
}
|
||||
83
LittleShop/Areas/Admin/Views/Products/EditVariant.cshtml
Normal file
83
LittleShop/Areas/Admin/Views/Products/EditVariant.cshtml
Normal 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" />
|
||||
}
|
||||
@ -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?')">
|
||||
|
||||
107
LittleShop/Areas/Admin/Views/Products/ProductVariants.cshtml
Normal file
107
LittleShop/Areas/Admin/Views/Products/ProductVariants.cshtml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user