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:
55
LittleShop/Areas/Admin/Controllers/ActivityController.cs
Normal file
55
LittleShop/Areas/Admin/Controllers/ActivityController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
317
LittleShop/Areas/Admin/Views/Activity/Live.cshtml
Normal file
317
LittleShop/Areas/Admin/Views/Activity/Live.cshtml
Normal 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>
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model LittleShop.DTOs.CreateProductVariationDto
|
||||
@model LittleShop.DTOs.CreateProductMultiBuyDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Product Variation";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model LittleShop.DTOs.UpdateProductVariationDto
|
||||
@model LittleShop.DTOs.UpdateProductMultiBuyDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Product Variation";
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model IEnumerable<LittleShop.DTOs.ProductVariationDto>
|
||||
@model IEnumerable<LittleShop.DTOs.ProductMultiBuyDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Product Variations";
|
||||
|
||||
Reference in New Issue
Block a user