Refactor: Streamline product management UI and enhance PWA behavior
**Product List Improvements:** - Move Import/Export to settings menu for cleaner interface - Replace Edit/Variants/Multi-Buys buttons with single Details action - Remove Blazor UI button from product list - Simplify product row actions for better mobile UX **Product Details Enhancements:** - Add Danger Zone section with Delete button at bottom - Improve visual hierarchy and action placement **Navigation Updates:** - Remove hamburger menu toggle (desktop nav always visible) - Rename Settings to Menu in mobile bottom nav - Update settings drawer header and icon **Code Cleanup:** - Remove unused Blazor, Variations, and Variants endpoints (243 lines) - Consolidate variant/multi-buy management within product details - Clean up ProductsController for better maintainability **PWA & Notifications:** - Add proper PWA support detection (only show if browser supports) - Implement session-based notification prompt tracking - Prevent repeated prompts after dismissal in same session - Respect permanent dismissal preferences - Enhance iOS Safari detection and instructions **Technical Details:** - 6 files changed, 96 insertions(+), 286 deletions(-) - Build successful with 0 errors - All features production-ready 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,6 @@ public class ProductsController : Controller
|
||||
return View(products);
|
||||
}
|
||||
|
||||
public IActionResult Blazor(Guid? id)
|
||||
{
|
||||
ViewData["ProductId"] = id;
|
||||
return View();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
@@ -193,243 +187,6 @@ public class ProductsController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Product Variations
|
||||
public async Task<IActionResult> Variations(Guid id)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(id);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
ViewData["Product"] = product;
|
||||
var variations = await _productService.GetProductMultiBuysAsync(id);
|
||||
return View(variations);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> CreateVariation(Guid productId)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
// Force no-cache to ensure updated form loads
|
||||
Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
Response.Headers.Add("Pragma", "no-cache");
|
||||
Response.Headers.Add("Expires", "0");
|
||||
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Get existing quantities to help user avoid duplicates
|
||||
var existingQuantities = await _productService.GetProductMultiBuysAsync(productId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(new CreateProductMultiBuyDto { ProductId = productId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CreateVariation(CreateProductMultiBuyDto model)
|
||||
{
|
||||
// Debug form data
|
||||
Console.WriteLine("=== FORM DEBUG ===");
|
||||
Console.WriteLine($"Received CreateVariation POST: ProductId={model?.ProductId}, Name='{model?.Name}', Quantity={model?.Quantity}, Price={model?.Price}");
|
||||
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("================");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
foreach (var key in ModelState.Keys)
|
||||
{
|
||||
var errors = ModelState[key]?.Errors;
|
||||
if (errors?.Any() == true)
|
||||
{
|
||||
Console.WriteLine($"ModelState Error - {key}: {string.Join(", ", errors.Select(e => e.ErrorMessage))}");
|
||||
}
|
||||
}
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(model.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Re-populate existing quantities for error display
|
||||
var existingQuantities = await _productService.GetProductMultiBuysAsync(model.ProductId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _productService.CreateProductMultiBuyAsync(model);
|
||||
return RedirectToAction(nameof(Variations), new { id = model.ProductId });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// Add the error to the appropriate field if it's a quantity conflict
|
||||
if (ex.Message.Contains("quantity") && ex.Message.Contains("already exists"))
|
||||
{
|
||||
ModelState.AddModelError("Quantity", ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError("", ex.Message);
|
||||
}
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(model.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Re-populate existing quantities for error display
|
||||
var existingQuantities = await _productService.GetProductMultiBuysAsync(model.ProductId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> EditVariation(Guid id)
|
||||
{
|
||||
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
|
||||
if (variation == null)
|
||||
return NotFound();
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(variation.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
var model = new UpdateProductMultiBuyDto
|
||||
{
|
||||
Name = variation.Name,
|
||||
Description = variation.Description,
|
||||
Quantity = variation.Quantity,
|
||||
Price = variation.Price,
|
||||
SortOrder = variation.SortOrder,
|
||||
IsActive = variation.IsActive
|
||||
};
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> EditVariation(Guid id, UpdateProductMultiBuyDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
|
||||
var product = await _productService.GetProductByIdAsync(variation!.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var success = await _productService.UpdateProductMultiBuyAsync(id, model);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
var variationToRedirect = await _productService.GetProductMultiBuyByIdAsync(id);
|
||||
return RedirectToAction(nameof(Variations), new { id = variationToRedirect!.ProductId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteVariation(Guid id)
|
||||
{
|
||||
var variation = await _productService.GetProductMultiBuyByIdAsync(id);
|
||||
if (variation == null)
|
||||
return NotFound();
|
||||
|
||||
await _productService.DeleteProductMultiBuyAsync(id);
|
||||
return RedirectToAction(nameof(Variations), new { id = variation.ProductId });
|
||||
}
|
||||
|
||||
// Product Variants (Colors/Flavors)
|
||||
public async Task<IActionResult> Variants(Guid id)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(id);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
ViewData["Product"] = product;
|
||||
var variants = await _productService.GetProductVariantsAsync(id);
|
||||
return View("ProductVariants", variants);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> CreateVariant(Guid productId)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
ViewData["Product"] = product;
|
||||
return View("CreateVariant", new CreateProductVariantDto { ProductId = productId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CreateVariant(CreateProductVariantDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(model.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
return View("CreateVariant", model);
|
||||
}
|
||||
|
||||
await _productService.CreateProductVariantAsync(model);
|
||||
return RedirectToAction(nameof(Variants), new { id = model.ProductId });
|
||||
}
|
||||
|
||||
public async Task<IActionResult> EditVariant(Guid id)
|
||||
{
|
||||
var variant = await _productService.GetProductVariantByIdAsync(id);
|
||||
if (variant == null)
|
||||
return NotFound();
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(variant.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
var model = new UpdateProductVariantDto
|
||||
{
|
||||
Name = variant.Name,
|
||||
VariantType = variant.VariantType,
|
||||
StockLevel = variant.StockLevel,
|
||||
SortOrder = variant.SortOrder,
|
||||
IsActive = variant.IsActive
|
||||
};
|
||||
|
||||
return View("EditVariant", model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> EditVariant(Guid id, UpdateProductVariantDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var variant = await _productService.GetProductVariantByIdAsync(id);
|
||||
var product = await _productService.GetProductByIdAsync(variant!.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
return View("EditVariant", model);
|
||||
}
|
||||
|
||||
await _productService.UpdateProductVariantAsync(id, model);
|
||||
var variantToRedirect = await _productService.GetProductVariantByIdAsync(id);
|
||||
return RedirectToAction(nameof(Variants), new { id = variantToRedirect!.ProductId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteVariant(Guid id)
|
||||
{
|
||||
var variant = await _productService.GetProductVariantByIdAsync(id);
|
||||
if (variant == null)
|
||||
return NotFound();
|
||||
|
||||
await _productService.DeleteProductVariantAsync(id);
|
||||
return RedirectToAction(nameof(Variants), new { id = variant.ProductId });
|
||||
}
|
||||
|
||||
// Product Import/Export
|
||||
public IActionResult Import()
|
||||
{
|
||||
|
||||
@@ -442,6 +442,22 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5><i class="fas fa-exclamation-triangle"></i> Danger Zone</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Once you delete a product, there is no going back. Please be certain.</p>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = productId })"
|
||||
onsubmit="return confirm('Are you sure you want to delete this product? This action cannot be undone.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="fas fa-trash"></i> Delete Product
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,20 +9,9 @@
|
||||
<h1><i class="fas fa-box"></i> Products</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<a href="@Url.Action("Blazor")" class="btn btn-success">
|
||||
<i class="fas fa-rocket"></i> <span class="d-none d-sm-inline">New</span> Blazor UI
|
||||
</a>
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Import")" class="btn btn-outline-success">
|
||||
<i class="fas fa-upload"></i> <span class="d-none d-sm-inline">Import</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Export")" class="btn btn-outline-info">
|
||||
<i class="fas fa-download"></i> <span class="d-none d-sm-inline">Export</span>
|
||||
</a>
|
||||
</div>
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,24 +98,9 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Multi-Buys">
|
||||
<i class="fas fa-tags"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variants", new { id = product.Id })" class="btn btn-outline-success" title="Manage Variants">
|
||||
<i class="fas fa-palette"></i>
|
||||
</a>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this product?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete Product">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary btn-sm" title="View Details">
|
||||
<i class="fas fa-info-circle"></i> <span class="d-none d-sm-inline">Details</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
<a class="navbar-brand" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
|
||||
<i class="fas fa-store"></i> TeleShop Admin
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
|
||||
<ul class="navbar-nav flex-grow-1">
|
||||
<li class="nav-item">
|
||||
@@ -178,8 +175,8 @@
|
||||
</li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="#" class="mobile-nav-link" onclick="toggleSettingsDrawer(); return false;">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
<i class="fas fa-bars"></i>
|
||||
<span>Menu</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -189,7 +186,7 @@
|
||||
<div class="drawer-overlay" onclick="toggleSettingsDrawer()"></div>
|
||||
<div class="settings-drawer" id="settingsDrawer">
|
||||
<div class="settings-drawer-header">
|
||||
<h5>Settings</h5>
|
||||
<h5>Menu</h5>
|
||||
<button class="settings-drawer-close" onclick="toggleSettingsDrawer()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -243,6 +240,24 @@
|
||||
System Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<a href="@Url.Action("Import", "Products", new { area = "Admin" })" class="settings-menu-link">
|
||||
<i class="fas fa-upload"></i>
|
||||
Import Products
|
||||
</a>
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<a href="@Url.Action("Export", "Products", new { area = "Admin" })" class="settings-menu-link">
|
||||
<i class="fas fa-download"></i>
|
||||
Export Products
|
||||
</a>
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<form method="post" action="@Url.Action("Logout", "Account", new { area = "Admin" })">
|
||||
<button type="submit" class="settings-menu-link" style="width: 100%; border: none; background: none; text-align: left;">
|
||||
|
||||
Reference in New Issue
Block a user