feat: Add customer management, payments, and push notifications with security enhancements
Some checks failed
Build and Deploy LittleShop / Build TeleBot Docker Image (push) Failing after 11s
Build and Deploy LittleShop / Build LittleShop Docker Image (push) Failing after 15s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Has been skipped

Major Feature Additions:
- Customer management: Full CRUD with data export and privacy compliance
- Payment management: Centralized payment tracking and administration
- Push notification subscriptions: Manage and track web push subscriptions

Security Enhancements:
- IP whitelist middleware for administrative endpoints
- Data retention service with configurable policies
- Enhanced push notification security documentation
- Security fixes progress tracking (2025-11-14)

UI/UX Improvements:
- Enhanced navigation with improved mobile responsiveness
- Updated admin dashboard with order status counts
- Improved product CRUD forms
- New customer and payment management interfaces

Backend Improvements:
- Extended customer service with data export capabilities
- Enhanced order service with status count queries
- Improved crypto payment service with better error handling
- Updated validators and configuration

Documentation:
- DEPLOYMENT_NGINX_GUIDE.md: Nginx deployment instructions
- IP_STORAGE_ANALYSIS.md: IP storage security analysis
- PUSH_NOTIFICATION_SECURITY.md: Push notification security guide
- UI_UX_IMPROVEMENT_PLAN.md: Planned UI/UX enhancements
- UI_UX_IMPROVEMENTS_COMPLETED.md: Completed improvements

Cleanup:
- Removed temporary database WAL files
- Removed stale commit message file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-16 19:33:02 +00:00
parent 47e43d4ff8
commit a2247d7c02
45 changed files with 5302 additions and 371 deletions

View File

