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:
SysAdmin 2025-10-02 14:35:52 +01:00
parent 4992b6b839
commit 5adf1b90d5
6 changed files with 96 additions and 286 deletions

View File

@ -34,12 +34,6 @@ public class ProductsController : Controller
return View(products); return View(products);
} }
public IActionResult Blazor(Guid? id)
{
ViewData["ProductId"] = id;
return View();
}
public async Task<IActionResult> Create() public async Task<IActionResult> Create()
{ {
var categories = await _categoryService.GetAllCategoriesAsync(); var categories = await _categoryService.GetAllCategoriesAsync();
@ -193,243 +187,6 @@ public class ProductsController : Controller
return RedirectToAction(nameof(Index)); 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 // Product Import/Export
public IActionResult Import() public IActionResult Import()
{ {

View File

@ -442,6 +442,22 @@
</ul> </ul>
</div> </div>
</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>
</div> </div>

View File

@ -9,20 +9,9 @@
<h1><i class="fas fa-box"></i> Products</h1> <h1><i class="fas fa-box"></i> Products</h1>
</div> </div>
<div class="col-auto"> <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"> <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> <i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
</a> </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>
</div> </div>
</div> </div>
@ -109,24 +98,9 @@
} }
</td> </td>
<td> <td>
<div class="btn-group btn-group-sm"> <a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary btn-sm" title="View Details">
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product"> <i class="fas fa-info-circle"></i> <span class="d-none d-sm-inline">Details</span>
<i class="fas fa-edit"></i>
</a> </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>
</td> </td>
</tr> </tr>
} }

View File

@ -46,9 +46,6 @@
<a class="navbar-brand" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })"> <a class="navbar-brand" href="@Url.Action("Index", "Dashboard", new { area = "Admin" })">
<i class="fas fa-store"></i> TeleShop Admin <i class="fas fa-store"></i> TeleShop Admin
</a> </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"> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1"> <ul class="navbar-nav flex-grow-1">
<li class="nav-item"> <li class="nav-item">
@ -178,8 +175,8 @@
</li> </li>
<li class="mobile-nav-item"> <li class="mobile-nav-item">
<a href="#" class="mobile-nav-link" onclick="toggleSettingsDrawer(); return false;"> <a href="#" class="mobile-nav-link" onclick="toggleSettingsDrawer(); return false;">
<i class="fas fa-cog"></i> <i class="fas fa-bars"></i>
<span>Settings</span> <span>Menu</span>
</a> </a>
</li> </li>
</ul> </ul>
@ -189,7 +186,7 @@
<div class="drawer-overlay" onclick="toggleSettingsDrawer()"></div> <div class="drawer-overlay" onclick="toggleSettingsDrawer()"></div>
<div class="settings-drawer" id="settingsDrawer"> <div class="settings-drawer" id="settingsDrawer">
<div class="settings-drawer-header"> <div class="settings-drawer-header">
<h5>Settings</h5> <h5>Menu</h5>
<button class="settings-drawer-close" onclick="toggleSettingsDrawer()"> <button class="settings-drawer-close" onclick="toggleSettingsDrawer()">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
@ -243,6 +240,24 @@
System Settings System Settings
</a> </a>
</li> </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"> <li class="settings-menu-item">
<form method="post" action="@Url.Action("Logout", "Account", new { area = "Admin" })"> <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;"> <button type="submit" class="settings-menu-link" style="width: 100%; border: none; background: none; text-align: left;">

View File

