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";

View File

@@ -0,0 +1,152 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using QRCoder;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Controllers;
public class BotDirectoryController : Controller
{
private readonly LittleShopContext _context;
private readonly ILogger<BotDirectoryController> _logger;
public BotDirectoryController(LittleShopContext context, ILogger<BotDirectoryController> logger)
{
_context = context;
_logger = logger;
}
// GET: /bots
[HttpGet("/bots")]
public async Task<IActionResult> Index()
{
var bots = await _context.Bots
.Where(b => b.Status == BotStatus.Active && b.IsActive)
.OrderBy(b => b.Name)
.Select(b => new BotDirectoryDto
{
Id = b.Id,
Name = b.Name,
Description = b.Description,
Type = b.Type.ToString(),
PersonalityName = b.PersonalityName,
TelegramUsername = b.Settings.Contains("\"telegram_username\":")
? ExtractTelegramUsername(b.Settings)
: b.PlatformUsername,
LastSeenAt = b.LastSeenAt,
CreatedAt = b.CreatedAt
})
.ToListAsync();
return View(bots);
}
// GET: /bots/qr/{id}
[HttpGet("/bots/qr/{id}")]
public async Task<IActionResult> GetQRCode(Guid id)
{
var bot = await _context.Bots
.Where(b => b.Id == id && b.Status == BotStatus.Active)
.FirstOrDefaultAsync();
if (bot == null)
{
return NotFound();
}
// Extract Telegram username from settings JSON or use platform username
string? telegramUsername = ExtractTelegramUsername(bot.Settings) ?? bot.PlatformUsername;
if (string.IsNullOrEmpty(telegramUsername))
{
return NotFound("Bot does not have a Telegram username configured");
}
// Generate Telegram deep link
string telegramUrl = $"https://t.me/{telegramUsername}";
// Generate QR code
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
{
QRCodeData qrCodeData = qrGenerator.CreateQrCode(telegramUrl, QRCodeGenerator.ECCLevel.Q);
using (PngByteQRCode qrCode = new PngByteQRCode(qrCodeData))
{
byte[] qrCodeImage = qrCode.GetGraphic(20);
return File(qrCodeImage, "image/png");
}
}
}
private static string? ExtractTelegramUsername(string settings)
{
try
{
var json = System.Text.Json.JsonDocument.Parse(settings);
if (json.RootElement.TryGetProperty("telegram_username", out var username))
{
return username.GetString();
}
}
catch
{
// JSON parsing failed
}
return null;
}
}
public class BotDirectoryDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string PersonalityName { get; set; } = string.Empty;
public string? TelegramUsername { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime CreatedAt { get; set; }
public string GetBadgeColor()
{
return Type.ToLower() switch
{
"sales" => "primary",
"support" => "success",
"marketing" => "warning",
"technical" => "info",
_ => "secondary"
};
}
public string GetStatusBadge()
{
if (!LastSeenAt.HasValue)
return "Never Connected";
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
if (timeSinceLastSeen.TotalMinutes < 5)
return "Online";
else if (timeSinceLastSeen.TotalHours < 1)
return "Recently Active";
else if (timeSinceLastSeen.TotalDays < 1)
return "Active Today";
else
return $"Last seen {timeSinceLastSeen.Days} days ago";
}
public string GetStatusColor()
{
if (!LastSeenAt.HasValue)
return "secondary";
var timeSinceLastSeen = DateTime.UtcNow - LastSeenAt.Value;
if (timeSinceLastSeen.TotalMinutes < 5)
return "success";
else if (timeSinceLastSeen.TotalHours < 1)
return "info";
else
return "secondary";
}
}

View File