@@ -28,14 +28,12 @@ public class AccountController : Controller
}
[HttpPost]
// [ValidateAntiForgeryToken] // Temporarily disabled for HTTPS proxy issue
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(string Username, string Password)
{
// Make parameters case-insensitive for form compatibility
var username = Username?.ToLowerInvariant();
var password = Password;
Console.WriteLine($"Received Username: '{username}', Password: '{password}'");
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{

View File

@@ -74,16 +74,6 @@ public class BotsController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Wizard(BotWizardDto dto)
{
Console.WriteLine("=== BOT WIZARD DEBUG ===");
Console.WriteLine($"Received: BotName='{dto?.BotName}', BotUsername='{dto?.BotUsername}', PersonalityName='{dto?.PersonalityName}'");
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
Console.WriteLine("Raw form data:");
foreach (var key in Request.Form.Keys)
{
Console.WriteLine($" {key} = '{Request.Form[key]}'");
}
Console.WriteLine("========================");
_logger.LogInformation("Wizard POST received - BotName: '{BotName}', BotUsername: '{BotUsername}'", dto.BotName, dto.BotUsername);
if (!ModelState.IsValid)
@@ -102,7 +92,6 @@ public class BotsController : Controller
var personalities = new[] { "Alan", "Dave", "Sarah", "Mike", "Emma", "Tom" };
var random = new Random();
dto.PersonalityName = personalities[random.Next(personalities.Length)];
Console.WriteLine($"Auto-assigned personality: {dto.PersonalityName}");
}
// Generate BotFather commands

View File

@@ -31,15 +31,8 @@ public class CategoriesController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCategoryDto model)
{
Console.WriteLine($"Received Category: Name='{model?.Name}', Description='{model?.Description}'");
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
if (!ModelState.IsValid)
{
foreach (var error in ModelState)
{
Console.WriteLine($"ModelState Error - Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
return View(model);
}
@@ -70,18 +63,8 @@ public class CategoriesController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(Guid id, UpdateCategoryDto model)
{
Console.WriteLine($"Edit POST - CategoryId: {id}");
Console.WriteLine($"Edit POST - Name: '{model?.Name}'");
Console.WriteLine($"Edit POST - Description: '{model?.Description}'");
Console.WriteLine($"Edit POST - IsActive: {model?.IsActive} (HasValue: {model?.IsActive.HasValue})");
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
if (!ModelState.IsValid)
{
foreach (var error in ModelState)
{
Console.WriteLine($"ModelState Error - Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
ViewData["CategoryId"] = id;
return View(model);
}

View File

@@ -0,0 +1,339 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.DTOs;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class CustomersController : Controller
{
private readonly ICustomerService _customerService;
private readonly IOrderService _orderService;
private readonly ILogger<CustomersController> _logger;
public CustomersController(
ICustomerService customerService,
IOrderService orderService,
ILogger<CustomersController> logger)
{
_customerService = customerService;
_orderService = orderService;
_logger = logger;
}
public async Task<IActionResult> Index(string searchTerm = "")
{
try
{
IEnumerable<CustomerDto> customers;
if (!string.IsNullOrWhiteSpace(searchTerm))
{
customers = await _customerService.SearchCustomersAsync(searchTerm);
ViewData["SearchTerm"] = searchTerm;
}
else
{
customers = await _customerService.GetAllCustomersAsync();
}
return View(customers.OrderByDescending(c => c.CreatedAt));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading customers index");
TempData["ErrorMessage"] = "Error loading customers";
return View(new List<CustomerDto>());
}
}
public async Task<IActionResult> Details(Guid id)
{
try
{
var customer = await _customerService.GetCustomerByIdAsync(id);
if (customer == null)
{
TempData["ErrorMessage"] = "Customer not found";
return RedirectToAction(nameof(Index));
}
// Get customer's order history
var allOrders = await _orderService.GetAllOrdersAsync();
var customerOrders = allOrders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.ToList();
ViewData["CustomerOrders"] = customerOrders;
return View(customer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error loading customer details";
return RedirectToAction(nameof(Index));
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Block(Guid id, string reason)
{
try
{
if (string.IsNullOrWhiteSpace(reason))
{
TempData["ErrorMessage"] = "Block reason is required";
return RedirectToAction(nameof(Details), new { id });
}
var success = await _customerService.BlockCustomerAsync(id, reason);
if (success)
{
_logger.LogWarning("Customer {CustomerId} blocked by admin. Reason: {Reason}", id, reason);
TempData["SuccessMessage"] = "Customer blocked successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to block customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error blocking customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error blocking customer";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unblock(Guid id)
{
try
{
var success = await _customerService.UnblockCustomerAsync(id);
if (success)
{
_logger.LogInformation("Customer {CustomerId} unblocked by admin", id);
TempData["SuccessMessage"] = "Customer unblocked successfully";
}
else
{
TempData["ErrorMessage"] = "Failed to unblock customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unblocking customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error unblocking customer";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RefreshRiskScore(Guid id)
{
try
{
await _customerService.UpdateCustomerMetricsAsync(id);
TempData["SuccessMessage"] = "Risk score recalculated successfully";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing risk score for customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error refreshing risk score";
}
return RedirectToAction(nameof(Details), new { id });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Guid id)
{
try
{
var success = await _customerService.DeleteCustomerAsync(id);
if (success)
{
_logger.LogWarning("Customer {CustomerId} deleted by admin", id);
TempData["SuccessMessage"] = "Customer deleted successfully (soft delete - data retained)";
return RedirectToAction(nameof(Index));
}
else
{
TempData["ErrorMessage"] = "Failed to delete customer";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting customer {CustomerId}", id);
TempData["ErrorMessage"] = "Error deleting customer";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Export customer data as JSON (GDPR "Right to Data Portability")
/// </summary>
[HttpGet]
public async Task<IActionResult> ExportJson(Guid id)
{
try
{
var exportData = await _customerService.GetCustomerDataForExportAsync(id);
if (exportData == null)
{
return NotFound();
}
var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json";
var jsonOptions = new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
};
var jsonData = System.Text.Json.JsonSerializer.Serialize(exportData, jsonOptions);
_logger.LogInformation("Customer {CustomerId} data exported as JSON by admin", id);
return File(System.Text.Encoding.UTF8.GetBytes(jsonData), "application/json", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customer {CustomerId} data as JSON", id);
TempData["ErrorMessage"] = "Error generating JSON export";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Export customer data as CSV (GDPR "Right to Data Portability")
/// </summary>
[HttpGet]
public async Task<IActionResult> ExportCsv(Guid id)
{
try
{
var exportData = await _customerService.GetCustomerDataForExportAsync(id);
if (exportData == null)
{
return NotFound();
}
var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
var csvData = GenerateCsvExport(exportData);
_logger.LogInformation("Customer {CustomerId} data exported as CSV by admin", id);
return File(System.Text.Encoding.UTF8.GetBytes(csvData), "text/csv", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting customer {CustomerId} data as CSV", id);
TempData["ErrorMessage"] = "Error generating CSV export";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Helper method to generate CSV export from customer data
/// </summary>
private string GenerateCsvExport(LittleShop.DTOs.CustomerDataExportDto exportData)
{
var csv = new System.Text.StringBuilder();
// Customer Profile Section
csv.AppendLine("CUSTOMER PROFILE");
csv.AppendLine("Field,Value");
csv.AppendLine($"Customer ID,{exportData.CustomerId}");
csv.AppendLine($"Telegram User ID,{exportData.TelegramUserId}");
csv.AppendLine($"Telegram Username,\"{EscapeCsv(exportData.TelegramUsername)}\"");
csv.AppendLine($"Telegram Display Name,\"{EscapeCsv(exportData.TelegramDisplayName)}\"");
csv.AppendLine($"First Name,\"{EscapeCsv(exportData.TelegramFirstName)}\"");
csv.AppendLine($"Last Name,\"{EscapeCsv(exportData.TelegramLastName)}\"");
csv.AppendLine($"Email,\"{EscapeCsv(exportData.Email)}\"");
csv.AppendLine($"Phone,\"{EscapeCsv(exportData.PhoneNumber)}\"");
csv.AppendLine($"Allow Marketing,{exportData.AllowMarketing}");
csv.AppendLine($"Allow Order Updates,{exportData.AllowOrderUpdates}");
csv.AppendLine($"Language,{exportData.Language}");
csv.AppendLine($"Timezone,{exportData.Timezone}");
csv.AppendLine($"Total Orders,{exportData.TotalOrders}");
csv.AppendLine($"Total Spent,{exportData.TotalSpent:F2}");
csv.AppendLine($"Average Order Value,{exportData.AverageOrderValue:F2}");
csv.AppendLine($"Is Blocked,{exportData.IsBlocked}");
csv.AppendLine($"Block Reason,\"{EscapeCsv(exportData.BlockReason)}\"");
csv.AppendLine($"Risk Score,{exportData.RiskScore}");
csv.AppendLine($"Created At,{exportData.CreatedAt:yyyy-MM-dd HH:mm:ss}");
csv.AppendLine($"Updated At,{exportData.UpdatedAt:yyyy-MM-dd HH:mm:ss}");
csv.AppendLine();
// Orders Section
csv.AppendLine("ORDERS");
csv.AppendLine("Order ID,Status,Total Amount,Currency,Order Date,Shipping Name,Shipping Address,City,Post Code,Country,Tracking Number");
foreach (var order in exportData.Orders)
{
csv.AppendLine($"{order.OrderId},{order.Status},{order.TotalAmount:F2},{order.Currency},{order.OrderDate:yyyy-MM-dd HH:mm:ss}," +
$"\"{EscapeCsv(order.ShippingName)}\",\"{EscapeCsv(order.ShippingAddress)}\",\"{EscapeCsv(order.ShippingCity)}\"," +
$"\"{order.ShippingPostCode}\",\"{order.ShippingCountry}\",\"{EscapeCsv(order.TrackingNumber)}\"");
// Order Items sub-section
if (order.Items.Any())
{
csv.AppendLine(" Order Items:");
csv.AppendLine(" Product Name,Variant,Quantity,Unit Price,Total Price");
foreach (var item in order.Items)
{
csv.AppendLine($" \"{EscapeCsv(item.ProductName)}\",\"{EscapeCsv(item.VariantName)}\"," +
$"{item.Quantity},{item.UnitPrice:F2},{item.TotalPrice:F2}");
}
}
}
csv.AppendLine();
// Messages Section
if (exportData.Messages.Any())
{
csv.AppendLine("MESSAGES");
csv.AppendLine("Sent At,Type,Content,Was Read,Read At");
foreach (var message in exportData.Messages)
{
csv.AppendLine($"{message.SentAt:yyyy-MM-dd HH:mm:ss},{message.MessageType}," +
$"\"{EscapeCsv(message.Content)}\",{message.WasRead}," +
$"{(message.ReadAt.HasValue ? message.ReadAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "")}");
}
csv.AppendLine();
}
// Reviews Section
if (exportData.Reviews.Any())
{
csv.AppendLine("REVIEWS");
csv.AppendLine("Product Name,Rating,Comment,Created At,Is Approved,Is Verified Purchase");
foreach (var review in exportData.Reviews)
{
csv.AppendLine($"\"{EscapeCsv(review.ProductName)}\",{review.Rating}," +
$"\"{EscapeCsv(review.Comment)}\",{review.CreatedAt:yyyy-MM-dd HH:mm:ss}," +
$"{review.IsApproved},{review.IsVerifiedPurchase}");
}
}
return csv.ToString();
}
/// <summary>
/// Escape CSV values containing quotes, commas, or newlines
/// </summary>
private string EscapeCsv(string? value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value.Replace("\"", "\"\""); // Escape quotes by doubling them
}
}

View File

@@ -56,12 +56,13 @@ public class OrdersController : Controller
break;
}
// Get workflow counts for tab badges
ViewData["PendingCount"] = (await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.PendingPayment)).Count();
ViewData["AcceptCount"] = (await _orderService.GetOrdersRequiringActionAsync()).Count();
ViewData["PackingCount"] = (await _orderService.GetOrdersForPackingAsync()).Count();
ViewData["DispatchedCount"] = (await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Dispatched)).Count();
ViewData["OnHoldCount"] = (await _orderService.GetOrdersOnHoldAsync()).Count();
// Get workflow counts for tab badges (single optimized query)
var statusCounts = await _orderService.GetOrderStatusCountsAsync();
ViewData["PendingCount"] = statusCounts.PendingPaymentCount;
ViewData["AcceptCount"] = statusCounts.RequiringActionCount;
ViewData["PackingCount"] = statusCounts.ForPackingCount;
ViewData["DispatchedCount"] = statusCounts.DispatchedCount;
ViewData["OnHoldCount"] = statusCounts.OnHoldCount;
return View();
}
@@ -137,15 +138,6 @@ public class OrdersController : Controller
{
if (!ModelState.IsValid)
{
// Log validation errors for debugging
foreach (var error in ModelState)
{
if (error.Value?.Errors.Count > 0)
{
Console.WriteLine($"Validation error for {error.Key}: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
}
// Return to details page with error
var order = await _orderService.GetOrderByIdAsync(id);
if (order == null)

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class PaymentsController : Controller
{
private readonly ICryptoPaymentService _paymentService;
private readonly IOrderService _orderService;
private readonly ILogger<PaymentsController> _logger;
public PaymentsController(
ICryptoPaymentService paymentService,
IOrderService orderService,
ILogger<PaymentsController> _logger)
{
_paymentService = paymentService;
_orderService = orderService;
this._logger = _logger;
}
//GET: Admin/Payments
public async Task<IActionResult> Index(string? status = null)
{
try
{
var payments = await _paymentService.GetAllPaymentsAsync();
// Filter by status if provided
if (!string.IsNullOrEmpty(status) && Enum.TryParse<PaymentStatus>(status, out var paymentStatus))
{
payments = payments.Where(p => p.Status == paymentStatus);
ViewData["CurrentStatus"] = status;
}
// Get orders for payment-order linking
var allOrders = await _orderService.GetAllOrdersAsync();
ViewData["Orders"] = allOrders.ToDictionary(o => o.Id, o => o);
return View(payments.OrderByDescending(p => p.CreatedAt));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving payments list");
TempData["ErrorMessage"] = "Failed to load payments. Please try again.";
return View(Enumerable.Empty<LittleShop.DTOs.CryptoPaymentDto>());
}
}
}

View File

@@ -14,13 +14,17 @@ public class ProductsController : Controller
private readonly ICategoryService _categoryService;
private readonly IProductImportService _importService;
private readonly IVariantCollectionService _variantCollectionService;
private readonly IReviewService _reviewService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductService productService, ICategoryService categoryService, IProductImportService importService, IVariantCollectionService variantCollectionService)
public ProductsController(IProductService productService, ICategoryService categoryService, IProductImportService importService, IVariantCollectionService variantCollectionService, IReviewService reviewService, ILogger<ProductsController> logger)
{
_productService = productService;
_categoryService = categoryService;
_importService = importService;
_variantCollectionService = variantCollectionService;
_reviewService = reviewService;
_logger = logger;
}
public async Task<IActionResult> Index()
@@ -49,24 +53,11 @@ public class ProductsController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateProductDto model)
{
Console.WriteLine($"Received Product: Name='{model?.Name}', Description='{model?.Description}', Price={model?.Price}, Stock={model?.StockQuantity}");
Console.WriteLine($"CategoryId: {model?.CategoryId}");
Console.WriteLine($"Weight: {model?.Weight}, WeightUnit: {model?.WeightUnit}");
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
// Remove Description validation errors since it's optional
ModelState.Remove("Description");
if (!ModelState.IsValid)
{
Console.WriteLine("Validation errors:");
foreach (var error in ModelState)
{
if (error.Value?.Errors.Count > 0)
{
Console.WriteLine($" {error.Key}: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}");
}
}
var categories = await _categoryService.GetAllCategoriesAsync();
ViewData["Categories"] = categories.Where(c => c.IsActive);
@@ -114,32 +105,9 @@ public class ProductsController : Controller
product.VariantsJson = System.Text.Json.JsonSerializer.Serialize(variantsForJson);
}
// TODO: Add ReviewService injection and retrieve actual reviews
// For now, providing mock review data for demonstration
ViewData["ProductReviews"] = new[]
{
new {
Rating = 5,
CustomerName = "John D.",
Comment = "Excellent quality! Exceeded my expectations.",
CreatedAt = DateTime.Now.AddDays(-7),
OrderReference = "ORD-123456"
},
new {
Rating = 4,
CustomerName = "Sarah M.",
Comment = "Very good product, fast delivery.",
CreatedAt = DateTime.Now.AddDays(-14),
OrderReference = "ORD-789012"
},
new {
Rating = 5,
CustomerName = (string?)null, // Anonymous
Comment = "Love it! Will order again.",
CreatedAt = DateTime.Now.AddDays(-21),
OrderReference = "ORD-345678"
}
};
// Load actual product reviews from ReviewService
var productReviews = await _reviewService.GetReviewsByProductAsync(id, approvedOnly: true);
ViewData["ProductReviews"] = productReviews;
var model = new UpdateProductDto
{
@@ -325,16 +293,29 @@ public class ProductsController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteAllSalesData()
public async Task<IActionResult> DeleteAllSalesData(string confirmText)
{
// Require explicit typed confirmation
if (confirmText != "DELETE ALL SALES DATA")
{
TempData["ErrorMessage"] = "❌ Confirmation text did not match. Operation cancelled for safety.";
return RedirectToAction(nameof(Index));
}
try
{
var deletedCount = await _importService.DeleteAllOrdersAndSalesDataAsync();
// Log this critical action
_logger.LogWarning("CRITICAL: User {UserId} deleted ALL sales data ({Count} records)",
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, deletedCount);
TempData["SuccessMessage"] = $"✅ Successfully deleted {deletedCount} sales records (orders, payments, customers, messages)";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete all sales data");
TempData["ErrorMessage"] = $"Failed to delete sales data: {ex.Message}";
return RedirectToAction(nameof(Index));
}

View File

@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.Services;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class PushSubscriptionsController : Controller
{
private readonly IPushNotificationService _pushService;
private readonly ILogger<PushSubscriptionsController> _logger;
public PushSubscriptionsController(
IPushNotificationService pushService,
ILogger<PushSubscriptionsController> logger)
{
_pushService = pushService;
_logger = logger;
}
// GET: Admin/PushSubscriptions
public async Task<IActionResult> Index()
{
try
{
var subscriptions = await _pushService.GetActiveSubscriptionsAsync();
return View(subscriptions.OrderByDescending(s => s.SubscribedAt));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving push subscriptions list");
TempData["ErrorMessage"] = "Failed to load push subscriptions. Please try again.";
return View(new List<LittleShop.Models.PushSubscription>());
}
}
// POST: Admin/PushSubscriptions/Delete/{id}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
try
{
// Find subscription by ID and delete via UnsubscribeAsync (which uses endpoint)
var subscriptions = await _pushService.GetActiveSubscriptionsAsync();
var subscription = subscriptions.FirstOrDefault(s => s.Id == id);
if (subscription == null)
{
TempData["ErrorMessage"] = "Push subscription not found.";
return RedirectToAction(nameof(Index));
}
var success = await _pushService.UnsubscribeAsync(subscription.Endpoint);
if (success)
{
_logger.LogInformation("Deleted push subscription {Id} (Endpoint: {Endpoint})", id, subscription.Endpoint);
TempData["SuccessMessage"] = "Push subscription deleted successfully.";
}
else
{
_logger.LogWarning("Failed to delete push subscription {Id}", id);
TempData["ErrorMessage"] = "Failed to delete push subscription.";
}
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting push subscription {Id}", id);
TempData["ErrorMessage"] = $"Error deleting push subscription: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
// POST: Admin/PushSubscriptions/CleanupExpired
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CleanupExpired()
{
try
{
var deletedCount = await _pushService.CleanupExpiredSubscriptionsAsync();
_logger.LogInformation("Cleaned up {Count} expired push subscriptions", deletedCount);
if (deletedCount > 0)
{
TempData["SuccessMessage"] = $"Successfully cleaned up {deletedCount} expired subscription(s).";
}
else
{
TempData["InfoMessage"] = "No expired subscriptions found to clean up.";
}
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cleaning up expired push subscriptions");
TempData["ErrorMessage"] = $"Error during cleanup: {ex.Message}";
return RedirectToAction(nameof(Index));
}
}
}

View File

@@ -55,6 +55,7 @@ public class ReviewsController : Controller
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Approve(Guid id)
{
try
@@ -86,6 +87,7 @@ public class ReviewsController : Controller
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Guid id)
{
try

View File

@@ -86,9 +86,9 @@ public class UsersController : Controller
}
// Validate password if provided
if (!string.IsNullOrEmpty(model.Password) && model.Password.Length < 3)
if (!string.IsNullOrEmpty(model.Password) && model.Password.Length < 8)
{
ModelState.AddModelError("Password", "Password must be at least 3 characters if changing");
ModelState.AddModelError("Password", "Password must be at least 8 characters if changing");
}
if (!ModelState.IsValid)

View File

@@ -0,0 +1,445 @@
@model LittleShop.DTOs.CustomerDto
@{
ViewData["Title"] = $"Customer: {Model.TelegramDisplayName}";
var customerOrders = ViewData["CustomerOrders"] as List<LittleShop.DTOs.OrderDto> ?? new List<LittleShop.DTOs.OrderDto>();
}
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Dashboard")">Dashboard</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Customers")">Customers</a></li>
<li class="breadcrumb-item active">@Model.TelegramDisplayName</li>
</ol>
</nav>
</div>
</div>
<!-- Customer Header -->
<div class="row mb-4">
<div class="col-md-8">
<h1>
<i class="fas fa-user-circle"></i> @Model.TelegramDisplayName
@if (Model.IsBlocked)
{
<span class="badge bg-danger ms-2">
<i class="fas fa-ban"></i> BLOCKED
</span>
}
else if (!Model.IsActive)
{
<span class="badge bg-secondary ms-2">
<i class="fas fa-trash"></i> DELETED
</span>
}
else
{
<span class="badge bg-success ms-2">
<i class="fas fa-check-circle"></i> ACTIVE
</span>
}
</h1>
<p class="text-muted mb-0">
Telegram: @@<strong>@Model.TelegramUsername</strong> | ID: @Model.TelegramUserId
</p>
</div>
<div class="col-md-4 text-end">
<!-- GDPR Data Export Buttons -->
<div class="btn-group me-2" role="group">
<a href="@Url.Action("ExportJson", "Customers", new { id = Model.Id })"
class="btn btn-info"
title="Export all customer data as JSON (GDPR Right to Data Portability)">
<i class="fas fa-file-code"></i> Export JSON
</a>
<a href="@Url.Action("ExportCsv", "Customers", new { id = Model.Id })"
class="btn btn-success"
title="Export all customer data as CSV (GDPR Right to Data Portability)">
<i class="fas fa-file-csv"></i> Export CSV
</a>
</div>
<a href="@Url.Action("Index", "Customers")" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to List
</a>
</div>
</div>
<!-- Customer Information Cards -->
<div class="row mb-4">
<!-- Basic Information -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-id-card"></i> Customer Information</h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr>
<th style="width: 40%;">Display Name:</th>
<td>@Model.TelegramDisplayName</td>
</tr>
<tr>
<th>Full Name:</th>
<td>
@if (!string.IsNullOrEmpty(Model.TelegramFirstName) || !string.IsNullOrEmpty(Model.TelegramLastName))
{
@Model.TelegramFirstName @Model.TelegramLastName
}
else
{
<span class="text-muted">Not provided</span>
}
</td>
</tr>
<tr>
<th>Telegram Username:</th>
<td>@@<strong>@Model.TelegramUsername</strong></td>
</tr>
<tr>
<th>Telegram User ID:</th>
<td><code>@Model.TelegramUserId</code></td>
</tr>
<tr>
<th>Language:</th>
<td>@Model.Language.ToUpper()</td>
</tr>
<tr>
<th>Timezone:</th>
<td>@Model.Timezone</td>
</tr>
<tr>
<th>Customer Since:</th>
<td>@Model.CreatedAt.ToString("MMMM dd, yyyy")</td>
</tr>
<tr>
<th>Last Active:</th>
<td>
@if (Model.LastActiveAt > DateTime.MinValue)
{
var daysAgo = (DateTime.UtcNow - Model.LastActiveAt).Days;
@Model.LastActiveAt.ToString("MMMM dd, yyyy HH:mm")
@if (daysAgo <= 1)
{
<span class="badge bg-success">Active today</span>
}
else if (daysAgo <= 7)
{
<span class="badge bg-info">@daysAgo days ago</span>
}
else if (daysAgo <= 30)
{
<span class="badge bg-warning">@daysAgo days ago</span>
}
else
{
<span class="badge bg-danger">@daysAgo days ago</span>
}
}
else
{
<span class="text-muted">Never</span>
}
</td>
</tr>
</table>
</div>
</div>
</div>
<!-- Risk Score & Metrics -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0"><i class="fas fa-shield-alt"></i> Risk Assessment</h5>
</div>
<div class="card-body">
@{
var riskLevel = Model.RiskScore >= 80 ? "Very High Risk" :
Model.RiskScore >= 50 ? "High Risk" :
Model.RiskScore >= 30 ? "Medium Risk" :
"Low Risk";
var riskClass = Model.RiskScore >= 80 ? "danger" :
Model.RiskScore >= 50 ? "warning" :
Model.RiskScore >= 30 ? "info" :
"success";
}
<div class="text-center mb-3">
<h1 class="display-4 text-@riskClass mb-0">
<i class="fas fa-exclamation-triangle"></i> @Model.RiskScore
</h1>
<p class="text-@riskClass fw-bold mb-0">@riskLevel</p>
</div>
<hr>
<table class="table table-sm mb-0">
<tr>
<th style="width: 60%;">Total Orders:</th>
<td class="text-end"><strong>@Model.TotalOrders</strong></td>
</tr>
<tr class="table-success">
<th>Successful Orders:</th>
<td class="text-end text-success"><strong>@Model.SuccessfulOrders</strong></td>
</tr>
<tr class="table-warning">
<th>Cancelled Orders:</th>
<td class="text-end text-warning"><strong>@Model.CancelledOrders</strong></td>
</tr>
<tr class="table-danger">
<th>Disputed Orders:</th>
<td class="text-end text-danger"><strong>@Model.DisputedOrders</strong></td>
</tr>
<tr>
<th>Success Rate:</th>
<td class="text-end">
@if (Model.TotalOrders > 0)
{
var successRate = (Model.SuccessfulOrders * 100.0) / Model.TotalOrders;
@successRate.ToString("F1")<text>%</text>
}
else
{
<span class="text-muted">N/A</span>
}
</td>
</tr>
</table>
<div class="mt-3">
<form method="post" action="@Url.Action("RefreshRiskScore", new { id = Model.Id })" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-warning w-100">
<i class="fas fa-sync-alt"></i> Recalculate Risk Score
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Spending Metrics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h6 class="mb-2">Total Spent</h6>
<h3 class="mb-0">£@Model.TotalSpent.ToString("N2")</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h6 class="mb-2">Average Order Value</h6>
<h3 class="mb-0">£@Model.AverageOrderValue.ToString("N2")</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h6 class="mb-2">First Order</h6>
<h3 class="mb-0" style="font-size: 1.2rem;">
@if (Model.FirstOrderDate > DateTime.MinValue)
{
@Model.FirstOrderDate.ToString("MMM dd, yyyy")
}
else
{
<span>No orders</span>
}
</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body text-center">
<h6 class="mb-2">Last Order</h6>
<h3 class="mb-0" style="font-size: 1.2rem;">
@if (Model.LastOrderDate > DateTime.MinValue)
{
@Model.LastOrderDate.ToString("MMM dd, yyyy")
}
else
{
<span>No orders</span>
}
</h3>
</div>
</div>
</div>
</div>
<!-- Customer Management Actions -->
<div class="row mb-4">
<div class="col">
<div class="card @(Model.IsBlocked ? "border-danger" : "")">
<div class="card-header @(Model.IsBlocked ? "bg-danger text-white" : "bg-light")">
<h5 class="mb-0"><i class="fas fa-cog"></i> Customer Management Actions</h5>
</div>
<div class="card-body">
@if (Model.IsBlocked)
{
<div class="alert alert-danger">
<h5><i class="fas fa-ban"></i> Customer is Blocked</h5>
<p class="mb-0"><strong>Reason:</strong> @Model.BlockReason</p>
</div>
<form method="post" action="@Url.Action("Unblock", new { id = Model.Id })" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-success" onclick="return confirm('Are you sure you want to unblock this customer?')">
<i class="fas fa-check-circle"></i> Unblock Customer
</button>
</form>
}
else
{
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#blockModal">
<i class="fas fa-ban"></i> Block Customer
</button>
}
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#deleteModal">
<i class="fas fa-trash"></i> Delete Customer
</button>
@if (!string.IsNullOrEmpty(Model.CustomerNotes))
{
<hr>
<h6>Admin Notes:</h6>
<p class="text-muted mb-0">@Model.CustomerNotes</p>
}
</div>
</div>
</div>
</div>
<!-- Order History -->
<div class="row">
<div class="col">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-shopping-cart"></i> Order History
<span class="badge bg-primary">@customerOrders.Count</span>
</h5>
</div>
<div class="card-body p-0">
@if (customerOrders.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Order ID</th>
<th>Date</th>
<th>Status</th>
<th class="text-end">Items</th>
<th class="text-end">Total</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var order in customerOrders)
{
<tr>
<td><code>@order.Id.ToString().Substring(0, 8)</code></td>
<td>@order.CreatedAt.ToString("MMM dd, yyyy HH:mm")</td>
<td>
@{
var statusClass = order.Status.ToString() == "Delivered" ? "success" :
order.Status.ToString() == "Cancelled" ? "danger" :
order.Status.ToString() == "PendingPayment" ? "warning" :
"info";
}
<span class="badge bg-@statusClass">@order.Status</span>
</td>
<td class="text-end">@order.Items.Sum(i => i.Quantity)</td>
<td class="text-end"><strong>£@order.TotalAmount.ToString("N2")</strong></td>
<td class="text-center">
<a href="@Url.Action("Details", "Orders", new { id = order.Id })" class="btn btn-sm btn-primary">
<i class="fas fa-eye"></i> View
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="alert alert-info mb-0">
<i class="fas fa-info-circle"></i> No orders yet for this customer.
</div>
}
</div>
</div>
</div>
</div>
<!-- Block Customer Modal -->
<div class="modal fade" id="blockModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="@Url.Action("Block", new { id = Model.Id })">
@Html.AntiForgeryToken()
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="fas fa-ban"></i> Block Customer</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> Blocking this customer will prevent them from placing new orders.
</div>
<div class="mb-3">
<label for="blockReason" class="form-label">
<strong>Reason for blocking:</strong> <span class="text-danger">*</span>
</label>
<textarea name="reason" id="blockReason" class="form-control" rows="3"
placeholder="Enter the reason for blocking this customer..." required></textarea>
<small class="text-muted">This reason will be visible to administrators.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="fas fa-ban"></i> Block Customer
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Customer Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="@Url.Action("Delete", new { id = Model.Id })">
@Html.AntiForgeryToken()
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title"><i class="fas fa-trash"></i> Delete Customer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Note:</strong> This is a soft delete. Customer data will be retained but marked as inactive.
</div>
<p>Are you sure you want to delete customer <strong>@Model.TelegramDisplayName</strong>?</p>
<p class="text-muted mb-0">The customer record and order history will be preserved but hidden from normal views.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="fas fa-trash"></i> Delete Customer
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,232 @@
@model IEnumerable<LittleShop.DTOs.CustomerDto>
@{
ViewData["Title"] = "Customers";
var searchTerm = ViewData["SearchTerm"] as string ?? "";
}
<div class="row mb-3">
<div class="col">
<h1><i class="fas fa-users"></i> Customer Management</h1>
<p class="text-muted mb-0">Manage customer accounts, view order history, and monitor risk scores</p>
</div>
</div>
<!-- Search Bar -->
<div class="row mb-4">
<div class="col-md-6">
<form method="get" action="@Url.Action("Index")" class="input-group">
<input type="text" name="searchTerm" value="@searchTerm"
class="form-control"
placeholder="Search by name, username, Telegram ID, or email...">
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i> Search
</button>
@if (!string.IsNullOrEmpty(searchTerm))
{
<a href="@Url.Action("Index")" class="btn btn-secondary">
<i class="fas fa-times"></i> Clear
</a>
}
</form>
</div>
<div class="col-md-6 text-end">
<span class="text-muted">
<strong>@Model.Count()</strong> customer@(Model.Count() != 1 ? "s" : "") found
</span>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body">
<h6 class="text-muted mb-2">Total Customers</h6>
<h3 class="mb-0">@Model.Count()</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted mb-2">Active</h6>
<h3 class="mb-0 text-success">@Model.Count(c => c.IsActive && !c.IsBlocked)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body">
<h6 class="text-muted mb-2">Blocked</h6>
<h3 class="mb-0 text-danger">@Model.Count(c => c.IsBlocked)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body">
<h6 class="text-muted mb-2">High Risk (>70)</h6>
<h3 class="mb-0 text-warning">@Model.Count(c => c.RiskScore > 70)</h3>
</div>
</div>
</div>
</div>
<!-- Customers Table -->
@if (Model.Any())
{
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-list"></i> Customer List</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>Telegram</th>
<th class="text-center">Status</th>
<th class="text-end">Orders</th>
<th class="text-end">Total Spent</th>
<th class="text-center">Risk Score</th>
<th>Last Active</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var customer in Model)
{
<tr class="@(customer.IsBlocked ? "table-danger" : "")">
<td>
<div>
<strong>@customer.TelegramDisplayName</strong>
@if (!string.IsNullOrEmpty(customer.TelegramFirstName) && !string.IsNullOrEmpty(customer.TelegramLastName))
{
<br><small class="text-muted">@customer.TelegramFirstName @customer.TelegramLastName</small>
}
</div>
</td>
<td>
<div>
@@<strong>@customer.TelegramUsername</strong>
<br><small class="text-muted">ID: @customer.TelegramUserId</small>
</div>
</td>
<td class="text-center">
@if (customer.IsBlocked)
{
<span class="badge bg-danger">
<i class="fas fa-ban"></i> Blocked
</span>
}
else if (!customer.IsActive)
{
<span class="badge bg-secondary">
<i class="fas fa-trash"></i> Deleted
</span>
}
else
{
<span class="badge bg-success">
<i class="fas fa-check-circle"></i> Active
</span>
}
</td>
<td class="text-end">
<div>
<strong>@customer.TotalOrders</strong>
@if (customer.SuccessfulOrders > 0)
{
<br><small class="text-success">@customer.SuccessfulOrders successful</small>
}
@if (customer.CancelledOrders > 0)
{
<br><small class="text-muted">@customer.CancelledOrders cancelled</small>
}
@if (customer.DisputedOrders > 0)
{
<br><small class="text-danger">@customer.DisputedOrders disputed</small>
}
</div>
</td>
<td class="text-end">
<strong>£@customer.TotalSpent.ToString("N2")</strong>
@if (customer.AverageOrderValue > 0)
{
<br><small class="text-muted">Avg: £@customer.AverageOrderValue.ToString("N2")</small>
}
</td>
<td class="text-center">
@{
var riskBadgeClass = customer.RiskScore >= 80 ? "bg-danger" :
customer.RiskScore >= 50 ? "bg-warning" :
customer.RiskScore >= 30 ? "bg-info" :
"bg-success";
var riskIcon = customer.RiskScore >= 80 ? "fa-exclamation-triangle" :
customer.RiskScore >= 50 ? "fa-exclamation-circle" :
customer.RiskScore >= 30 ? "fa-info-circle" :
"fa-check-circle";
}
<span class="badge @riskBadgeClass" style="font-size: 1.1em;">
<i class="fas @riskIcon"></i> @customer.RiskScore
</span>
</td>
<td>
@if (customer.LastActiveAt > DateTime.MinValue)
{
var daysAgo = (DateTime.UtcNow - customer.LastActiveAt).Days;
<span>@customer.LastActiveAt.ToString("MMM dd, yyyy")</span>
@if (daysAgo <= 1)
{
<br><small class="text-success">Today</small>
}
else if (daysAgo <= 7)
{
<br><small class="text-muted">@daysAgo days ago</small>
}
else if (daysAgo <= 30)
{
<br><small class="text-warning">@daysAgo days ago</small>
}
else
{
<br><small class="text-danger">@daysAgo days ago</small>
}
}
else
{
<span class="text-muted">Never</span>
}
</td>
<td class="text-center">
<a href="@Url.Action("Details", new { id = customer.Id })"
class="btn btn-sm btn-primary"
title="View Details">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
@if (!string.IsNullOrEmpty(searchTerm))
{
<strong>No customers found matching "@searchTerm"</strong>
<p class="mb-0">Try a different search term or <a href="@Url.Action("Index")" class="alert-link">view all customers</a>.</p>
}
else
{
<strong>No customers yet</strong>
<p class="mb-0">Customers will appear here automatically when they place their first order through the TeleBot.</p>
}
</div>
}

View File

@@ -2,12 +2,107 @@
ViewData["Title"] = "Dashboard";
}
<div class="row mb-4">
<div class="row mb-3">
<div class="col">
<h1><i class="fas fa-tachometer-alt"></i> Dashboard</h1>
<p class="text-muted mb-0">Welcome back! Here's what needs your attention today.</p>
</div>
</div>
<!-- PWA Install Prompt (Inline Content) -->
<div id="pwa-install-alert" class="alert alert-info alert-dismissible fade show d-flex align-items-center" role="alert" style="display: none !important;">
<i class="fas fa-mobile-alt me-3 fs-4"></i>
<div class="flex-grow-1">
<strong>Install TeleShop Admin as an App</strong>
<p class="mb-0 small">Get a better experience with offline access and a native app feel.</p>
</div>
<button id="pwa-install-btn" class="btn btn-primary btn-sm me-2">
<i class="fas fa-download"></i> Install
</button>
<button id="pwa-dismiss-btn" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@{
var pendingOrders = (int)ViewData["PendingOrders"]!;
var lowStockProducts = (int)ViewData["LowStockProducts"]!;
var outOfStockProducts = (int)ViewData["OutOfStockProducts"]!;
var totalUrgentActions = pendingOrders + (lowStockProducts > 5 ? 1 : 0) + (outOfStockProducts > 0 ? 1 : 0);
}
<!-- URGENT ACTIONS PANEL -->
@if (totalUrgentActions > 0)
{
<div class="row mb-4">
<div class="col">
<div class="card border-warning shadow-sm">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle"></i>
<strong>Urgent Actions Required</strong>
<span class="badge bg-danger ms-2">@totalUrgentActions</span>
</h5>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
@if (pendingOrders > 0)
{
<a href="@Url.Action("Index", "Orders", new { area = "Admin", status = "PendingPayment" })"
class="list-group-item list-group-item-action list-group-item-warning d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-shopping-cart text-warning me-2"></i>
<strong>@pendingOrders order@(pendingOrders != 1 ? "s" : "") awaiting payment</strong>
<small class="d-block text-muted">Customers may need payment reminders</small>
</div>
<i class="fas fa-chevron-right"></i>
</a>
}
@if (outOfStockProducts > 0)
{
<a href="@Url.Action("Index", "Products", new { area = "Admin" })"
class="list-group-item list-group-item-action list-group-item-danger d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-box-open text-danger me-2"></i>
<strong>@outOfStockProducts product@(outOfStockProducts != 1 ? "s" : "") out of stock</strong>
<small class="d-block text-muted">Update inventory or mark as unavailable</small>
</div>
<i class="fas fa-chevron-right"></i>
</a>
}
@if (lowStockProducts > 5)
{
<a href="@Url.Action("Index", "Products", new { area = "Admin" })"
class="list-group-item list-group-item-action list-group-item-warning d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-exclamation-circle text-warning me-2"></i>
<strong>@lowStockProducts products running low on stock</strong>
<small class="d-block text-muted">Stock levels below 10 units</small>
</div>
<i class="fas fa-chevron-right"></i>
</a>
}
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="row mb-4">
<div class="col">
<div class="alert alert-success d-flex align-items-center" role="alert">
<i class="fas fa-check-circle fs-4 me-3"></i>
<div>
<strong>All systems running smoothly!</strong>
<p class="mb-0 small">No urgent actions required at this time.</p>
</div>
</div>
</div>
</div>
}
<div class="row">
<div class="col-md-3">
<div class="card text-white bg-primary mb-3">
@@ -104,9 +199,6 @@
<div class="card">
<div class="card-header">
<h5><i class="fas fa-chart-line"></i> Quick Actions</h5>
<button id="pwa-install-dashboard" class="btn btn-sm btn-outline-primary" style="float: right;">
<i class="fas fa-mobile-alt"></i> Install App
</button>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
@@ -147,15 +239,29 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
const installBtn = document.getElementById('pwa-install-dashboard');
const installAlert = document.getElementById('pwa-install-alert');
const installBtn = document.getElementById('pwa-install-btn');
const dismissBtn = document.getElementById('pwa-dismiss-btn');
// Check if user has dismissed the alert
const alertDismissed = localStorage.getItem('pwa-install-dismissed');
// Check if app is in standalone mode (already installed)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
// Show alert if not dismissed and not already installed
if (installAlert && !alertDismissed && !isStandalone) {
installAlert.style.display = 'flex';
}
if (installBtn) {
installBtn.addEventListener('click', function() {
// Check if app is in standalone mode
if (window.matchMedia('(display-mode: standalone)').matches) {
if (isStandalone) {
alert('App is already installed!');
return;
}
// Show manual install instructions
alert(`To install TeleShop Admin as an app:
@@ -172,5 +278,11 @@ document.addEventListener('DOMContentLoaded', function() {
The app will then work offline and appear in your apps list!`);
});
}
if (dismissBtn) {
dismissBtn.addEventListener('click', function() {
localStorage.setItem('pwa-install-dismissed', 'true');
});
}
});
</script>

View File

@@ -0,0 +1,260 @@
@model IEnumerable<LittleShop.DTOs.CryptoPaymentDto>
@{
ViewData["Title"] = "Payment Transactions";
var orders = ViewData["Orders"] as Dictionary<Guid, LittleShop.DTOs.OrderDto> ?? new Dictionary<Guid, LittleShop.DTOs.OrderDto>();
var currentStatus = ViewData["CurrentStatus"] as string ?? "";
}
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Dashboard")">Dashboard</a></li>
<li class="breadcrumb-item active">Payment Transactions</li>
</ol>
</nav>
</div>
</div>
<div class="row mb-3">
<div class="col">
<h1><i class="fas fa-wallet"></i> Payment Transactions</h1>
<p class="text-muted mb-0">View all cryptocurrency payment transactions and their statuses</p>
</div>
</div>
<!-- Status Filter Tabs -->
<div class="row mb-4">
<div class="col">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link @(string.IsNullOrEmpty(currentStatus) ? "active" : "")"
href="@Url.Action("Index")">
All Payments
<span class="badge bg-secondary ms-1">@Model.Count()</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @(currentStatus == "Pending" ? "active" : "")"
href="@Url.Action("Index", new { status = "Pending" })">
<i class="fas fa-clock"></i> Pending
<span class="badge bg-warning ms-1">@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Pending)</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @(currentStatus == "Paid" ? "active" : "")"
href="@Url.Action("Index", new { status = "Paid" })">
<i class="fas fa-check-circle"></i> Paid
<span class="badge bg-success ms-1">@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Paid)</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @(currentStatus == "Expired" ? "active" : "")"
href="@Url.Action("Index", new { status = "Expired" })">
<i class="fas fa-times-circle"></i> Expired
<span class="badge bg-danger ms-1">@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Expired)</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body">
<h6 class="text-muted mb-2">Total Transactions</h6>
<h3 class="mb-0">@Model.Count()</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted mb-2">Successful Payments</h6>
<h3 class="mb-0 text-success">@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Paid || p.Status == LittleShop.Enums.PaymentStatus.Completed)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body">
<h6 class="text-muted mb-2">Pending</h6>
<h3 class="mb-0 text-warning">@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Pending)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body">
<h6 class="text-muted mb-2">Total Value</h6>
<h3 class="mb-0">£@Model.Where(p => p.Status == LittleShop.Enums.PaymentStatus.Paid || p.Status == LittleShop.Enums.PaymentStatus.Completed).Sum(p => p.PaidAmount).ToString("N2")</h3>
</div>
</div>
</div>
</div>
<!-- Payments Table -->
@if (Model.Any())
{
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-list"></i> Transaction List</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Payment ID</th>
<th>Order</th>
<th>Currency</th>
<th class="text-end">Required</th>
<th class="text-end">Paid</th>
<th class="text-center">Status</th>
<th>Created</th>
<th>Expires/Paid</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var payment in Model)
{
var statusClass = payment.Status.ToString() == "Paid" || payment.Status.ToString() == "Completed" ? "success" :
payment.Status.ToString() == "Expired" || payment.Status.ToString() == "Cancelled" ? "danger" :
payment.Status.ToString() == "Pending" ? "warning" :
"info";
var isExpired = DateTime.UtcNow > payment.ExpiresAt && payment.Status == LittleShop.Enums.PaymentStatus.Pending;
<tr class="@(isExpired ? "table-warning" : "")">
<td>
<code>@payment.Id.ToString().Substring(0, 8)</code>
@if (!string.IsNullOrEmpty(payment.SilverPayOrderId))
{
<br><small class="text-muted">SilverPay: @payment.SilverPayOrderId</small>
}
</td>
<td>
@if (orders.ContainsKey(payment.OrderId))
{
var order = orders[payment.OrderId];
<a href="@Url.Action("Details", "Orders", new { id = payment.OrderId })">
Order #@payment.OrderId.ToString().Substring(0, 8)
</a>
<br><small class="text-muted">£@order.TotalAmount.ToString("N2")</small>
}
else
{
<span class="text-muted">Order #@payment.OrderId.ToString().Substring(0, 8)</span>
}
</td>
<td>
<span class="badge bg-dark">
@payment.Currency
</span>
</td>
<td class="text-end">
<strong>@payment.RequiredAmount.ToString("0.########")</strong>
<br><small class="text-muted">@payment.Currency</small>
</td>
<td class="text-end">
@if (payment.PaidAmount > 0)
{
<strong class="text-success">@payment.PaidAmount.ToString("0.########")</strong>
<br><small class="text-muted">@payment.Currency</small>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-center">
<span class="badge bg-@statusClass">
@if (payment.Status.ToString() == "Paid")
{
<i class="fas fa-check-circle"></i>
}
else if (payment.Status.ToString() == "Pending")
{
<i class="fas fa-clock"></i>
}
else if (payment.Status.ToString() == "Expired")
{
<i class="fas fa-times-circle"></i>
}
@payment.Status
</span>
@if (isExpired)
{
<br><small class="text-danger"><i class="fas fa-exclamation-triangle"></i> Expired</small>
}
</td>
<td>
@payment.CreatedAt.ToString("MMM dd, yyyy")
<br><small class="text-muted">@payment.CreatedAt.ToString("HH:mm")</small>
</td>
<td>
@if (payment.PaidAt.HasValue)
{
<span class="text-success">
<i class="fas fa-check"></i> @payment.PaidAt.Value.ToString("MMM dd, HH:mm")
</span>
}
else
{
<span class="@(isExpired ? "text-danger" : "text-muted")">
<i class="fas fa-clock"></i> @payment.ExpiresAt.ToString("MMM dd, HH:mm")
</span>
}
</td>
<td class="text-center">
@if (!string.IsNullOrEmpty(payment.TransactionHash))
{
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="tooltip"
title="@payment.TransactionHash">
<i class="fas fa-link"></i> TX
</button>
}
<a href="@Url.Action("Details", "Orders", new { id = payment.OrderId })"
class="btn btn-sm btn-primary"
title="View Order">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
@if (!string.IsNullOrEmpty(currentStatus))
{
<strong>No @currentStatus.ToLower() payments found</strong>
<p class="mb-0">Try <a href="@Url.Action("Index")" class="alert-link">viewing all payments</a>.</p>
}
else
{
<strong>No payment transactions yet</strong>
<p class="mb-0">Payment transactions will appear here when customers make cryptocurrency payments.</p>
}
</div>
}
@section Scripts {
<script>
// Initialize Bootstrap tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
</script>
}

View File

@@ -9,6 +9,7 @@
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-plus"></i> Create Product</h1>
<p class="text-muted">Fill in the essential details below. Additional options can be configured after creation.</p>
</div>
</div>
@@ -18,7 +19,7 @@
<div class="card-body">
<form method="post" asp-area="Admin" asp-controller="Products" asp-action="Create">
@Html.AntiForgeryToken()
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger" role="alert">
@@ -31,183 +32,303 @@
</ul>
</div>
}
<div class="mb-3">
<label for="Name" class="form-label">Product Name</label>
<input name="Name" id="Name" value="@Model?.Name" class="form-control @(ViewData.ModelState["Name"]?.Errors.Count > 0 ? "is-invalid" : "")" required />
@if(ViewData.ModelState["Name"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Name"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description <small class="text-muted">(optional)</small></label>
<textarea name="Description" id="Description" class="form-control @(ViewData.ModelState["Description"]?.Errors.Count > 0 ? "is-invalid" : "")" rows="3" placeholder="Describe your product...">@Model?.Description</textarea>
@if(ViewData.ModelState["Description"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Description"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="Price" class="form-label">Price (£)</label>
<input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" class="form-control @(ViewData.ModelState["Price"]?.Errors.Count > 0 ? "is-invalid" : "")" required />
@if(ViewData.ModelState["Price"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Price"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="StockQuantity" class="form-label">Stock Quantity</label>
<input name="StockQuantity" id="StockQuantity" value="@(Model?.StockQuantity ?? 0)" type="number" min="0" class="form-control @(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0 ? "is-invalid" : "")" required />
@if(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["StockQuantity"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="CategoryId" class="form-label">Category</label>
<select name="CategoryId" id="CategoryId" class="form-select @(ViewData.ModelState["CategoryId"]?.Errors.Count > 0 ? "is-invalid" : "")" required>
<option value="">Select a category</option>
@if (categories != null)
{
@foreach (var category in categories)
{
<option value="@category.Id" selected="@(Model?.CategoryId == category.Id)">@category.Name</option>
}
}
</select>
@if(ViewData.ModelState["CategoryId"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["CategoryId"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="Weight" class="form-label">Weight/Volume</label>
<input name="Weight" id="Weight" value="@Model?.Weight" type="number" step="0.01" class="form-control @(ViewData.ModelState["Weight"]?.Errors.Count > 0 ? "is-invalid" : "")" required />
@if(ViewData.ModelState["Weight"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Weight"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="WeightUnit" class="form-label">Unit</label>
<select name="WeightUnit" id="WeightUnit" class="form-select @(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0 ? "is-invalid" : "")">
<option value="0" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Unit)">Unit</option>
<option value="1" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Micrograms)">Micrograms</option>
<option value="2" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Grams)">Grams</option>
<option value="3" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Ounces)">Ounces</option>
<option value="4" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Pounds)">Pounds</option>
<option value="5" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Millilitres)">Millilitres</option>
<option value="6" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Litres)">Litres</option>
</select>
@if(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["WeightUnit"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
</div>
<!-- Variant Collection Section -->
<hr class="my-4">
<h5><i class="fas fa-layer-group"></i> Product Variants <small class="text-muted">(optional)</small></h5>
<p class="text-muted">Add variant properties like Size, Color, or Flavor to this product.</p>
<!-- ESSENTIAL INFORMATION (Always Visible) -->
<div class="mb-4">
<h5 class="text-primary mb-3">
<i class="fas fa-star text-warning"></i> Essential Information
</h5>
<div class="mb-3">
<label for="VariantCollectionId" class="form-label">Variant Collection</label>
<select name="VariantCollectionId" id="VariantCollectionId" class="form-select">
<option value="">No variant collection</option>
@if (variantCollections != null)
<div class="mb-3">
<label for="Name" class="form-label fw-bold">
Product Name <span class="text-danger">*</span>
<i class="fas fa-info-circle text-muted ms-1" data-bs-toggle="tooltip" title="Enter a clear, descriptive name that customers will see"></i>
</label>
<input name="Name" id="Name" value="@Model?.Name"
class="form-control form-control-lg @(ViewData.ModelState["Name"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="e.g., Wireless Noise-Cancelling Headphones"
required autofocus />
@if(ViewData.ModelState["Name"]?.Errors.Count > 0)
{
@foreach (var collection in variantCollections)
{
<option value="@collection.Id" selected="@(Model?.VariantCollectionId == collection.Id)">@collection.Name</option>
}
<div class="invalid-feedback">
@ViewData.ModelState["Name"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</select>
<small class="form-text text-muted">Select a reusable variant template, or leave blank for custom variants</small>
<small class="form-text text-success d-none" id="name-success">
<i class="fas fa-check-circle"></i> Great! This name is unique.
</small>
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="Price" class="form-label fw-bold">
Price (£) <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-pound-sign"></i></span>
<input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" min="0.01"
class="form-control @(ViewData.ModelState["Price"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="10.00"
required />
@if(ViewData.ModelState["Price"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Price"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
<small class="form-text text-muted">Base price before multi-buy discounts</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="StockQuantity" class="form-label fw-bold">
Stock Quantity <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-boxes"></i></span>
<input name="StockQuantity" id="StockQuantity" value="@(Model?.StockQuantity ?? 0)" type="number" min="0"
class="form-control @(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="100"
required />
@if(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["StockQuantity"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
<small class="form-text text-muted">Current inventory available</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="CategoryId" class="form-label fw-bold">
Category <span class="text-danger">*</span>
</label>
<select name="CategoryId" id="CategoryId" class="form-select @(ViewData.ModelState["CategoryId"]?.Errors.Count > 0 ? "is-invalid" : "")" required>
<option value="">Choose category...</option>
@if (categories != null)
{
@foreach (var category in categories)
{
<option value="@category.Id" selected="@(Model?.CategoryId == category.Id)">@category.Name</option>
}
}
</select>
@if(ViewData.ModelState["CategoryId"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["CategoryId"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<small class="form-text text-muted">Helps customers find this product</small>
</div>
</div>
</div>
</div>
<!-- Dynamic Variant Fields (populated by JavaScript) -->
<div id="dynamic-variant-fields" class="mb-3">
<!-- JavaScript will populate this -->
</div>
<!-- Hidden VariantsJson field for form submission -->
<input type="hidden" name="VariantsJson" id="VariantsJson" value="@Model?.VariantsJson" />
<!-- Advanced JSON Editor (hidden by default) -->
<div class="mb-3">
<button type="button" id="toggle-advanced-variants" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-code"></i> Show Advanced JSON Editor
<!-- PRODUCT DETAILS (Collapsible) -->
<div class="mb-4">
<button class="btn btn-outline-secondary w-100 text-start" type="button" data-bs-toggle="collapse" data-bs-target="#productDetailsSection" aria-expanded="false">
<i class="fas fa-chevron-down me-2"></i>
<strong>Product Details</strong>
<small class="text-muted ms-2">(Optional - Click to expand)</small>
</button>
<div class="collapse mt-3" id="productDetailsSection">
<div class="border rounded p-3 bg-light">
<div class="mb-3">
<label for="Description" class="form-label">
Description
<i class="fas fa-info-circle text-muted ms-1" data-bs-toggle="tooltip" title="Provide details about features, benefits, or specifications"></i>
</label>
<textarea name="Description" id="Description" class="form-control @(ViewData.ModelState["Description"]?.Errors.Count > 0 ? "is-invalid" : "")"
rows="4"
placeholder="e.g., Premium wireless headphones with active noise cancellation, 30-hour battery life, and comfortable over-ear design...">@Model?.Description</textarea>
@if(ViewData.ModelState["Description"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Description"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<small class="form-text text-muted">Supports emojis and Unicode characters</small>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="Weight" class="form-label">
Weight/Volume
<i class="fas fa-info-circle text-muted ms-1" data-bs-toggle="tooltip" title="Used for shipping cost calculations"></i>
</label>
<input name="Weight" id="Weight" value="@Model?.Weight" type="number" step="0.01" min="0"
class="form-control @(ViewData.ModelState["Weight"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="e.g., 350"
required />
@if(ViewData.ModelState["Weight"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Weight"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="WeightUnit" class="form-label">Measurement Unit</label>
<select name="WeightUnit" id="WeightUnit" class="form-select @(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0 ? "is-invalid" : "")">
<option value="0" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Unit)">Unit (default)</option>
<option value="1" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Micrograms)">Micrograms (μg)</option>
<option value="2" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Grams)">Grams (g)</option>
<option value="3" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Ounces)">Ounces (oz)</option>
<option value="4" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Pounds)">Pounds (lb)</option>
<option value="5" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Millilitres)">Millilitres (ml)</option>
<option value="6" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Litres)">Litres (L)</option>
</select>
@if(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["WeightUnit"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
<div id="advanced-variant-section" class="mb-3" style="display: none;">
<label class="form-label">Advanced: Custom Variants JSON</label>
<textarea class="form-control font-monospace" rows="4" placeholder='[{"Size":"M","Color":"Red"}]'
onchange="document.getElementById('VariantsJson').value = this.value">@Model?.VariantsJson</textarea>
<small class="form-text text-muted">For advanced users: Directly edit the JSON structure</small>
<!-- VARIANTS SECTION (Collapsible - Advanced) -->
<div class="mb-4">
<button class="btn btn-outline-info w-100 text-start" type="button" data-bs-toggle="collapse" data-bs-target="#variantsSection" aria-expanded="false">
<i class="fas fa-chevron-down me-2"></i>
<strong><i class="fas fa-layer-group"></i> Product Variants</strong>
<small class="text-muted ms-2">(Optional - For products with size/color options)</small>
</button>
<div class="collapse mt-3" id="variantsSection">
<div class="border rounded p-3 bg-light">
<p class="text-muted mb-3">
<i class="fas fa-lightbulb text-warning"></i>
Add variant properties like Size, Color, or Flavor to this product. Variants allow customers to choose specific options.
</p>
<div class="mb-3">
<label for="VariantCollectionId" class="form-label">Variant Collection</label>
<select name="VariantCollectionId" id="VariantCollectionId" class="form-select">
<option value="">No variant collection</option>
@if (variantCollections != null)
{
@foreach (var collection in variantCollections)
{
<option value="@collection.Id" selected="@(Model?.VariantCollectionId == collection.Id)">@collection.Name</option>
}
}
</select>
<small class="form-text text-muted">Select a reusable variant template, or leave blank for custom variants</small>
</div>
<!-- Dynamic Variant Fields (populated by JavaScript) -->
<div id="dynamic-variant-fields" class="mb-3">
<!-- JavaScript will populate this -->
</div>
<!-- Hidden VariantsJson field for form submission -->
<input type="hidden" name="VariantsJson" id="VariantsJson" value="@Model?.VariantsJson" />
<!-- Advanced JSON Editor (hidden by default) -->
<div class="mb-3">
<button type="button" id="toggle-advanced-variants" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-code"></i> Show Advanced JSON Editor
</button>
</div>
<div id="advanced-variant-section" class="mb-3" style="display: none;">
<label class="form-label">Advanced: Custom Variants JSON</label>
<textarea class="form-control font-monospace" rows="4" placeholder='[{"Size":"M","Color":"Red"}]'
onchange="document.getElementById('VariantsJson').value = this.value">@Model?.VariantsJson</textarea>
<small class="form-text text-muted">For advanced users: Directly edit the JSON structure</small>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="@Url.Action("Index")" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Products
<!-- FORM ACTIONS -->
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<a href="@Url.Action("Index")" class="btn btn-lg btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Product
<button type="submit" class="btn btn-lg btn-primary px-5">
<i class="fas fa-check-circle"></i> Create Product
</button>
</div>
<div class="mt-3 text-center text-muted small">
<i class="fas fa-info-circle"></i>
You can add photos and configure multi-buy discounts after creating the product.
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5><i class="fas fa-info-circle"></i> Product Information</h5>
<!-- QUICK START GUIDE -->
<div class="card border-primary mb-3">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-rocket"></i> Quick Start Guide</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li><strong>Name:</strong> Unique product identifier</li>
<li><strong>Description:</strong> Optional, supports Unicode and emojis</li>
<li><strong>Price:</strong> Base price in GBP</li>
<li><strong>Stock:</strong> Current inventory quantity</li>
<li><strong>Weight/Volume:</strong> Used for shipping calculations</li>
<li><strong>Category:</strong> Product organization</li>
<li><strong>Photos:</strong> Can be added after creating the product</li>
<h6 class="text-primary"><i class="fas fa-star text-warning"></i> Essential Fields (Required)</h6>
<ol class="mb-3">
<li><strong>Product Name</strong> - Clear, descriptive title</li>
<li><strong>Price</strong> - Base price in £ (GBP)</li>
<li><strong>Stock Quantity</strong> - Available inventory</li>
<li><strong>Category</strong> - Helps customers find it</li>
</ol>
<h6 class="text-secondary"><i class="fas fa-cog"></i> Optional Sections</h6>
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="fas fa-chevron-right text-muted"></i>
<strong>Product Details</strong><br>
<small class="text-muted">Description, weight/dimensions for shipping</small>
</li>
<li>
<i class="fas fa-chevron-right text-muted"></i>
<strong>Variants</strong><br>
<small class="text-muted">Size, color, or other options</small>
</li>
</ul>
</div>
</div>
<!-- HELPFUL TIPS -->
<div class="card border-info">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="fas fa-lightbulb"></i> Helpful Tips</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="fas fa-check-circle text-success"></i>
<strong>Photos:</strong> Add after creating the product
</li>
<li class="mb-2">
<i class="fas fa-check-circle text-success"></i>
<strong>Multi-buy:</strong> Configure bulk discounts later
</li>
<li class="mb-2">
<i class="fas fa-check-circle text-success"></i>
<strong>Smart Form:</strong> Remembers your last category
</li>
<li>
<i class="fas fa-check-circle text-success"></i>
<strong>Validation:</strong> Real-time feedback as you type
</li>
</ul>
<small class="text-muted">The form remembers your last used category and weight unit. Add photos after creation.</small>
</div>
</div>
</div>
@@ -217,83 +338,75 @@
<script src="~/js/product-variants.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap tooltips
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
// Animate collapse button icons
const collapseButtons = document.querySelectorAll('[data-bs-toggle="collapse"]');
collapseButtons.forEach(button => {
const icon = button.querySelector('.fa-chevron-down');
const target = button.getAttribute('data-bs-target');
if (icon && target) {
document.querySelector(target)?.addEventListener('show.bs.collapse', () => {
icon.style.transform = 'rotate(180deg)';
icon.style.transition = 'transform 0.3s ease';
});
document.querySelector(target)?.addEventListener('hide.bs.collapse', () => {
icon.style.transform = 'rotate(0deg)';
});
}
});
const categorySelect = document.getElementById('CategoryId');
const weightUnitSelect = document.getElementById('WeightUnit');
const photoInput = document.getElementById('ProductPhotos');
const photoPreview = document.getElementById('photo-preview');
const nameInput = document.getElementById('Name');
// Restore last used category and weight unit
const lastCategory = localStorage.getItem('lastProductCategory');
const lastWeightUnit = localStorage.getItem('lastProductWeightUnit');
if (lastCategory && categorySelect) {
categorySelect.value = lastCategory;
console.log('Restored last category:', lastCategory);
}
if (lastWeightUnit && weightUnitSelect) {
weightUnitSelect.value = lastWeightUnit;
console.log('Restored last weight unit:', lastWeightUnit);
}
// Save category and weight unit when changed
if (categorySelect) {
categorySelect.addEventListener('change', function() {
if (this.value) {
localStorage.setItem('lastProductCategory', this.value);
console.log('Saved category preference:', this.value);
}
});
}
if (weightUnitSelect) {
weightUnitSelect.addEventListener('change', function() {
localStorage.setItem('lastProductWeightUnit', this.value);
console.log('Saved weight unit preference:', this.value);
});
}
// Photo preview functionality
if (photoInput) {
photoInput.addEventListener('change', function(e) {
const files = Array.from(e.target.files);
photoPreview.innerHTML = '';
if (files.length > 0) {
photoPreview.style.display = 'block';
files.forEach((file, index) => {
if (file.type.startsWith('image/')) {
const col = document.createElement('div');
col.className = 'col-md-3 col-6 mb-3';
const reader = new FileReader();
reader.onload = function(event) {
col.innerHTML = `
<div class="card">
<img src="${event.target.result}" class="card-img-top" style="height: 120px; object-fit: cover;" alt="Preview ${index + 1}">
<div class="card-body p-2">
<small class="text-muted">${file.name}</small><br>
<small class="text-success">${(file.size / 1024).toFixed(1)} KB</small>
</div>
</div>
`;
};
reader.readAsDataURL(file);
photoPreview.appendChild(col);
}
});
} else {
photoPreview.style.display = 'none';
}
});
}
// Focus on name field for better UX
const nameInput = document.getElementById('Name');
if (nameInput) {
setTimeout(() => nameInput.focus(), 100);
}
// Simple inline validation feedback for product name
if (nameInput) {
nameInput.addEventListener('input', function() {
const successMsg = document.getElementById('name-success');
if (this.value.trim().length >= 3) {
successMsg?.classList.remove('d-none');
} else {
successMsg?.classList.add('d-none');
}
});
}
});
</script>
}

View File

@@ -493,7 +493,7 @@
<i class="fas fa-star me-2"></i>Product Reviews
<small class="text-muted ms-2">
@{
var productReviews = ViewData["ProductReviews"] as IEnumerable<dynamic>;
var productReviews = ViewData["ProductReviews"] as IEnumerable<LittleShop.DTOs.ReviewDto>;
if (productReviews != null && productReviews.Any())
{
<span>@productReviews.Count() review(s)</span>
@@ -523,7 +523,7 @@
<div class="me-2">
@for (int i = 1; i <= 5; i++)
{
if (i <= (review.Rating ?? 0))
if (i <= review.Rating)
{
<i class="fas fa-star text-warning"></i>
}
@@ -533,20 +533,20 @@
}
}
</div>
<strong>@(review.CustomerName ?? "Anonymous Customer")</strong>
<strong>@(string.IsNullOrEmpty(review.CustomerDisplayName) ? "Anonymous Customer" : review.CustomerDisplayName)</strong>
</div>
<small class="text-muted">@(review.CreatedAt?.ToString("MMM dd, yyyy") ?? "Date unknown")</small>
<small class="text-muted">@review.CreatedAt.ToString("MMM dd, yyyy")</small>
</div>
<span class="badge bg-primary">@(review.Rating ?? 0)/5</span>
<span class="badge bg-primary">@review.Rating/5</span>
</div>
@if (!string.IsNullOrEmpty(review.Comment?.ToString()))
@if (!string.IsNullOrEmpty(review.Comment))
{
<p class="mb-2">@review.Comment</p>
}
@if (!string.IsNullOrEmpty(review.OrderReference?.ToString()))
@if (review.IsVerifiedPurchase)
{
<small class="text-muted">
<i class="fas fa-receipt"></i> Order: @review.OrderReference
<i class="fas fa-check-circle text-success"></i> Verified Purchase
</small>
}
</div>
@@ -558,7 +558,7 @@
<div class="row text-center">
<div class="col-md-6">
<strong>Average Rating</strong><br>
<span class="h4">@(productReviews.Average(r => r.Rating ?? 0).ToString("F1"))/5</span>
<span class="h4">@(productReviews.Average(r => r.Rating).ToString("F1"))/5</span>
</div>
<div class="col-md-6">
<strong>Total Reviews</strong><br>

View File

@@ -0,0 +1,260 @@
@model IEnumerable<LittleShop.Models.PushSubscription>
@{
ViewData["Title"] = "Push Subscriptions";
}
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Dashboard")">Dashboard</a></li>
<li class="breadcrumb-item active">Push Subscriptions</li>
</ol>
</nav>
</div>
</div>
<div class="row mb-3">
<div class="col-md-8">
<h1><i class="fas fa-bell"></i> Push Subscriptions</h1>
<p class="text-muted mb-0">Manage browser push notification subscriptions for admins and customers</p>
</div>
<div class="col-md-4 text-end">
<form method="post" action="@Url.Action("CleanupExpired")" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning"
onclick="return confirm('Remove all inactive and expired subscriptions?')">
<i class="fas fa-broom"></i> Cleanup Expired
</button>
</form>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body">
<h6 class="text-muted mb-2">Total Subscriptions</h6>
<h3 class="mb-0">@Model.Count()</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted mb-2">Active</h6>
<h3 class="mb-0 text-success">@Model.Count(s => s.IsActive)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body">
<h6 class="text-muted mb-2">Admin Users</h6>
<h3 class="mb-0">@Model.Count(s => s.UserId.HasValue)</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body">
<h6 class="text-muted mb-2">Customers</h6>
<h3 class="mb-0">@Model.Count(s => s.CustomerId.HasValue)</h3>
</div>
</div>
</div>
</div>
<!-- Subscriptions Table -->
@if (Model.Any())
{
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-list"></i> Subscription List</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 5%;">ID</th>
<th style="width: 15%;">Type</th>
<th style="width: 20%;">Endpoint</th>
<th style="width: 15%;">Subscribed</th>
<th style="width: 15%;">Last Used</th>
<th style="width: 15%;">Browser/Device</th>
<th style="width: 8%;">Status</th>
<th class="text-center" style="width: 7%;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var subscription in Model)
{
var daysInactive = subscription.LastUsedAt.HasValue
? (DateTime.UtcNow - subscription.LastUsedAt.Value).Days
: (DateTime.UtcNow - subscription.SubscribedAt).Days;
var statusClass = subscription.IsActive
? (daysInactive > 90 ? "warning" : "success")
: "danger";
<tr>
<td><code>@subscription.Id</code></td>
<td>
@if (subscription.UserId.HasValue)
{
<span class="badge bg-info">
<i class="fas fa-user-shield"></i> Admin User
</span>
@if (subscription.User != null)
{
<br><small class="text-muted">@subscription.User.Username</small>
}
}
else if (subscription.CustomerId.HasValue)
{
<span class="badge bg-warning">
<i class="fas fa-user"></i> Customer
</span>
@if (subscription.Customer != null)
{
<br><small class="text-muted">@subscription.Customer.TelegramDisplayName</small>
}
}
else
{
<span class="badge bg-secondary">
<i class="fas fa-question"></i> Unknown
</span>
}
</td>
<td>
<small class="font-monospace text-break"
data-bs-toggle="tooltip"
title="@subscription.Endpoint">
@(subscription.Endpoint.Length > 40 ? subscription.Endpoint.Substring(0, 40) + "..." : subscription.Endpoint)
</small>
@if (!string.IsNullOrEmpty(subscription.IpAddress))
{
<br><span class="badge bg-secondary"><i class="fas fa-network-wired"></i> @subscription.IpAddress</span>
}
</td>
<td>
@subscription.SubscribedAt.ToString("MMM dd, yyyy")
<br><small class="text-muted">@subscription.SubscribedAt.ToString("HH:mm")</small>
</td>
<td>
@if (subscription.LastUsedAt.HasValue)
{
@subscription.LastUsedAt.Value.ToString("MMM dd, yyyy")
<br><small class="text-muted">@subscription.LastUsedAt.Value.ToString("HH:mm")</small>
@if (daysInactive > 0)
{
<br><span class="badge bg-@(daysInactive > 90 ? "danger" : daysInactive > 30 ? "warning" : "info")">
@daysInactive days ago
</span>
}
}
else
{
<span class="text-muted">Never</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(subscription.UserAgent))
{
var ua = subscription.UserAgent;
var browser = ua.Contains("Chrome") ? "Chrome" :
ua.Contains("Firefox") ? "Firefox" :
ua.Contains("Safari") ? "Safari" :
ua.Contains("Edge") ? "Edge" : "Unknown";
var os = ua.Contains("Windows") ? "Windows" :
ua.Contains("Mac") ? "macOS" :
ua.Contains("Linux") ? "Linux" :
ua.Contains("Android") ? "Android" :
ua.Contains("iOS") ? "iOS" : "Unknown";
<span class="badge bg-secondary">@browser</span>
<span class="badge bg-dark">@os</span>
<br><small class="text-muted"
data-bs-toggle="tooltip"
title="@subscription.UserAgent">
@(ua.Length > 30 ? ua.Substring(0, 30) + "..." : ua)
</small>
}
else
{
<span class="text-muted">Not available</span>
}
</td>
<td class="text-center">
<span class="badge bg-@statusClass">
@if (subscription.IsActive)
{
<i class="fas fa-check-circle"></i> <text>Active</text>
}
else
{
<i class="fas fa-times-circle"></i> <text>Inactive</text>
}
</span>
</td>
<td class="text-center">
<form method="post" action="@Url.Action("Delete", new { id = subscription.Id })" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit"
class="btn btn-sm btn-danger"
onclick="return confirm('Are you sure you want to delete this push subscription?')"
title="Delete Subscription">
<i class="fas fa-trash"></i>
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>No push subscriptions yet</strong>
<p class="mb-0">Push notification subscriptions will appear here when users enable browser notifications.</p>
</div>
}
<!-- Information Card -->
<div class="row mt-4">
<div class="col">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h6 class="mb-0"><i class="fas fa-info-circle"></i> About Push Subscriptions</h6>
</div>
<div class="card-body">
<ul class="mb-0">
<li><strong>Active Status:</strong> Subscriptions marked as active can receive push notifications</li>
<li><strong>IP Address Storage:</strong> IP addresses are stored for security and duplicate detection purposes</li>
<li><strong>Cleanup:</strong> Expired subscriptions (inactive for >90 days) can be removed using the cleanup button</li>
<li><strong>User Agent:</strong> Browser and device information helps identify subscription sources</li>
<li><strong>Privacy:</strong> Subscription data contains encryption keys required for Web Push API</li>
</ul>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Initialize Bootstrap tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
</script>
}

View File

@@ -37,6 +37,7 @@
<link href="/_content/Radzen.Blazor/css/material-base.css" rel="stylesheet">
<link href="/css/modern-admin.css" rel="stylesheet">
<link href="/css/mobile-admin.css" rel="stylesheet">
<link href="/css/enhanced-navigation.css?v=20251114c" rel="stylesheet">
@await RenderSectionAsync("Head", required: false)
</head>
<body>
@@ -64,65 +65,106 @@
</a>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<!-- Dashboard (Always Visible) -->
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<a class="nav-link @(ViewContext.RouteData.Values["controller"]?.ToString() == "Dashboard" ? "active" : "")" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Categories", new { area = "Admin" })">
<i class="fas fa-tags"></i> Categories
<!-- Catalog Dropdown -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @(new[]{"Products","Categories","VariantCollections"}.Contains(ViewContext.RouteData.Values["controller"]?.ToString()) ? "active" : "")" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-store"></i> Catalog
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="@Url.Action("Index", "Products", new { area = "Admin" })">
<i class="fas fa-box"></i> Products
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Categories", new { area = "Admin" })">
<i class="fas fa-tags"></i> Categories
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "VariantCollections", new { area = "Admin" })">
<i class="fas fa-layer-group"></i> Variant Collections
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@Url.Action("Import", "Products", new { area = "Admin" })">
<i class="fas fa-upload"></i> Import Products
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Export", "Products", new { area = "Admin" })">
<i class="fas fa-download"></i> Export Products
</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Products", new { area = "Admin" })">
<i class="fas fa-box"></i> Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "VariantCollections", new { area = "Admin" })">
<i class="fas fa-layer-group"></i> Variants
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Orders", new { area = "Admin" })">
<!-- Orders & Fulfillment -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @(new[]{"Orders","Customers","Payments","ShippingRates"}.Contains(ViewContext.RouteData.Values["controller"]?.ToString()) ? "active" : "")" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-shopping-cart"></i> Orders
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="@Url.Action("Index", "Orders", new { area = "Admin" })">
<i class="fas fa-clipboard-list"></i> All Orders
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Customers", new { area = "Admin" })">
<i class="fas fa-users"></i> Customers
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Payments", new { area = "Admin" })">
<i class="fas fa-wallet"></i> Payment Transactions
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Orders", new { area = "Admin", tab = "pending" })">
<i class="fas fa-clock"></i> Pending Payment
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Orders", new { area = "Admin", tab = "accept" })">
<i class="fas fa-check-circle"></i> Ready to Accept
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })">
<i class="fas fa-truck"></i> Shipping Rates
</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Reviews", new { area = "Admin" })">
<i class="fas fa-star"></i> Reviews
<!-- Customer Communication -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @(new[]{"Reviews","Messages","BotActivity"}.Contains(ViewContext.RouteData.Values["controller"]?.ToString()) ? "active" : "")" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-comments"></i> Customers
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="@Url.Action("Index", "Reviews", new { area = "Admin" })">
<i class="fas fa-star"></i> Reviews
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Messages", new { area = "Admin" })">
<i class="fas fa-envelope"></i> Messages
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@Url.Action("LiveView", "BotActivity", new { area = "Admin" })">
<i class="fas fa-satellite-dish"></i> Live Activity
</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Messages", new { area = "Admin" })">
<i class="fas fa-comments"></i> Messages
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })">
<i class="fas fa-truck"></i> Shipping
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Users", new { area = "Admin" })">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Bots", new { area = "Admin" })">
<i class="fas fa-robot"></i> Bots
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("LiveView", "BotActivity", new { area = "Admin" })">
<i class="fas fa-satellite-dish"></i> Live Activity
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "SystemSettings", new { area = "Admin" })">
<!-- Settings -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle @(new[]{"Users","Bots","PushSubscriptions","SystemSettings"}.Contains(ViewContext.RouteData.Values["controller"]?.ToString()) ? "active" : "")" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-cog"></i> Settings
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="@Url.Action("Index", "Users", new { area = "Admin" })">
<i class="fas fa-users"></i> Users
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "Bots", new { area = "Admin" })">
<i class="fas fa-robot"></i> Bots
</a></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "PushSubscriptions", new { area = "Admin" })">
<i class="fas fa-bell"></i> Push Subscriptions
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@Url.Action("Index", "SystemSettings", new { area = "Admin" })">
<i class="fas fa-sliders-h"></i> System Settings
</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">

View File

@@ -0,0 +1,54 @@
namespace LittleShop.Configuration;
/// <summary>
/// Configuration options for GDPR data retention enforcement
/// </summary>
public class DataRetentionOptions
{
public const string SectionName = "DataRetention";
/// <summary>
/// Enable automatic data retention enforcement
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Time of day to run cleanup (24-hour format, e.g., "02:00" for 2 AM)
/// </summary>
public string CleanupTime { get; set; } = "02:00";
/// <summary>
/// Interval in hours to check for data retention (default: 24 hours)
/// </summary>
public int CheckIntervalHours { get; set; } = 24;
/// <summary>
/// Default retention period in days for active customers (default: 730 days = 2 years)
/// </summary>
public int DefaultRetentionDays { get; set; } = 730;
/// <summary>
/// Retention period in days after customer requests deletion (default: 30 days)
/// </summary>
public int DeletionGracePeriodDays { get; set; } = 30;
/// <summary>
/// Maximum number of customers to process per cleanup run (prevent long-running jobs)
/// </summary>
public int MaxCustomersPerRun { get; set; } = 100;
/// <summary>
/// Enable dry-run mode (log what would be deleted without actually deleting)
/// </summary>
public bool DryRunMode { get; set; } = false;
/// <summary>
/// Enable admin notification before deletion (requires notification service)
/// </summary>
public bool NotifyAdminBeforeDeletion { get; set; } = true;
/// <summary>
/// Days before deletion to send admin notification
/// </summary>
public int NotificationDaysBeforeDeletion { get; set; } = 7;
}

View File

@@ -0,0 +1,115 @@
namespace LittleShop.DTOs;
/// <summary>
/// Complete customer data export for GDPR "Right to Data Portability" compliance.
/// Contains all personal data stored about a customer.
/// </summary>
public class CustomerDataExportDto
{
// Customer Profile
public Guid CustomerId { get; set; }
public long TelegramUserId { get; set; }
public string TelegramUsername { get; set; } = string.Empty;
public string TelegramDisplayName { get; set; } = string.Empty;
public string TelegramFirstName { get; set; } = string.Empty;
public string TelegramLastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
// Preferences
public bool AllowMarketing { get; set; }
public bool AllowOrderUpdates { get; set; }
public string Language { get; set; } = string.Empty;
public string Timezone { get; set; } = string.Empty;
// Metrics
public int TotalOrders { get; set; }
public decimal TotalSpent { get; set; }
public decimal AverageOrderValue { get; set; }
public DateTime? FirstOrderDate { get; set; }
public DateTime? LastOrderDate { get; set; }
// Account Status
public bool IsBlocked { get; set; }
public string? BlockReason { get; set; }
public int RiskScore { get; set; }
public string? CustomerNotes { get; set; }
// Timestamps
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime LastActiveAt { get; set; }
public DateTime? DataRetentionDate { get; set; }
// Related Data
public List<CustomerOrderExportDto> Orders { get; set; } = new();
public List<CustomerMessageExportDto> Messages { get; set; } = new();
public List<CustomerReviewExportDto> Reviews { get; set; } = new();
}
/// <summary>
/// Order data included in customer export
/// </summary>
public class CustomerOrderExportDto
{
public Guid OrderId { get; set; }
public string Status { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = string.Empty;
public DateTime OrderDate { get; set; }
// Shipping Information
public string ShippingName { get; set; } = string.Empty;
public string ShippingAddress { get; set; } = string.Empty;
public string ShippingCity { get; set; } = string.Empty;
public string ShippingPostCode { get; set; } = string.Empty;
public string ShippingCountry { get; set; } = string.Empty;
// Tracking
public string? TrackingNumber { get; set; }
public DateTime? EstimatedDeliveryDate { get; set; }
public DateTime? ActualDeliveryDate { get; set; }
public string? Notes { get; set; }
// Order Items
public List<CustomerOrderItemExportDto> Items { get; set; } = new();
}
/// <summary>
/// Order item data included in customer export
/// </summary>
public class CustomerOrderItemExportDto
{
public string ProductName { get; set; } = string.Empty;
public string? VariantName { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
/// <summary>
/// Message data included in customer export
/// </summary>
public class CustomerMessageExportDto
{
public DateTime SentAt { get; set; }
public string MessageType { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public bool WasRead { get; set; }
public DateTime? ReadAt { get; set; }
}
/// <summary>
/// Review data included in customer export
/// </summary>
public class CustomerReviewExportDto
{
public Guid ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public int Rating { get; set; }
public string? Comment { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsApproved { get; set; }
public bool IsVerifiedPurchase { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace LittleShop.DTOs;
/// <summary>
/// DTO containing counts for each order status/workflow state.
/// Used for displaying badge counts in the Orders Index view.
/// </summary>
public class OrderStatusCountsDto
{
public int PendingPaymentCount { get; set; }
public int RequiringActionCount { get; set; } // PaymentReceived orders needing acceptance
public int ForPackingCount { get; set; } // Accepted orders ready for packing
public int DispatchedCount { get; set; }
public int OnHoldCount { get; set; }
public int DeliveredCount { get; set; }
public int CancelledCount { get; set; }
}

View File

@@ -0,0 +1,218 @@
using Microsoft.Extensions.Options;
using System.Net;
namespace LittleShop.Middleware;
/// <summary>
/// Middleware to restrict access to admin endpoints based on IP address
/// Optional defense-in-depth measure (reverse proxy is preferred approach)
/// </summary>
public class IPWhitelistMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IPWhitelistMiddleware> _logger;
private readonly IPWhitelistOptions _options;
public IPWhitelistMiddleware(
RequestDelegate next,
ILogger<IPWhitelistMiddleware> logger,
IOptions<IPWhitelistOptions> options)
{
_next = next;
_logger = logger;
_options = options.Value;
}
public async Task InvokeAsync(HttpContext context)
{
// Skip if IP whitelist is disabled
if (!_options.Enabled)
{
await _next(context);
return;
}
var remoteIp = context.Connection.RemoteIpAddress;
// Check for X-Forwarded-For header if behind proxy
if (_options.UseForwardedHeaders && context.Request.Headers.ContainsKey("X-Forwarded-For"))
{
var forwardedFor = context.Request.Headers["X-Forwarded-For"].ToString();
var ips = forwardedFor.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (ips.Length > 0 && IPAddress.TryParse(ips[0].Trim(), out var parsedIp))
{
remoteIp = parsedIp;
}
}
// Check for X-Real-IP header if behind nginx
if (_options.UseForwardedHeaders && context.Request.Headers.ContainsKey("X-Real-IP"))
{
var realIp = context.Request.Headers["X-Real-IP"].ToString();
if (IPAddress.TryParse(realIp, out var parsedIp))
{
remoteIp = parsedIp;
}
}
if (remoteIp == null)
{
_logger.LogWarning("Unable to determine client IP address");
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
error = "Access denied",
message = "Unable to determine client IP address"
});
return;
}
// Check if IP is whitelisted
if (!IsIPWhitelisted(remoteIp))
{
_logger.LogWarning("Access denied for IP {IP} to {Path}", remoteIp, context.Request.Path);
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
error = "Access denied",
message = "Admin access is restricted to authorized networks",
clientIp = _options.ShowClientIP ? remoteIp.ToString() : "hidden"
});
return;
}
_logger.LogDebug("IP {IP} whitelisted for {Path}", remoteIp, context.Request.Path);
await _next(context);
}
private bool IsIPWhitelisted(IPAddress clientIp)
{
// Localhost is always allowed
if (IPAddress.IsLoopback(clientIp))
{
return true;
}
// Check individual IPs
foreach (var allowedIp in _options.WhitelistedIPs)
{
if (IPAddress.TryParse(allowedIp, out var parsed) && clientIp.Equals(parsed))
{
return true;
}
}
// Check CIDR ranges
foreach (var cidr in _options.WhitelistedCIDRs)
{
if (IsInCIDRRange(clientIp, cidr))
{
return true;
}
}
return false;
}
private bool IsInCIDRRange(IPAddress clientIp, string cidr)
{
try
{
var parts = cidr.Split('/');
if (parts.Length != 2)
{
_logger.LogWarning("Invalid CIDR format: {CIDR}", cidr);
return false;
}
var networkAddress = IPAddress.Parse(parts[0]);
var prefixLength = int.Parse(parts[1]);
// Convert to byte arrays
var clientBytes = clientIp.GetAddressBytes();
var networkBytes = networkAddress.GetAddressBytes();
// Check if same address family
if (clientBytes.Length != networkBytes.Length)
{
return false;
}
// Calculate network mask
var maskBytes = new byte[networkBytes.Length];
var fullBytes = prefixLength / 8;
var remainingBits = prefixLength % 8;
// Set full bytes to 255
for (int i = 0; i < fullBytes; i++)
{
maskBytes[i] = 255;
}
// Set remaining bits
if (remainingBits > 0 && fullBytes < maskBytes.Length)
{
maskBytes[fullBytes] = (byte)(255 << (8 - remainingBits));
}
// Compare masked addresses
for (int i = 0; i < clientBytes.Length; i++)
{
if ((clientBytes[i] & maskBytes[i]) != (networkBytes[i] & maskBytes[i]))
{
return false;
}
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking CIDR range {CIDR}", cidr);
return false;
}
}
}
/// <summary>
/// Configuration options for IP whitelist middleware
/// </summary>
public class IPWhitelistOptions
{
public const string SectionName = "IPWhitelist";
/// <summary>
/// Enable IP whitelist enforcement
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Use X-Forwarded-For and X-Real-IP headers (when behind reverse proxy)
/// </summary>
public bool UseForwardedHeaders { get; set; } = true;
/// <summary>
/// Show client IP in error response (for debugging)
/// </summary>
public bool ShowClientIP { get; set; } = false;
/// <summary>
/// List of whitelisted IP addresses
/// </summary>
public List<string> WhitelistedIPs { get; set; } = new()
{
"127.0.0.1",
"::1"
};
/// <summary>
/// List of whitelisted CIDR ranges
/// </summary>
public List<string> WhitelistedCIDRs { get; set; } = new()
{
"192.168.0.0/16", // Private network Class C
"10.0.0.0/8", // Private network Class A
"172.16.0.0/12" // Private network Class B
};
}

View File

@@ -27,5 +27,12 @@ public class PushSubscription
// Browser/device information for identification
public string? UserAgent { get; set; }
/// <summary>
/// IP address from which the subscription was created.
/// NOTE: NOT technically required for Web Push functionality - stored only for security monitoring.
/// Consider: May be removed or made optional for privacy/GDPR compliance.
/// See: IP_STORAGE_ANALYSIS.md for full analysis and recommendations.
/// </summary>
public string? IpAddress { get; set; }
}

View File

@@ -7,6 +7,7 @@ using LittleShop.Services;
using FluentValidation;
using Serilog;
using AspNetCoreRateLimit;
using LittleShop.Configuration;
var builder = WebApplication.CreateBuilder(args);
@@ -232,6 +233,13 @@ builder.Services.AddScoped<ISystemSettingsService, SystemSettingsService>();
// Configuration validation service
builder.Services.AddSingleton<ConfigurationValidationService>();
// Configure Data Retention Options
builder.Services.Configure<DataRetentionOptions>(
builder.Configuration.GetSection(DataRetentionOptions.SectionName));
// Data Retention Background Service
builder.Services.AddHostedService<DataRetentionService>();
// SignalR
builder.Services.AddSignalR();

View File

@@ -102,6 +102,16 @@ public class CryptoPaymentService : ICryptoPaymentService
}
}
public async Task<IEnumerable<CryptoPaymentDto>> GetAllPaymentsAsync()
{
var payments = await _context.CryptoPayments
.Include(cp => cp.Order)
.OrderByDescending(cp => cp.CreatedAt)
.ToListAsync();
return payments.Select(MapToDto);
}
public async Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId)
{
var payments = await _context.CryptoPayments

View File

@@ -293,4 +293,136 @@ public class CustomerService : ICustomerService
_logger.LogInformation("Unblocked customer {CustomerId}", customerId);
return true;
}
public async Task<CustomerDataExportDto?> GetCustomerDataForExportAsync(Guid customerId)
{
try
{
// Get customer with all related data
// Note: EF Core requires AsSplitQuery for multiple ThenInclude on same level
var customer = await _context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.ThenInclude(oi => oi.ProductVariant)
.Include(c => c.Messages)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null)
{
_logger.LogWarning("Customer {CustomerId} not found for data export", customerId);
return null;
}
// Get customer reviews separately (no direct navigation property from Customer to Review)
var reviews = await _context.Reviews
.Include(r => r.Product)
.Where(r => r.CustomerId == customerId)
.ToListAsync();
// Build export DTO
var exportDto = new CustomerDataExportDto
{
// Customer Profile
CustomerId = customer.Id,
TelegramUserId = customer.TelegramUserId,
TelegramUsername = customer.TelegramUsername,
TelegramDisplayName = customer.TelegramDisplayName,
TelegramFirstName = customer.TelegramFirstName,
TelegramLastName = customer.TelegramLastName,
Email = customer.Email,
PhoneNumber = customer.PhoneNumber,
// Preferences
AllowMarketing = customer.AllowMarketing,
AllowOrderUpdates = customer.AllowOrderUpdates,
Language = customer.Language,
Timezone = customer.Timezone,
// Metrics
TotalOrders = customer.TotalOrders,
TotalSpent = customer.TotalSpent,
AverageOrderValue = customer.AverageOrderValue,
FirstOrderDate = customer.FirstOrderDate == DateTime.MinValue ? null : customer.FirstOrderDate,
LastOrderDate = customer.LastOrderDate == DateTime.MinValue ? null : customer.LastOrderDate,
// Account Status
IsBlocked = customer.IsBlocked,
BlockReason = customer.BlockReason,
RiskScore = customer.RiskScore,
CustomerNotes = customer.CustomerNotes,
// Timestamps
CreatedAt = customer.CreatedAt,
UpdatedAt = customer.UpdatedAt,
LastActiveAt = customer.LastActiveAt,
DataRetentionDate = customer.DataRetentionDate,
// Orders
Orders = customer.Orders.Select(o => new CustomerOrderExportDto
{
OrderId = o.Id,
Status = o.Status.ToString(),
TotalAmount = o.TotalAmount,
Currency = o.Currency,
OrderDate = o.CreatedAt,
ShippingName = o.ShippingName,
ShippingAddress = o.ShippingAddress,
ShippingCity = o.ShippingCity,
ShippingPostCode = o.ShippingPostCode,
ShippingCountry = o.ShippingCountry,
TrackingNumber = o.TrackingNumber,
EstimatedDeliveryDate = o.ExpectedDeliveryDate,
ActualDeliveryDate = o.ActualDeliveryDate,
Notes = o.Notes,
Items = o.Items.Select(oi => new CustomerOrderItemExportDto
{
ProductName = oi.Product?.Name ?? "Unknown Product",
VariantName = oi.ProductVariant?.Name,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice,
TotalPrice = oi.TotalPrice
}).ToList()
}).ToList(),
// Messages
Messages = customer.Messages.Select(m => new CustomerMessageExportDto
{
SentAt = m.SentAt ?? m.CreatedAt,
MessageType = m.Type.ToString(),
Content = m.Content,
WasRead = m.Status == LittleShop.Models.MessageStatus.Read,
ReadAt = m.ReadAt
}).ToList(),
// Reviews
Reviews = reviews.Select(r => new CustomerReviewExportDto
{
ProductId = r.ProductId,
ProductName = r.Product?.Name ?? "Unknown Product",
Rating = r.Rating,
Comment = r.Comment,
CreatedAt = r.CreatedAt,
IsApproved = r.IsApproved,
IsVerifiedPurchase = r.IsVerifiedPurchase
}).ToList()
};
_logger.LogInformation("Generated data export for customer {CustomerId} with {OrderCount} orders, {MessageCount} messages, {ReviewCount} reviews",
customerId, exportDto.Orders.Count, exportDto.Messages.Count, exportDto.Reviews.Count);
return exportDto;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating data export for customer {CustomerId}", customerId);
return null;
}
}
}

View File

@@ -0,0 +1,263 @@
using Microsoft.Extensions.Options;
using Microsoft.EntityFrameworkCore;
using LittleShop.Configuration;
using LittleShop.Data;
using LittleShop.Models;
namespace LittleShop.Services;
/// <summary>
/// Background service that enforces GDPR data retention policies
/// Automatically deletes customer data after retention period expires
/// </summary>
public class DataRetentionService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DataRetentionService> _logger;
private readonly DataRetentionOptions _options;
private Timer? _timer;
public DataRetentionService(
IServiceProvider serviceProvider,
ILogger<DataRetentionService> logger,
IOptions<DataRetentionOptions> options)
{
_serviceProvider = serviceProvider;
_logger = logger;
_options = options.Value;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("📋 Data retention enforcement is disabled in configuration");
return;
}
_logger.LogInformation("🔒 Data retention service started. Cleanup time: {CleanupTime}, Interval: {IntervalHours}h",
_options.CleanupTime, _options.CheckIntervalHours);
if (_options.DryRunMode)
{
_logger.LogWarning("⚠️ DRY RUN MODE: Data will be logged but not actually deleted");
}
// Calculate initial delay to run at configured cleanup time
var initialDelay = CalculateInitialDelay();
_logger.LogInformation("⏰ Next cleanup scheduled in {Hours:F2} hours at {NextRun}",
initialDelay.TotalHours, DateTime.UtcNow.Add(initialDelay));
await Task.Delay(initialDelay, stoppingToken);
// Run cleanup immediately after initial delay
await PerformCleanupAsync(stoppingToken);
// Set up periodic timer
var interval = TimeSpan.FromHours(_options.CheckIntervalHours);
_timer = new Timer(
async _ => await PerformCleanupAsync(stoppingToken),
null,
interval,
interval);
// Keep service running
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private TimeSpan CalculateInitialDelay()
{
try
{
var now = DateTime.UtcNow;
var cleanupTime = TimeSpan.Parse(_options.CleanupTime);
var nextRun = now.Date.Add(cleanupTime);
// If cleanup time already passed today, schedule for tomorrow
if (nextRun <= now)
{
nextRun = nextRun.AddDays(1);
}
return nextRun - now;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Invalid CleanupTime format: {CleanupTime}. Using 1 hour delay.", _options.CleanupTime);
return TimeSpan.FromHours(1);
}
}
private async Task PerformCleanupAsync(CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("🧹 Starting data retention cleanup...");
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Find customers eligible for deletion
var now = DateTime.UtcNow;
var customersToDelete = await context.Customers
.Where(c => c.DataRetentionDate.HasValue && c.DataRetentionDate.Value <= now)
.Take(_options.MaxCustomersPerRun)
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.Include(c => c.Messages)
.ToListAsync(cancellationToken);
if (customersToDelete.Count == 0)
{
_logger.LogInformation("✅ No customers eligible for deletion at this time");
return;
}
_logger.LogInformation("📊 Found {Count} customer(s) eligible for deletion", customersToDelete.Count);
// Check for customers approaching deletion (notification period)
await CheckUpcomingDeletionsAsync(context, cancellationToken);
// Process each customer
int deletedCount = 0;
int failedCount = 0;
foreach (var customer in customersToDelete)
{
try
{
// DataRetentionDate is guaranteed non-null by LINQ query filter
var daysOverdue = (now - customer.DataRetentionDate!.Value).TotalDays;
if (_options.DryRunMode)
{
_logger.LogWarning("🔍 DRY RUN: Would delete customer {CustomerId} ({DisplayName}) - {DaysOverdue:F1} days overdue",
customer.Id, customer.DisplayName, daysOverdue);
deletedCount++;
}
else
{
await DeleteCustomerDataAsync(context, customer, cancellationToken);
deletedCount++;
_logger.LogWarning("🗑️ DELETED: Customer {CustomerId} ({DisplayName}) - {DaysOverdue:F1} days after retention date. Orders: {OrderCount}, Messages: {MessageCount}",
customer.Id, customer.DisplayName, daysOverdue, customer.Orders.Count, customer.Messages.Count);
}
}
catch (Exception ex)
{
failedCount++;
_logger.LogError(ex, "❌ Failed to delete customer {CustomerId} ({DisplayName})",
customer.Id, customer.DisplayName);
}
}
if (!_options.DryRunMode)
{
await context.SaveChangesAsync(cancellationToken);
}
_logger.LogInformation("✅ Cleanup completed: {Deleted} deleted, {Failed} failed",
deletedCount, failedCount);
// Log next run time
var nextRun = DateTime.UtcNow.AddHours(_options.CheckIntervalHours);
_logger.LogInformation("⏰ Next cleanup scheduled for {NextRun}", nextRun);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Data retention cleanup failed");
}
}
private async Task CheckUpcomingDeletionsAsync(LittleShopContext context, CancellationToken cancellationToken)
{
if (!_options.NotifyAdminBeforeDeletion)
{
return;
}
try
{
var now = DateTime.UtcNow;
var notificationDate = now.AddDays(_options.NotificationDaysBeforeDeletion);
var upcomingDeletions = await context.Customers
.Where(c => c.DataRetentionDate.HasValue &&
c.DataRetentionDate.Value > now &&
c.DataRetentionDate.Value <= notificationDate)
.Select(c => new
{
c.Id,
c.DisplayName,
c.TelegramUserId,
c.DataRetentionDate,
c.TotalOrders,
c.TotalSpent
})
.ToListAsync(cancellationToken);
if (upcomingDeletions.Any())
{
_logger.LogWarning("⚠️ UPCOMING DELETIONS: {Count} customer(s) scheduled for deletion within {Days} days:",
upcomingDeletions.Count, _options.NotificationDaysBeforeDeletion);
foreach (var customer in upcomingDeletions)
{
var daysUntilDeletion = (customer.DataRetentionDate!.Value - now).TotalDays;
_logger.LogWarning(" 📋 Customer {CustomerId} ({DisplayName}) - {Days:F1} days until deletion. Orders: {Orders}, Spent: £{Spent:F2}",
customer.Id, customer.DisplayName, daysUntilDeletion, customer.TotalOrders, customer.TotalSpent);
}
// TODO: Integrate with notification service to email admins
// var notificationService = scope.ServiceProvider.GetService<INotificationService>();
// await notificationService?.SendAdminNotificationAsync("Upcoming Customer Data Deletions", ...);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to check upcoming deletions");
}
}
private async Task DeleteCustomerDataAsync(LittleShopContext context, Customer customer, CancellationToken cancellationToken)
{
// Delete related data first (cascade delete might not be configured)
// Delete reviews
var reviews = await context.Reviews
.Where(r => r.CustomerId == customer.Id)
.ToListAsync(cancellationToken);
context.Reviews.RemoveRange(reviews);
// Delete order items (via orders)
foreach (var order in customer.Orders)
{
context.OrderItems.RemoveRange(order.Items);
}
// Delete orders
context.Orders.RemoveRange(customer.Orders);
// Delete messages
context.CustomerMessages.RemoveRange(customer.Messages);
// Delete push subscriptions
var subscriptions = await context.PushSubscriptions
.Where(s => s.CustomerId == customer.Id)
.ToListAsync(cancellationToken);
context.PushSubscriptions.RemoveRange(subscriptions);
// Finally, delete customer record
context.Customers.Remove(customer);
_logger.LogInformation("🗑️ Deleted all data for customer {CustomerId}: {Reviews} reviews, {Orders} orders with items, {Messages} messages, {Subscriptions} push subscriptions",
customer.Id, reviews.Count, customer.Orders.Count, customer.Messages.Count, subscriptions.Count);
}
public override void Dispose()
{
_timer?.Dispose();
base.Dispose();
}
}

View File

@@ -6,6 +6,7 @@ namespace LittleShop.Services;
public interface ICryptoPaymentService
{
Task<CryptoPaymentDto> CreatePaymentAsync(Guid orderId, CryptoCurrency currency);
Task<IEnumerable<CryptoPaymentDto>> GetAllPaymentsAsync();
Task<IEnumerable<CryptoPaymentDto>> GetPaymentsByOrderAsync(Guid orderId);
Task<bool> ProcessPaymentWebhookAsync(string invoiceId, PaymentStatus status, decimal amount, string? transactionHash = null, int confirmations = 0);
Task<bool> ProcessSilverPayWebhookAsync(string orderId, PaymentStatus status, decimal amount, string? transactionHash = null, int confirmations = 0);

View File

@@ -16,4 +16,7 @@ public interface ICustomerService
Task UpdateCustomerMetricsAsync(Guid customerId);
Task<bool> BlockCustomerAsync(Guid customerId, string reason);
Task<bool> UnblockCustomerAsync(Guid customerId);
// GDPR Data Export
Task<CustomerDataExportDto?> GetCustomerDataForExportAsync(Guid customerId);
}

View File

@@ -26,4 +26,7 @@ public interface IOrderService
Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync(); // PaymentReceived orders needing acceptance
Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync(); // Accepted orders ready for packing
Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync(); // Orders on hold
// Performance optimization - get all status counts in single query
Task<OrderStatusCountsDto> GetOrderStatusCountsAsync();
}

View File

@@ -607,6 +607,27 @@ public class OrderService : IOrderService
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
}
public async Task<OrderStatusCountsDto> GetOrderStatusCountsAsync()
{
// Single efficient query to get all status counts
var orders = await _context.Orders
.Select(o => new { o.Status })
.ToListAsync();
var statusCounts = new OrderStatusCountsDto
{
PendingPaymentCount = orders.Count(o => o.Status == OrderStatus.PendingPayment),
RequiringActionCount = orders.Count(o => o.Status == OrderStatus.PaymentReceived),
ForPackingCount = orders.Count(o => o.Status == OrderStatus.Accepted),
DispatchedCount = orders.Count(o => o.Status == OrderStatus.Dispatched),
OnHoldCount = orders.Count(o => o.Status == OrderStatus.OnHold),
DeliveredCount = orders.Count(o => o.Status == OrderStatus.Delivered),
CancelledCount = orders.Count(o => o.Status == OrderStatus.Cancelled)
};
return statusCounts;
}
private async Task SendNewOrderNotification(Order order)
{
try

View File

@@ -13,6 +13,6 @@ public class LoginDtoValidator : AbstractValidator<LoginDto>
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(3).WithMessage("Password must be at least 3 characters long");
.MinimumLength(8).WithMessage("Password must be at least 8 characters long");
}
}

View File

@@ -39,5 +39,30 @@
"TeleBot": {
"ApiUrl": "http://localhost:8080",
"ApiKey": "development-key-replace-in-production"
},
"DataRetention": {
"Enabled": true,
"CleanupTime": "03:00",
"CheckIntervalHours": 1,
"DefaultRetentionDays": 7,
"DeletionGracePeriodDays": 1,
"MaxCustomersPerRun": 10,
"DryRunMode": true,
"NotifyAdminBeforeDeletion": true,
"NotificationDaysBeforeDeletion": 1
},
"IPWhitelist": {
"Enabled": false,
"UseForwardedHeaders": true,
"ShowClientIP": true,
"WhitelistedIPs": [
"127.0.0.1",
"::1"
],
"WhitelistedCIDRs": [
"192.168.0.0/16",
"10.0.0.0/8",
"172.16.0.0/12"
]
}
}

View File

@@ -22,6 +22,31 @@
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
"Subject": "mailto:admin@littleshop.local"
},
"DataRetention": {
"Enabled": true,
"CleanupTime": "02:00",
"CheckIntervalHours": 24,
"DefaultRetentionDays": 730,
"DeletionGracePeriodDays": 30,
"MaxCustomersPerRun": 100,
"DryRunMode": false,
"NotifyAdminBeforeDeletion": true,
"NotificationDaysBeforeDeletion": 7
},
"IPWhitelist": {
"Enabled": false,
"UseForwardedHeaders": true,
"ShowClientIP": false,
"WhitelistedIPs": [
"127.0.0.1",
"::1"
],
"WhitelistedCIDRs": [
"192.168.0.0/16",
"10.0.0.0/8",
"172.16.0.0/12"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",

Binary file not shown.

View File

@@ -0,0 +1,312 @@
/*
* Enhanced Navigation Styles for TeleShop Admin
* Improves dropdown menus and active state highlighting
*/
/* ========================================
NAVIGATION BAR
======================================== */
/* Modern Navbar Styling */
.navbar {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
border-bottom: 1px solid #e5e7eb;
}
.navbar-brand {
font-weight: 700;
font-size: 1.25rem;
color: #2563eb !important;
transition: all 0.2s ease;
}
.navbar-brand:hover {
transform: scale(1.05);
}
.navbar-brand i {
margin-right: 0.5rem;
}
/* ========================================
NAVIGATION LINKS
======================================== */
.nav-link {
font-weight: 500;
color: #4b5563 !important;
transition: all 0.2s ease;
border-radius: 0.375rem;
margin: 0 0.25rem;
padding: 0.5rem 1rem !important;
}
.nav-link:hover {
color: #2563eb !important;
background-color: #eff6ff;
}
.nav-link.active {
color: #2563eb !important;
background-color: #dbeafe;
font-weight: 600;
}
.nav-link i {
margin-right: 0.5rem;
font-size: 0.875rem;
}
/* ========================================
DROPDOWN MENUS
======================================== */
.dropdown-menu {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 0.5rem;
min-width: 220px;
margin-top: 0.5rem;
}
.dropdown-item {
border-radius: 0.375rem;
padding: 0.625rem 1rem;
font-weight: 500;
color: #4b5563;
transition: all 0.15s ease;
display: flex;
align-items: center;
}
.dropdown-item i {
margin-right: 0.75rem;
width: 1.25rem;
text-align: center;
color: #6b7280;
transition: color 0.15s ease;
}
.dropdown-item:hover {
background-color: #eff6ff;
color: #2563eb;
transform: translateX(2px);
}
.dropdown-item:hover i {
color: #2563eb;
}
.dropdown-item:active {
background-color: #dbeafe;
}
.dropdown-divider {
margin: 0.5rem 0;
border-color: #e5e7eb;
}
/* ========================================
DROPDOWN TOGGLE INDICATOR
======================================== */
.dropdown-toggle::after {
margin-left: 0.5rem;
vertical-align: 0.1em;
transition: transform 0.2s ease;
}
.dropdown-toggle[aria-expanded="true"]::after {
transform: rotate(180deg);
}
/* ========================================
ACTIVE DROPDOWN HIGHLIGHTING
======================================== */
/* Highlight dropdown parent when child page is active */
.nav-item.dropdown .nav-link.active {
background-color: #dbeafe;
color: #2563eb !important;
}
.nav-item.dropdown .nav-link.active::before {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 3px;
background: linear-gradient(90deg, transparent, #2563eb, transparent);
border-radius: 2px 2px 0 0;
}
.nav-item.dropdown {
position: relative;
}
/* ========================================
RESPONSIVE IMPROVEMENTS
======================================== */
@media (max-width: 768px) {
.navbar-collapse {
background-color: white;
border-radius: 0.5rem;
margin-top: 0.5rem;
padding: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.nav-link {
padding: 0.75rem 1rem !important;
margin: 0.25rem 0;
}
.dropdown-menu {
border: none;
box-shadow: none;
padding-left: 1rem;
margin-top: 0;
background-color: #f9fafb;
}
.dropdown-item {
font-size: 0.9rem;
}
}
/* ========================================
BREADCRUMB NAVIGATION (Future Enhancement)
======================================== */
.breadcrumb {
background-color: transparent;
padding: 0.5rem 0;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.breadcrumb-item {
color: #6b7280;
}
.breadcrumb-item.active {
color: #2563eb;
font-weight: 600;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "";
color: #9ca3af;
padding: 0 0.5rem;
}
.breadcrumb-item a {
color: #6b7280;
text-decoration: none;
transition: color 0.2s ease;
}
.breadcrumb-item a:hover {
color: #2563eb;
}
/* ========================================
ACCESSIBILITY ENHANCEMENTS
======================================== */
.nav-link:focus,
.dropdown-item:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Keyboard navigation indicator */
.nav-link:focus-visible,
.dropdown-item:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* ========================================
ANIMATION & MICRO-INTERACTIONS
======================================== */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-menu.show {
animation: slideDown 0.2s ease-out;
}
/* Hover effect for dropdown parent */
.nav-item.dropdown:hover .nav-link {
background-color: #f3f4f6;
}
/* ========================================
NOTIFICATION BADGES (For Future Use)
======================================== */
.nav-link .badge {
position: absolute;
top: 0.25rem;
right: 0.25rem;
font-size: 0.625rem;
padding: 0.2rem 0.4rem;
border-radius: 0.75rem;
background-color: #ef4444;
color: white;
font-weight: 700;
min-width: 1.25rem;
text-align: center;
}
/* ========================================
LOADING STATE (For Future Enhancement)
======================================== */
.nav-link.loading {
opacity: 0.6;
pointer-events: none;
}
.nav-link.loading::after {
content: "";
display: inline-block;
width: 0.75rem;
height: 0.75rem;
margin-left: 0.5rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ========================================
PWA INSTALL ALERT (Inline Dashboard Content)
======================================== */
/* PWA install alert is now inline Bootstrap alert component */
/* No custom styles needed - using standard Bootstrap alert-info */
/* Dismiss functionality handled via Bootstrap's data-bs-dismiss */
/* Visibility controlled by JavaScript based on localStorage */

View File

@@ -102,9 +102,13 @@ class PWAManager {
installBtn.id = 'pwa-install-btn';
installBtn.className = 'btn btn-primary btn-sm';
installBtn.innerHTML = '<i class="fas fa-download"></i> Install App';
// Detect if mobile bottom nav exists and adjust positioning
const isMobileView = window.innerWidth < 768;
const bottomPosition = isMobileView ? '80px' : '20px'; // 80px to clear mobile bottom nav
installBtn.style.cssText = `
position: fixed;
bottom: 20px;
bottom: ${bottomPosition};
right: 20px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
@@ -242,9 +246,13 @@ class PWAManager {
installBtn.id = 'pwa-install-btn';
installBtn.className = 'btn btn-primary btn-sm';
installBtn.innerHTML = '<i class="fas fa-mobile-alt"></i> Install as App';
// Detect if mobile bottom nav exists and adjust positioning
const isMobileView = window.innerWidth < 768;
const bottomPosition = isMobileView ? '80px' : '20px'; // 80px to clear mobile bottom nav
installBtn.style.cssText = `
position: fixed;
bottom: 20px;
bottom: ${bottomPosition};
right: 20px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);