@ -42,11 +42,37 @@ class AdminNotificationManager {
} }
showAdminNotificationPrompt() { showAdminNotificationPrompt() {
// Check if notifications are supported
if (!('Notification' in window) || !('PushManager' in window)) {
console.log('Admin Notifications: Push notifications not supported in this browser');
return;
}
// Check if already enabled
if (window.pwaManager && window.pwaManager.pushSubscription) {
console.log('Admin Notifications: Already subscribed');
return;
}
// Check if prompt already exists // Check if prompt already exists
if (document.getElementById('admin-notification-prompt')) { if (document.getElementById('admin-notification-prompt')) {
return; return;
} }
// Check if dismissed in current session
const sessionDismissed = sessionStorage.getItem('notificationPromptDismissed');
if (sessionDismissed === 'true') {
console.log('Admin Notifications: Prompt dismissed this session');
return;
}
// Check if permanently dismissed
const permanentlyDismissed = localStorage.getItem('pushNotificationDeclined');
if (permanentlyDismissed === 'true') {
console.log('Admin Notifications: Notifications declined permanently');
return;
}
const promptDiv = document.createElement('div'); const promptDiv = document.createElement('div');
promptDiv.id = 'admin-notification-prompt'; promptDiv.id = 'admin-notification-prompt';
promptDiv.className = 'alert alert-warning alert-dismissible position-fixed'; promptDiv.className = 'alert alert-warning alert-dismissible position-fixed';
@ -74,7 +100,7 @@ class AdminNotificationManager {
</div> </div>
</div> </div>
</div> </div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" id="close-notification-prompt"></button>
`; `;
document.body.appendChild(promptDiv); document.body.appendChild(promptDiv);
@ -84,6 +110,7 @@ class AdminNotificationManager {
try { try {
await this.enableNotifications(); await this.enableNotifications();
promptDiv.remove(); promptDiv.remove();
sessionStorage.setItem('notificationPromptDismissed', 'true');
} catch (error) { } catch (error) {
console.error('Failed to enable notifications:', error); console.error('Failed to enable notifications:', error);
this.showNotificationError('Failed to enable notifications. Please try again.'); this.showNotificationError('Failed to enable notifications. Please try again.');
@ -92,8 +119,14 @@ class AdminNotificationManager {
document.getElementById('remind-later').addEventListener('click', () => { document.getElementById('remind-later').addEventListener('click', () => {
promptDiv.remove(); promptDiv.remove();
// Set reminder for 1 hour // Mark as dismissed for this session only
setTimeout(() => this.showAdminNotificationPrompt(), 60 * 60 * 1000); sessionStorage.setItem('notificationPromptDismissed', 'true');
});
document.getElementById('close-notification-prompt').addEventListener('click', () => {
promptDiv.remove();
// Mark as dismissed for this session only
sessionStorage.setItem('notificationPromptDismissed', 'true');
}); });
} }

View File

@ -220,10 +220,20 @@ class PWAManager {
} }
showManualInstallButton() { showManualInstallButton() {
// Don't show if PWA is not supported or already installed
if (this.isInstalled() || document.getElementById('pwa-install-btn')) { if (this.isInstalled() || document.getElementById('pwa-install-btn')) {
return; return;
} }
// Only show if browser supports PWA (has beforeinstallprompt event or is iOS)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const supportsPWA = 'serviceWorker' in navigator && ('BeforeInstallPromptEvent' in window || isIOS);
if (!supportsPWA) {
console.log('PWA: Browser does not support PWA installation');
return;
}
const installBtn = document.createElement('button'); const installBtn = document.createElement('button');
installBtn.id = 'pwa-install-btn'; installBtn.id = 'pwa-install-btn';
installBtn.className = 'btn btn-primary btn-sm'; installBtn.className = 'btn btn-primary btn-sm';
@ -269,12 +279,17 @@ class PWAManager {
const isChrome = navigator.userAgent.includes('Chrome'); const isChrome = navigator.userAgent.includes('Chrome');
const isEdge = navigator.userAgent.includes('Edge'); const isEdge = navigator.userAgent.includes('Edge');
const isFirefox = navigator.userAgent.includes('Firefox'); const isFirefox = navigator.userAgent.includes('Firefox');
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
let instructions = 'To install this app:\n\n'; let instructions = 'To install this app:\n\n';
if (isChrome || isEdge) { if (isIOS) {
instructions += '1. Tap the Share button (□↑) in Safari\n';
instructions += '2. Scroll down and tap "Add to Home Screen"\n';
instructions += '3. Tap "Add" to install';
} else if (isChrome || isEdge) {
instructions += '1. Look for the install icon (⬇️) in the address bar\n'; instructions += '1. Look for the install icon (⬇️) in the address bar\n';
instructions += '2. Or click the browser menu (⋮) → "Install LittleShop Admin"\n'; instructions += '2. Or click the browser menu (⋮) → "Install TeleShop Admin"\n';
instructions += '3. Or check if there\'s an "Install app" option in the browser menu'; instructions += '3. Or check if there\'s an "Install app" option in the browser menu';
} else if (isFirefox) { } else if (isFirefox) {
instructions += '1. Firefox doesn\'t support PWA installation yet\n'; instructions += '1. Firefox doesn\'t support PWA installation yet\n';