@@ -15,13 +15,13 @@ public class DevController : ControllerBase
_productService = productService;
}
[HttpPost("variations")]
public async Task<ActionResult<ProductVariationDto>> CreateVariationForDev(CreateProductVariationDto createVariationDto)
[HttpPost("multibuys")]
public async Task<ActionResult<ProductMultiBuyDto>> CreateMultiBuyForDev(CreateProductMultiBuyDto createMultiBuyDto)
{
try
{
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
return CreatedAtAction("GetProductVariation", "ProductVariations", new { id = variation.Id }, variation);
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
return CreatedAtAction("GetProductMultiBuy", "ProductMultiBuys", new { id = multiBuy.Id }, multiBuy);
}
catch (ArgumentException ex)
{
@@ -37,7 +37,8 @@ public class DevController : ControllerBase
id = p.Id,
name = p.Name,
price = p.Price,
variationCount = p.Variations.Count
multiBuyCount = p.MultiBuys.Count,
variantCount = p.Variants.Count
});
return Ok(result);
}

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductMultiBuysController : ControllerBase
{
private readonly IProductService _productService;
public ProductMultiBuysController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductMultiBuyDto>>> GetProductMultiBuys(Guid productId)
{
var multiBuys = await _productService.GetProductMultiBuysAsync(productId);
return Ok(multiBuys);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductMultiBuyDto>> GetProductMultiBuy(Guid id)
{
var multiBuy = await _productService.GetProductMultiBuyByIdAsync(id);
if (multiBuy == null)
return NotFound();
return Ok(multiBuy);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductMultiBuyDto>> CreateProductMultiBuy(CreateProductMultiBuyDto createMultiBuyDto)
{
try
{
var multiBuy = await _productService.CreateProductMultiBuyAsync(createMultiBuyDto);
return CreatedAtAction(nameof(GetProductMultiBuy), new { id = multiBuy.Id }, multiBuy);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductMultiBuy(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
{
try
{
await _productService.UpdateProductMultiBuyAsync(id, updateMultiBuyDto);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductMultiBuy(Guid id)
{
try
{
await _productService.DeleteProductMultiBuyAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductVariantsController : ControllerBase
{
private readonly IProductService _productService;
public ProductVariantsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductVariantDto>>> GetProductVariants(Guid productId)
{
var variants = await _productService.GetProductVariantsAsync(productId);
return Ok(variants);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductVariantDto>> GetProductVariant(Guid id)
{
var variant = await _productService.GetProductVariantByIdAsync(id);
if (variant == null)
return NotFound();
return Ok(variant);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductVariantDto>> CreateProductVariant(CreateProductVariantDto createVariantDto)
{
try
{
var variant = await _productService.CreateProductVariantAsync(createVariantDto);
return CreatedAtAction(nameof(GetProductVariant), new { id = variant.Id }, variant);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductVariant(Guid id, UpdateProductVariantDto updateVariantDto)
{
try
{
await _productService.UpdateProductVariantAsync(id, updateVariantDto);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductVariant(Guid id)
{
try
{
await _productService.DeleteProductVariantAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -1,73 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class ProductVariationsController : ControllerBase
{
private readonly IProductService _productService;
public ProductVariationsController(IProductService productService)
{
_productService = productService;
}
[HttpGet("product/{productId}")]
public async Task<ActionResult<IEnumerable<ProductVariationDto>>> GetProductVariations(Guid productId)
{
var variations = await _productService.GetProductVariationsAsync(productId);
return Ok(variations);
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductVariationDto>> GetProductVariation(Guid id)
{
var variation = await _productService.GetProductVariationByIdAsync(id);
if (variation == null)
return NotFound();
return Ok(variation);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ProductVariationDto>> CreateProductVariation(CreateProductVariationDto createVariationDto)
{
try
{
var variation = await _productService.CreateProductVariationAsync(createVariationDto);
return CreatedAtAction(nameof(GetProductVariation), new { id = variation.Id }, variation);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> UpdateProductVariation(Guid id, UpdateProductVariationDto updateVariationDto)
{
var success = await _productService.UpdateProductVariationAsync(id, updateVariationDto);
if (!success)
return NotFound();
return NoContent();
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> DeleteProductVariation(Guid id)
{
var success = await _productService.DeleteProductVariationAsync(id);
if (!success)
return NotFound();
return NoContent();
}
}

View File

@@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.DTOs;
public class BotActivityDto
{
public Guid Id { get; set; }
public Guid BotId { get; set; }
public string BotName { get; set; } = string.Empty;
public string SessionIdentifier { get; set; } = string.Empty;
public string UserDisplayName { get; set; } = string.Empty;
public string ActivityType { get; set; } = string.Empty;
public string ActivityDescription { get; set; } = string.Empty;
public Guid? ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public Guid? OrderId { get; set; }
public string CategoryName { get; set; } = string.Empty;
public decimal? Value { get; set; }
public int? Quantity { get; set; }
public string Platform { get; set; } = "Telegram";
public string DeviceType { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
public string Metadata { get; set; } = "{}";
}
public class CreateBotActivityDto
{
[Required]
public Guid BotId { get; set; }
[Required]
[StringLength(256)]
public string SessionIdentifier { get; set; } = string.Empty;
[StringLength(100)]
public string UserDisplayName { get; set; } = string.Empty;
[Required]
[StringLength(50)]
public string ActivityType { get; set; } = string.Empty;
[Required]
[StringLength(500)]
public string ActivityDescription { get; set; } = string.Empty;
public Guid? ProductId { get; set; }
[StringLength(200)]
public string ProductName { get; set; } = string.Empty;
public Guid? OrderId { get; set; }
[StringLength(100)]
public string CategoryName { get; set; } = string.Empty;
public decimal? Value { get; set; }
public int? Quantity { get; set; }
[StringLength(100)]
public string Platform { get; set; } = "Telegram";
[StringLength(50)]
public string DeviceType { get; set; } = string.Empty;
[StringLength(100)]
public string Location { get; set; } = string.Empty;
public string Metadata { get; set; } = "{}";
}
public class LiveActivitySummaryDto
{
public int ActiveUsers { get; set; }
public int TotalActivitiesLast5Min { get; set; }
public int ProductViewsLast5Min { get; set; }
public int CartsActiveNow { get; set; }
public decimal TotalValueInCartsNow { get; set; }
public List<string> ActiveUserNames { get; set; } = new();
public List<BotActivityDto> RecentActivities { get; set; } = new();
}

View File

@@ -50,9 +50,10 @@ public class OrderItemDto
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public Guid? ProductVariationId { get; set; }
public Guid? ProductMultiBuyId { get; set; }
public string ProductName { get; set; } = string.Empty;
public string? ProductVariationName { get; set; }
public string? ProductMultiBuyName { get; set; }
public string? SelectedVariant { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
@@ -94,7 +95,9 @@ public class CreateOrderItemDto
[Required]
public Guid ProductId { get; set; }
public Guid? ProductVariationId { get; set; } // Optional: if specified, use variation pricing
public Guid? ProductMultiBuyId { get; set; } // Optional: if specified, use multi-buy pricing
public string? SelectedVariant { get; set; } // Optional: variant choice (color/flavor)
[Range(1, int.MaxValue)]
public int Quantity { get; set; }

View File

@@ -18,7 +18,8 @@ public class ProductDto
public DateTime UpdatedAt { get; set; }
public bool IsActive { get; set; }
public List<ProductPhotoDto> Photos { get; set; } = new();
public List<ProductVariationDto> Variations { get; set; } = new();
public List<ProductMultiBuyDto> MultiBuys { get; set; } = new();
public List<ProductVariantDto> Variants { get; set; } = new();
}
public class ProductPhotoDto
@@ -91,7 +92,7 @@ public class CreateProductPhotoDto
public int DisplayOrder { get; set; }
}
public class ProductVariationDto
public class ProductMultiBuyDto
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
@@ -106,7 +107,20 @@ public class ProductVariationDto
public DateTime UpdatedAt { get; set; }
}
public class CreateProductVariationDto
public class ProductVariantDto
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public string Name { get; set; } = string.Empty;
public string VariantType { get; set; } = "Standard";
public int SortOrder { get; set; }
public int StockLevel { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class CreateProductMultiBuyDto
{
[Required]
public Guid ProductId { get; set; }
@@ -129,7 +143,26 @@ public class CreateProductVariationDto
public int SortOrder { get; set; }
}
public class UpdateProductVariationDto
public class CreateProductVariantDto
{
[Required]
public Guid ProductId { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(50)]
public string VariantType { get; set; } = "Standard";
[Range(0, int.MaxValue)]
public int SortOrder { get; set; }
[Range(0, int.MaxValue)]
public int StockLevel { get; set; } = 0;
}
public class UpdateProductMultiBuyDto
{
[StringLength(100)]
public string? Name { get; set; }
@@ -145,5 +178,22 @@ public class UpdateProductVariationDto
[Range(0, int.MaxValue)]
public int? SortOrder { get; set; }
public bool? IsActive { get; set; }
}
public class UpdateProductVariantDto
{
[StringLength(100)]
public string? Name { get; set; }
[StringLength(50)]
public string? VariantType { get; set; }
[Range(0, int.MaxValue)]
public int? SortOrder { get; set; }
[Range(0, int.MaxValue)]
public int? StockLevel { get; set; }
public bool? IsActive { get; set; }
}

View File

@@ -13,7 +13,9 @@ public class LittleShopContext : DbContext
public DbSet<Category> Categories { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<ProductPhoto> ProductPhotos { get; set; }
public DbSet<ProductVariation> ProductVariations { get; set; }
public DbSet<ProductMultiBuy> ProductMultiBuys { get; set; }
public DbSet<ProductVariant> ProductVariants { get; set; }
public DbSet<BotActivity> BotActivities { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<CryptoPayment> CryptoPayments { get; set; }
@@ -54,30 +56,72 @@ public class LittleShopContext : DbContext
.HasForeignKey(pp => pp.ProductId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Variations)
entity.HasMany(p => p.MultiBuys)
.WithOne(pmb => pmb.Product)
.HasForeignKey(pmb => pmb.ProductId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Variants)
.WithOne(pv => pv.Product)
.HasForeignKey(pv => pv.ProductId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Activities)
.WithOne(ba => ba.Product)
.HasForeignKey(ba => ba.ProductId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasMany(p => p.OrderItems)
.WithOne(oi => oi.Product)
.HasForeignKey(oi => oi.ProductId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductVariation entity
modelBuilder.Entity<ProductVariation>(entity =>
// ProductMultiBuy entity
modelBuilder.Entity<ProductMultiBuy>(entity =>
{
entity.HasIndex(e => new { e.ProductId, e.Quantity }).IsUnique(); // One variation per quantity per product
entity.HasIndex(e => new { e.ProductId, e.Quantity }).IsUnique(); // One multi-buy per quantity per product
entity.HasIndex(e => new { e.ProductId, e.SortOrder });
entity.HasIndex(e => e.IsActive);
entity.HasMany(pv => pv.OrderItems)
.WithOne(oi => oi.ProductVariation)
.HasForeignKey(oi => oi.ProductVariationId)
entity.HasMany(pmb => pmb.OrderItems)
.WithOne(oi => oi.ProductMultiBuy)
.HasForeignKey(oi => oi.ProductMultiBuyId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductVariant entity
modelBuilder.Entity<ProductVariant>(entity =>
{
entity.HasIndex(e => new { e.ProductId, e.Name }).IsUnique(); // Unique variant names per product
entity.HasIndex(e => new { e.ProductId, e.SortOrder });
entity.HasIndex(e => e.IsActive);
});
// BotActivity entity
modelBuilder.Entity<BotActivity>(entity =>
{
entity.HasOne(ba => ba.Bot)
.WithMany()
.HasForeignKey(ba => ba.BotId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(ba => ba.Product)
.WithMany(p => p.Activities)
.HasForeignKey(ba => ba.ProductId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(ba => ba.Order)
.WithMany()
.HasForeignKey(ba => ba.OrderId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasIndex(e => new { e.BotId, e.Timestamp });
entity.HasIndex(e => e.SessionIdentifier);
entity.HasIndex(e => e.ActivityType);
entity.HasIndex(e => e.Timestamp);
});
// Order entity
modelBuilder.Entity<Order>(entity =>
{

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Authorization;
using LittleShop.Services;
using LittleShop.DTOs;
namespace LittleShop.Hubs;
[Authorize(Policy = "AdminOnly")]
public class ActivityHub : Hub
{
private readonly IBotActivityService _activityService;
private readonly ILogger<ActivityHub> _logger;
public ActivityHub(IBotActivityService activityService, ILogger<ActivityHub> logger)
{
_activityService = activityService;
_logger = logger;
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Admin connected to activity hub: {ConnectionId}", Context.ConnectionId);
// Send initial summary when admin connects
var summary = await _activityService.GetLiveActivitySummaryAsync();
await Clients.Caller.SendAsync("InitialSummary", summary);
await base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Admin disconnected from activity hub: {ConnectionId}", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
public async Task GetRecentActivities(int minutesBack = 5)
{
var activities = await _activityService.GetRecentActivitiesAsync(minutesBack);
await Clients.Caller.SendAsync("RecentActivities", activities);
}
public async Task GetActivityStats(int hoursBack = 24)
{
var stats = await _activityService.GetActivityTypeStatsAsync(hoursBack);
await Clients.Caller.SendAsync("ActivityStats", stats);
}
public async Task GetSessionActivities(string sessionIdentifier)
{
var activities = await _activityService.GetActivitiesBySessionAsync(sessionIdentifier);
await Clients.Caller.SendAsync("SessionActivities", activities);
}
}

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -18,6 +19,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />

View File

@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.Models;
public class BotActivity
{
[Key]
public Guid Id { get; set; }
public Guid BotId { get; set; }
[StringLength(256)]
public string SessionIdentifier { get; set; } = string.Empty; // Hashed user ID
[StringLength(100)]
public string UserDisplayName { get; set; } = string.Empty; // e.g., "Merlin", "Anonymous User #123"
[Required]
[StringLength(50)]
public string ActivityType { get; set; } = string.Empty; // e.g., "ViewProduct", "AddToCart", "Checkout", "Browse"
[StringLength(500)]
public string ActivityDescription { get; set; } = string.Empty; // e.g., "Viewing Red Widget", "Added 3x Blue Gadget to cart"
public Guid? ProductId { get; set; } // Related product if applicable
[StringLength(200)]
public string ProductName { get; set; } = string.Empty; // Denormalized for performance
public Guid? OrderId { get; set; } // Related order if applicable
[StringLength(100)]
public string CategoryName { get; set; } = string.Empty; // If browsing categories
public decimal? Value { get; set; } // Monetary value if applicable (cart total, order amount)
public int? Quantity { get; set; } // Quantity if applicable
[StringLength(100)]
public string Platform { get; set; } = "Telegram"; // Telegram, Discord, Web, etc.
[StringLength(50)]
public string DeviceType { get; set; } = string.Empty; // Mobile, Desktop, etc.
[StringLength(100)]
public string Location { get; set; } = string.Empty; // Country or region if available
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string Metadata { get; set; } = "{}"; // JSON for additional flexible data
// Navigation properties
public virtual Bot Bot { get; set; } = null!;
public virtual Product? Product { get; set; }
public virtual Order? Order { get; set; }
}

View File

@@ -12,7 +12,10 @@ public class OrderItem
public Guid ProductId { get; set; }
public Guid? ProductVariationId { get; set; } // Nullable for backward compatibility
public Guid? ProductMultiBuyId { get; set; } // Nullable for backward compatibility
[StringLength(100)]
public string? SelectedVariant { get; set; } // The variant chosen (e.g., "Red", "Vanilla")
public int Quantity { get; set; }
@@ -25,5 +28,5 @@ public class OrderItem
// Navigation properties
public virtual Order Order { get; set; } = null!;
public virtual Product Product { get; set; } = null!;
public virtual ProductVariation? ProductVariation { get; set; }
public virtual ProductMultiBuy? ProductMultiBuy { get; set; }
}

View File

@@ -36,7 +36,9 @@ public class Product
// Navigation properties
public virtual Category Category { get; set; } = null!;
public virtual ICollection<ProductPhoto> Photos { get; set; } = new List<ProductPhoto>();
public virtual ICollection<ProductVariation> Variations { get; set; } = new List<ProductVariation>();
public virtual ICollection<ProductMultiBuy> MultiBuys { get; set; } = new List<ProductMultiBuy>();
public virtual ICollection<ProductVariant> Variants { get; set; } = new List<ProductVariant>();
public virtual ICollection<BotActivity> Activities { get; set; } = new List<BotActivity>();
public virtual ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
public virtual ICollection<Review> Reviews { get; set; } = new List<Review>();
}

View File

@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace LittleShop.Models;
public class ProductVariation
public class ProductMultiBuy
{
[Key]
public Guid Id { get; set; }
@@ -16,7 +16,7 @@ public class ProductVariation
public string Description { get; set; } = string.Empty; // e.g., "Best value for 3 items"
public int Quantity { get; set; } // The quantity this variation represents (1, 2, 3, etc.)
public int Quantity { get; set; } // The quantity this multi-buy represents (1, 2, 3, etc.)
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; set; } // The price for this quantity (£10, £19, £25)

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.Models;
public class ProductVariant
{
[Key]
public Guid Id { get; set; }
public Guid ProductId { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty; // e.g., "Red", "Blue", "Vanilla", "Chocolate"
[StringLength(50)]
public string VariantType { get; set; } = "Standard"; // e.g., "Color", "Flavor", "Size", "Standard"
public int SortOrder { get; set; } = 0; // For controlling display order
public bool IsActive { get; set; } = true;
public int StockLevel { get; set; } = 0; // Optional: track stock per variant
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual Product Product { get; set; } = null!;
}

View File

@@ -102,6 +102,10 @@ builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
builder.Services.AddHttpClient<ITeleBotMessagingService, TeleBotMessagingService>();
builder.Services.AddScoped<IProductImportService, ProductImportService>();
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
builder.Services.AddScoped<IBotActivityService, BotActivityService>();
// SignalR
builder.Services.AddSignalR();
// Health Checks
builder.Services.AddHealthChecks()
@@ -243,6 +247,9 @@ app.MapControllerRoute(
app.MapControllers(); // API routes
// Map SignalR hub
app.MapHub<LittleShop.Hubs.ActivityHub>("/activityHub");
// Health check endpoint
app.MapHealthChecks("/health");

View File

@@ -0,0 +1,225 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Hubs;
using LittleShop.Models;
namespace LittleShop.Services;
public class BotActivityService : IBotActivityService
{
private readonly LittleShopContext _context;
private readonly IHubContext<ActivityHub> _hubContext;
private readonly ILogger<BotActivityService> _logger;
public BotActivityService(
LittleShopContext context,
IHubContext<ActivityHub> hubContext,
ILogger<BotActivityService> logger)
{
_context = context;
_hubContext = hubContext;
_logger = logger;
}
public async Task<BotActivity> LogActivityAsync(CreateBotActivityDto dto)
{
var activity = new BotActivity
{
Id = Guid.NewGuid(),
BotId = dto.BotId,
SessionIdentifier = dto.SessionIdentifier,
UserDisplayName = dto.UserDisplayName,
ActivityType = dto.ActivityType,
ActivityDescription = dto.ActivityDescription,
ProductId = dto.ProductId,
ProductName = dto.ProductName,
OrderId = dto.OrderId,
CategoryName = dto.CategoryName,
Value = dto.Value,
Quantity = dto.Quantity,
Platform = dto.Platform,
DeviceType = dto.DeviceType,
Location = dto.Location,
Timestamp = DateTime.UtcNow,
Metadata = dto.Metadata
};
_context.BotActivities.Add(activity);
await _context.SaveChangesAsync();
// Broadcast the activity to connected clients
await BroadcastActivityAsync(activity);
_logger.LogInformation("Activity logged: {User} - {Type} - {Description}",
activity.UserDisplayName, activity.ActivityType, activity.ActivityDescription);
return activity;
}
public async Task<List<BotActivityDto>> GetRecentActivitiesAsync(int minutesBack = 5)
{
var cutoffTime = DateTime.UtcNow.AddMinutes(-minutesBack);
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.Timestamp >= cutoffTime)
.OrderByDescending(a => a.Timestamp)
.Take(100)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<List<BotActivityDto>> GetActivitiesBySessionAsync(string sessionIdentifier)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.SessionIdentifier == sessionIdentifier)
.OrderByDescending(a => a.Timestamp)
.Take(200)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<List<BotActivityDto>> GetActivitiesByBotAsync(Guid botId, int limit = 100)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.BotId == botId)
.OrderByDescending(a => a.Timestamp)
.Take(limit)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<LiveActivitySummaryDto> GetLiveActivitySummaryAsync()
{
var fiveMinutesAgo = DateTime.UtcNow.AddMinutes(-5);
var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);
var recentActivities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.Timestamp >= fiveMinutesAgo)
.ToListAsync();
var activeUsers = recentActivities
.Where(a => a.Timestamp >= oneMinuteAgo)
.Select(a => a.SessionIdentifier)
.Distinct()
.Count();
var activeUserNames = recentActivities
.Where(a => a.Timestamp >= oneMinuteAgo)
.Select(a => a.UserDisplayName)
.Distinct()
.Take(10)
.ToList();
var productViews = recentActivities
.Where(a => a.ActivityType == "ViewProduct")
.Count();
var cartsActive = recentActivities
.Where(a => a.ActivityType == "AddToCart" || a.ActivityType == "UpdateCart")
.Select(a => a.SessionIdentifier)
.Distinct()
.Count();
var totalCartValue = recentActivities
.Where(a => a.ActivityType == "AddToCart" && a.Value.HasValue)
.Sum(a => a.Value ?? 0);
var summary = new LiveActivitySummaryDto
{
ActiveUsers = activeUsers,
TotalActivitiesLast5Min = recentActivities.Count,
ProductViewsLast5Min = productViews,
CartsActiveNow = cartsActive,
TotalValueInCartsNow = totalCartValue,
ActiveUserNames = activeUserNames,
RecentActivities = recentActivities
.OrderByDescending(a => a.Timestamp)
.Take(20)
.Select(a => MapToDto(a))
.ToList()
};
return summary;
}
public async Task<List<BotActivityDto>> GetProductActivitiesAsync(Guid productId, int limit = 50)
{
var activities = await _context.BotActivities
.Include(a => a.Bot)
.Where(a => a.ProductId == productId)
.OrderByDescending(a => a.Timestamp)
.Take(limit)
.Select(a => MapToDto(a))
.ToListAsync();
return activities;
}
public async Task<Dictionary<string, int>> GetActivityTypeStatsAsync(int hoursBack = 24)
{
var cutoffTime = DateTime.UtcNow.AddHours(-hoursBack);
var stats = await _context.BotActivities
.Where(a => a.Timestamp >= cutoffTime)
.GroupBy(a => a.ActivityType)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Type, x => x.Count);
return stats;
}
public async Task BroadcastActivityAsync(BotActivity activity)
{
try
{
var dto = MapToDto(activity);
await _hubContext.Clients.All.SendAsync("NewActivity", dto);
// Also send summary update
var summary = await GetLiveActivitySummaryAsync();
await _hubContext.Clients.All.SendAsync("SummaryUpdate", summary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error broadcasting activity");
}
}
private static BotActivityDto MapToDto(BotActivity activity)
{
return new BotActivityDto
{
Id = activity.Id,
BotId = activity.BotId,
BotName = activity.Bot?.Name ?? "Unknown Bot",
SessionIdentifier = activity.SessionIdentifier,
UserDisplayName = activity.UserDisplayName,
ActivityType = activity.ActivityType,
ActivityDescription = activity.ActivityDescription,
ProductId = activity.ProductId,
ProductName = activity.ProductName,
OrderId = activity.OrderId,
CategoryName = activity.CategoryName,
Value = activity.Value,
Quantity = activity.Quantity,
Platform = activity.Platform,
DeviceType = activity.DeviceType,
Location = activity.Location,
Timestamp = activity.Timestamp,
Metadata = activity.Metadata
};
}
}

View File

@@ -0,0 +1,16 @@
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public interface IBotActivityService
{
Task<BotActivity> LogActivityAsync(CreateBotActivityDto dto);
Task<List<BotActivityDto>> GetRecentActivitiesAsync(int minutesBack = 5);
Task<List<BotActivityDto>> GetActivitiesBySessionAsync(string sessionIdentifier);
Task<List<BotActivityDto>> GetActivitiesByBotAsync(Guid botId, int limit = 100);
Task<LiveActivitySummaryDto> GetLiveActivitySummaryAsync();
Task<List<BotActivityDto>> GetProductActivitiesAsync(Guid productId, int limit = 50);
Task<Dictionary<string, int>> GetActivityTypeStatsAsync(int hoursBack = 24);
Task BroadcastActivityAsync(BotActivity activity);
}

View File

@@ -15,10 +15,17 @@ public interface IProductService
Task<bool> RemoveProductPhotoAsync(Guid productId, Guid photoId);
Task<IEnumerable<ProductDto>> SearchProductsAsync(string searchTerm);
// Product Variations
Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto);
Task<bool> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto);
Task<bool> DeleteProductVariationAsync(Guid id);
Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId);
Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id);
// Product Multi-Buys
Task<ProductMultiBuyDto> CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto);
Task<bool> UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto);
Task<bool> DeleteProductMultiBuyAsync(Guid id);
Task<IEnumerable<ProductMultiBuyDto>> GetProductMultiBuysAsync(Guid productId);
Task<ProductMultiBuyDto?> GetProductMultiBuyByIdAsync(Guid id);
// Product Variants
Task<ProductVariantDto> CreateProductVariantAsync(CreateProductVariantDto createVariantDto);
Task<bool> UpdateProductVariantAsync(Guid id, UpdateProductVariantDto updateVariantDto);
Task<bool> DeleteProductVariantAsync(Guid id);
Task<IEnumerable<ProductVariantDto>> GetProductVariantsAsync(Guid productId);
Task<ProductVariantDto?> GetProductVariantByIdAsync(Guid id);
}

View File

@@ -30,7 +30,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync();
@@ -45,7 +45,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.IdentityReference == identityReference)
.OrderByDescending(o => o.CreatedAt)
@@ -61,7 +61,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
@@ -77,7 +77,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == id);
@@ -146,20 +146,20 @@ public class OrderService : IOrderService
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
}
ProductVariation? variation = null;
ProductMultiBuy? multiBuy = null;
decimal unitPrice = product.Price;
if (itemDto.ProductVariationId.HasValue)
if (itemDto.ProductMultiBuyId.HasValue)
{
variation = await _context.ProductVariations.FindAsync(itemDto.ProductVariationId.Value);
if (variation == null || !variation.IsActive || variation.ProductId != itemDto.ProductId)
multiBuy = await _context.ProductMultiBuys.FindAsync(itemDto.ProductMultiBuyId.Value);
if (multiBuy == null || !multiBuy.IsActive || multiBuy.ProductId != itemDto.ProductId)
{
throw new ArgumentException($"Product variation {itemDto.ProductVariationId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
throw new ArgumentException($"Product multi-buy {itemDto.ProductMultiBuyId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
}
// When using a variation, the quantity represents how many of that variation bundle
// For example: buying 2 of the "3 for £25" variation means 6 total items for £50
unitPrice = variation.Price;
// When using a multi-buy, the quantity represents how many of that multi-buy bundle
// For example: buying 2 of the "3 for £25" multi-buy means 6 total items for £50
unitPrice = multiBuy.Price;
}
var orderItem = new OrderItem
@@ -167,7 +167,7 @@ public class OrderService : IOrderService
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = itemDto.ProductId,
ProductVariationId = itemDto.ProductVariationId,
ProductMultiBuyId = itemDto.ProductMultiBuyId,
Quantity = itemDto.Quantity,
UnitPrice = unitPrice,
TotalPrice = unitPrice * itemDto.Quantity
@@ -321,9 +321,9 @@ public class OrderService : IOrderService
{
Id = oi.Id,
ProductId = oi.ProductId,
ProductVariationId = oi.ProductVariationId,
ProductMultiBuyId = oi.ProductMultiBuyId,
ProductName = oi.Product.Name,
ProductVariationName = oi.ProductVariation?.Name,
ProductMultiBuyName = oi.ProductMultiBuy?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
@@ -500,7 +500,7 @@ public class OrderService : IOrderService
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Items)
.ThenInclude(oi => oi.ProductVariation)
.ThenInclude(oi => oi.ProductMultiBuy)
.Include(o => o.Payments)
.Where(o => o.Status == status)
.OrderByDescending(o => o.CreatedAt)

View File

@@ -183,7 +183,7 @@ public class ProductImportService : IProductImportService
// Import variations if provided
if (!string.IsNullOrEmpty(importDto.Variations))
{
await ImportProductVariationsAsync(product.Id, importDto.Variations);
await ImportProductMultiBuysAsync(product.Id, importDto.Variations);
}
// Import photos if provided
@@ -206,7 +206,7 @@ public class ProductImportService : IProductImportService
}
}
private async Task ImportProductVariationsAsync(Guid productId, string variationsText)
private async Task ImportProductMultiBuysAsync(Guid productId, string variationsText)
{
// Format: "Single Item:1:10.00;Twin Pack:2:19.00;Triple Pack:3:25.00"
var variations = variationsText.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -216,7 +216,7 @@ public class ProductImportService : IProductImportService
var parts = variations[i].Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3)
{
var variationDto = new CreateProductVariationDto
var multiBuyDto = new CreateProductMultiBuyDto
{
ProductId = productId,
Name = parts[0].Trim(),
@@ -226,7 +226,7 @@ public class ProductImportService : IProductImportService
SortOrder = i
};
await _productService.CreateProductVariationAsync(variationDto);
await _productService.CreateProductMultiBuyAsync(multiBuyDto);
}
}
}
@@ -275,7 +275,7 @@ public class ProductImportService : IProductImportService
foreach (var product in products)
{
// Build variations string
var variationsText = string.Join(";", product.Variations.OrderBy(v => v.SortOrder)
var variationsText = string.Join(";", product.MultiBuys.OrderBy(v => v.SortOrder)
.Select(v => $"{v.Name}:{v.Quantity}:{v.Price:F2}"));
// Build photo URLs string

View File

@@ -21,7 +21,7 @@ public class ProductService : IProductService
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Include(p => p.MultiBuys.Where(v => v.IsActive))
.Where(p => p.IsActive)
.Select(p => new ProductDto
{
@@ -45,7 +45,7 @@ public class ProductService : IProductService
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
MultiBuys = p.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
@@ -68,7 +68,7 @@ public class ProductService : IProductService
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Include(p => p.MultiBuys.Where(v => v.IsActive))
.Where(p => p.IsActive && p.CategoryId == categoryId)
.Select(p => new ProductDto
{
@@ -92,7 +92,7 @@ public class ProductService : IProductService
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
MultiBuys = p.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
@@ -115,7 +115,7 @@ public class ProductService : IProductService
var product = await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Include(p => p.MultiBuys.Where(v => v.IsActive))
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null) return null;
@@ -142,7 +142,7 @@ public class ProductService : IProductService
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = product.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
MultiBuys = product.MultiBuys.OrderBy(v => v.SortOrder).Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
@@ -195,7 +195,8 @@ public class ProductService : IProductService
UpdatedAt = product.UpdatedAt,
IsActive = product.IsActive,
Photos = new List<ProductPhotoDto>(),
Variations = new List<ProductVariationDto>()
MultiBuys = new List<ProductMultiBuyDto>(),
Variants = new List<ProductVariantDto>()
};
}
@@ -339,7 +340,7 @@ public class ProductService : IProductService
var query = _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Include(p => p.MultiBuys.Where(v => v.IsActive))
.Where(p => p.IsActive);
if (!string.IsNullOrWhiteSpace(searchTerm))
@@ -375,39 +376,40 @@ public class ProductService : IProductService
}).ToListAsync();
}
public async Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto)
// Product Multi-Buy Methods
public async Task<ProductMultiBuyDto> CreateProductMultiBuyAsync(CreateProductMultiBuyDto createMultiBuyDto)
{
var product = await _context.Products.FindAsync(createVariationDto.ProductId);
var product = await _context.Products.FindAsync(createMultiBuyDto.ProductId);
if (product == null)
throw new ArgumentException("Product not found");
// Check if variation with this quantity already exists
var existingVariation = await _context.ProductVariations
.FirstOrDefaultAsync(v => v.ProductId == createVariationDto.ProductId &&
v.Quantity == createVariationDto.Quantity &&
// Check if multi-buy with this quantity already exists
var existingMultiBuy = await _context.ProductMultiBuys
.FirstOrDefaultAsync(v => v.ProductId == createMultiBuyDto.ProductId &&
v.Quantity == createMultiBuyDto.Quantity &&
v.IsActive);
if (existingVariation != null)
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
if (existingMultiBuy != null)
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
var pricePerUnit = createVariationDto.Price / createVariationDto.Quantity;
var pricePerUnit = createMultiBuyDto.Price / createMultiBuyDto.Quantity;
var variation = new ProductVariation
var multiBuy = new ProductMultiBuy
{
Id = Guid.NewGuid(),
ProductId = createVariationDto.ProductId,
Name = createVariationDto.Name,
Description = createVariationDto.Description,
Quantity = createVariationDto.Quantity,
Price = createVariationDto.Price,
ProductId = createMultiBuyDto.ProductId,
Name = createMultiBuyDto.Name,
Description = createMultiBuyDto.Description,
Quantity = createMultiBuyDto.Quantity,
Price = createMultiBuyDto.Price,
PricePerUnit = pricePerUnit,
SortOrder = createVariationDto.SortOrder,
SortOrder = createMultiBuyDto.SortOrder,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.ProductVariations.Add(variation);
_context.ProductMultiBuys.Add(multiBuy);
try
{
@@ -415,74 +417,74 @@ public class ProductService : IProductService
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE constraint failed") == true)
{
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
throw new ArgumentException($"A multi-buy with quantity {createMultiBuyDto.Quantity} already exists for this product");
}
return new ProductVariationDto
return new ProductMultiBuyDto
{
Id = variation.Id,
ProductId = variation.ProductId,
Name = variation.Name,
Description = variation.Description,
Quantity = variation.Quantity,
Price = variation.Price,
PricePerUnit = variation.PricePerUnit,
SortOrder = variation.SortOrder,
IsActive = variation.IsActive,
CreatedAt = variation.CreatedAt,
UpdatedAt = variation.UpdatedAt
Id = multiBuy.Id,
ProductId = multiBuy.ProductId,
Name = multiBuy.Name,
Description = multiBuy.Description,
Quantity = multiBuy.Quantity,
Price = multiBuy.Price,
PricePerUnit = multiBuy.PricePerUnit,
SortOrder = multiBuy.SortOrder,
IsActive = multiBuy.IsActive,
CreatedAt = multiBuy.CreatedAt,
UpdatedAt = multiBuy.UpdatedAt
};
}
public async Task<bool> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto)
public async Task<bool> UpdateProductMultiBuyAsync(Guid id, UpdateProductMultiBuyDto updateMultiBuyDto)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return false;
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return false;
if (!string.IsNullOrEmpty(updateVariationDto.Name))
variation.Name = updateVariationDto.Name;
if (!string.IsNullOrEmpty(updateMultiBuyDto.Name))
multiBuy.Name = updateMultiBuyDto.Name;
if (!string.IsNullOrEmpty(updateVariationDto.Description))
variation.Description = updateVariationDto.Description;
if (!string.IsNullOrEmpty(updateMultiBuyDto.Description))
multiBuy.Description = updateMultiBuyDto.Description;
if (updateVariationDto.Quantity.HasValue)
variation.Quantity = updateVariationDto.Quantity.Value;
if (updateMultiBuyDto.Quantity.HasValue)
multiBuy.Quantity = updateMultiBuyDto.Quantity.Value;
if (updateVariationDto.Price.HasValue)
variation.Price = updateVariationDto.Price.Value;
if (updateMultiBuyDto.Price.HasValue)
multiBuy.Price = updateMultiBuyDto.Price.Value;
if (updateVariationDto.Quantity.HasValue || updateVariationDto.Price.HasValue)
variation.PricePerUnit = variation.Price / variation.Quantity;
if (updateMultiBuyDto.Quantity.HasValue || updateMultiBuyDto.Price.HasValue)
multiBuy.PricePerUnit = multiBuy.Price / multiBuy.Quantity;
if (updateVariationDto.SortOrder.HasValue)
variation.SortOrder = updateVariationDto.SortOrder.Value;
if (updateMultiBuyDto.SortOrder.HasValue)
multiBuy.SortOrder = updateMultiBuyDto.SortOrder.Value;
if (updateVariationDto.IsActive.HasValue)
variation.IsActive = updateVariationDto.IsActive.Value;
if (updateMultiBuyDto.IsActive.HasValue)
multiBuy.IsActive = updateMultiBuyDto.IsActive.Value;
variation.UpdatedAt = DateTime.UtcNow;
multiBuy.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductVariationAsync(Guid id)
public async Task<bool> DeleteProductMultiBuyAsync(Guid id)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return false;
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return false;
variation.IsActive = false;
variation.UpdatedAt = DateTime.UtcNow;
multiBuy.IsActive = false;
multiBuy.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId)
public async Task<IEnumerable<ProductMultiBuyDto>> GetProductMultiBuysAsync(Guid productId)
{
return await _context.ProductVariations
return await _context.ProductMultiBuys
.Where(v => v.ProductId == productId && v.IsActive)
.OrderBy(v => v.SortOrder)
.Select(v => new ProductVariationDto
.Select(v => new ProductMultiBuyDto
{
Id = v.Id,
ProductId = v.ProductId,
@@ -499,24 +501,145 @@ public class ProductService : IProductService
.ToListAsync();
}
public async Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id)
public async Task<ProductMultiBuyDto?> GetProductMultiBuyByIdAsync(Guid id)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return null;
var multiBuy = await _context.ProductMultiBuys.FindAsync(id);
if (multiBuy == null) return null;
return new ProductVariationDto
return new ProductMultiBuyDto
{
Id = variation.Id,
ProductId = variation.ProductId,
Name = variation.Name,
Description = variation.Description,
Quantity = variation.Quantity,
Price = variation.Price,
PricePerUnit = variation.PricePerUnit,
SortOrder = variation.SortOrder,
IsActive = variation.IsActive,
CreatedAt = variation.CreatedAt,
UpdatedAt = variation.UpdatedAt
Id = multiBuy.Id,
ProductId = multiBuy.ProductId,
Name = multiBuy.Name,
Description = multiBuy.Description,
Quantity = multiBuy.Quantity,
Price = multiBuy.Price,
PricePerUnit = multiBuy.PricePerUnit,
SortOrder = multiBuy.SortOrder,
IsActive = multiBuy.IsActive,
CreatedAt = multiBuy.CreatedAt,
UpdatedAt = multiBuy.UpdatedAt
};
}
// Product Variant Methods (for color/flavor options)
public async Task<ProductVariantDto> CreateProductVariantAsync(CreateProductVariantDto createVariantDto)
{
var product = await _context.Products.FindAsync(createVariantDto.ProductId);
if (product == null)
throw new ArgumentException("Product not found");
// Check if variant with this name already exists
var existingVariant = await _context.ProductVariants
.FirstOrDefaultAsync(v => v.ProductId == createVariantDto.ProductId &&
v.Name == createVariantDto.Name &&
v.IsActive);
if (existingVariant != null)
throw new ArgumentException($"A variant named '{createVariantDto.Name}' already exists for this product");
var variant = new ProductVariant
{
Id = Guid.NewGuid(),
ProductId = createVariantDto.ProductId,
Name = createVariantDto.Name,
VariantType = createVariantDto.VariantType,
SortOrder = createVariantDto.SortOrder,
StockLevel = createVariantDto.StockLevel,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.ProductVariants.Add(variant);
await _context.SaveChangesAsync();
return new ProductVariantDto
{
Id = variant.Id,
ProductId = variant.ProductId,
Name = variant.Name,
VariantType = variant.VariantType,
SortOrder = variant.SortOrder,
StockLevel = variant.StockLevel,
IsActive = variant.IsActive,
CreatedAt = variant.CreatedAt,
UpdatedAt = variant.UpdatedAt
};
}
public async Task<bool> UpdateProductVariantAsync(Guid id, UpdateProductVariantDto updateVariantDto)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return false;
if (!string.IsNullOrEmpty(updateVariantDto.Name))
variant.Name = updateVariantDto.Name;
if (!string.IsNullOrEmpty(updateVariantDto.VariantType))
variant.VariantType = updateVariantDto.VariantType;
if (updateVariantDto.SortOrder.HasValue)
variant.SortOrder = updateVariantDto.SortOrder.Value;
if (updateVariantDto.StockLevel.HasValue)
variant.StockLevel = updateVariantDto.StockLevel.Value;
if (updateVariantDto.IsActive.HasValue)
variant.IsActive = updateVariantDto.IsActive.Value;
variant.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductVariantAsync(Guid id)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return false;
variant.IsActive = false;
variant.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProductVariantDto>> GetProductVariantsAsync(Guid productId)
{
return await _context.ProductVariants
.Where(v => v.ProductId == productId && v.IsActive)
.OrderBy(v => v.SortOrder)
.Select(v => new ProductVariantDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
VariantType = v.VariantType,
SortOrder = v.SortOrder,
StockLevel = v.StockLevel,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
})
.ToListAsync();
}
public async Task<ProductVariantDto?> GetProductVariantByIdAsync(Guid id)
{
var variant = await _context.ProductVariants.FindAsync(id);
if (variant == null) return null;
return new ProductVariantDto
{
Id = variant.Id,
ProductId = variant.ProductId,
Name = variant.Name,
VariantType = variant.VariantType,
SortOrder = variant.SortOrder,
StockLevel = variant.StockLevel,
IsActive = variant.IsActive,
CreatedAt = variant.CreatedAt,
UpdatedAt = variant.UpdatedAt
};
}
}

View File

@@ -0,0 +1,150 @@
@model List<LittleShop.Controllers.BotDirectoryDto>
@{
ViewData["Title"] = "Bot Directory";
Layout = "_PublicLayout";
}
<div class="container mt-4">
<div class="row">
<div class="col-12">
<h1 class="display-4 text-center mb-4">
<i class="bi bi-robot"></i> Shop Assistant Bots
</h1>
<p class="text-center text-muted mb-5">
Connect with our shopping assistant bots on Telegram. Scan the QR code or click the username to start chatting!
</p>
</div>
</div>
<div class="row g-4">
@foreach (var bot in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 shadow-sm bot-card">
<div class="card-header bg-gradient text-white" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-robot"></i> @bot.Name
</h5>
<span class="badge bg-@bot.GetBadgeColor()">@bot.Type</span>
</div>
</div>
<div class="card-body">
<div class="text-center mb-3">
@if (!string.IsNullOrEmpty(bot.TelegramUsername))
{
<img src="/bots/qr/@bot.Id" alt="QR Code for @bot.Name" class="img-fluid qr-code" style="max-width: 200px;" />
<div class="mt-3">
<a href="https://t.me/@bot.TelegramUsername" target="_blank" class="btn btn-primary btn-lg">
<i class="bi bi-telegram"></i> @@@bot.TelegramUsername
</a>
</div>
}
else
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> Bot configuration pending
</div>
}
</div>
@if (!string.IsNullOrEmpty(bot.Description))
{
<p class="text-muted">@bot.Description</p>
}
@if (!string.IsNullOrEmpty(bot.PersonalityName))
{
<p class="mb-2">
<small class="text-muted">
<i class="bi bi-person-badge"></i> Personality: <strong>@bot.PersonalityName</strong>
</small>
</p>
}
<div class="d-flex justify-content-between align-items-center mt-3">
<span class="badge bg-@bot.GetStatusColor()">
<i class="bi bi-circle-fill"></i> @bot.GetStatusBadge()
</span>
<small class="text-muted">
Since @bot.CreatedAt.ToString("MMM dd, yyyy")
</small>
</div>
</div>
</div>
</div>
}
@if (!Model.Any())
{
<div class="col-12">
<div class="alert alert-info text-center">
<h4 class="alert-heading">No Bots Available</h4>
<p>There are currently no active bots in the directory. Please check back later!</p>
</div>
</div>
}
</div>
<div class="row mt-5">
<div class="col-12">
<div class="card bg-light">
<div class="card-body text-center">
<h5 class="card-title">How to Connect</h5>
<ol class="text-start" style="max-width: 600px; margin: 0 auto;">
<li>Open Telegram on your mobile device</li>
<li>Scan the QR code with your camera or click the bot username</li>
<li>Press "Start" to begin chatting with the bot</li>
<li>Browse products, add items to cart, and checkout securely</li>
</ol>
<hr>
<p class="text-muted mb-0">
<i class="bi bi-shield-check"></i> All transactions are secure and encrypted
</p>
</div>
</div>
</div>
</div>
</div>
<style>
.bot-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: none;
overflow: hidden;
}
.bot-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.15) !important;
}
.qr-code {
border: 4px solid #f8f9fa;
border-radius: 8px;
padding: 10px;
background: white;
}
.badge {
font-weight: 500;
}
@@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
}
}
.btn-primary {
animation: pulse 2s infinite;
}
</style>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - LittleShop</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">
<i class="bi bi-shop"></i> LittleShop
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/bots">
<i class="bi bi-robot"></i> Bot Directory
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/api">
<i class="bi bi-code-slash"></i> API
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Admin" asp-controller="Account" asp-action="Login">
<i class="bi bi-box-arrow-in-right"></i> Admin Login
</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container text-center">
&copy; @DateTime.Now.Year - LittleShop - Powered by Bots
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>