Product-enhancements-and-validation-fixes

This commit is contained in:
sysadmin 2025-09-01 08:03:00 +01:00
parent c8a55c143b
commit ee4a5c3578
12 changed files with 340 additions and 211 deletions

View File

@ -20,6 +20,11 @@ public class ProductsController : Controller
public async Task<IActionResult> Index()
{
// Prevent caching of products list to show real-time data
Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
Response.Headers.Add("Pragma", "no-cache");
Response.Headers.Add("Expires", "0");
var products = await _productService.GetAllProductsAsync();
return View(products);
}
@ -34,17 +39,31 @@ public class ProductsController : Controller
[HttpPost]
public async Task<IActionResult> Create(CreateProductDto model)
{
Console.WriteLine($"Received Product: Name='{model?.Name}', Description='{model?.Description}', Price={model?.Price}");
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);
return View(model);
}
await _productService.CreateProductAsync(model);
var createdProduct = await _productService.CreateProductAsync(model);
return RedirectToAction(nameof(Index));
}
@ -68,6 +87,7 @@ public class ProductsController : Controller
WeightUnit = product.WeightUnit,
Weight = product.Weight,
Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
IsActive = product.IsActive
};

View File

@ -17,46 +17,86 @@
<div class="card-body">
<form method="post" asp-area="Admin" asp-controller="Products" asp-action="Create">
@Html.AntiForgeryToken()
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger" role="alert">
@foreach (var error in ViewData.ModelState[""].Errors)
{
<div>@error.ErrorMessage</div>
}
<h6><i class="fas fa-exclamation-triangle"></i> Please fix the following errors:</h6>
<ul class="mb-0">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<li>@error.ErrorMessage</li>
}
</ul>
</div>
}
<div class="mb-3">
<label for="Name" class="form-label">Product Name</label>
<input name="Name" id="Name" class="form-control" required />
<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</label>
<textarea name="Description" id="Description" class="form-control" rows="4" required></textarea>
<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-6">
<div class="col-md-4">
<div class="mb-3">
<label for="Price" class="form-label">Price (£)</label>
<input name="Price" id="Price" type="number" step="0.01" class="form-control" required />
<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-6">
<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" required>
<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">@category.Name</option>
<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>
@ -65,21 +105,33 @@
<div class="col-md-6">
<div class="mb-3">
<label for="Weight" class="form-label">Weight/Volume</label>
<input name="Weight" id="Weight" type="number" step="0.01" class="form-control" required />
<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">
<option value="0">Unit</option>
<option value="1">Micrograms</option>
<option value="2">Grams</option>
<option value="3">Ounces</option>
<option value="4">Pounds</option>
<option value="5">Millilitres</option>
<option value="6">Litres</option>
<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>
@ -105,13 +157,99 @@
<div class="card-body">
<ul class="list-unstyled">
<li><strong>Name:</strong> Unique product identifier</li>
<li><strong>Description:</strong> Supports Unicode and emojis</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>
</ul>
<small class="text-muted">You can add photos after creating the product.</small>
<small class="text-muted">The form remembers your last used category and weight unit. Add photos after creation.</small>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
document.addEventListener('DOMContentLoaded', function() {
const categorySelect = document.getElementById('CategoryId');
const weightUnitSelect = document.getElementById('WeightUnit');
const photoInput = document.getElementById('ProductPhotos');
const photoPreview = document.getElementById('photo-preview');
// 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);
}
});
</script>
}

View File

@ -40,13 +40,19 @@
</div>
<div class="row">
<div class="col-md-6">
<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" required />
</div>
</div>
<div class="col-md-6">
<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" type="number" min="0" class="form-control" required />
</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" required>

View File

@ -27,6 +27,7 @@
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th>Weight</th>
<th>Status</th>
<th>Actions</th>
@ -58,6 +59,16 @@
<td>
<strong>£@product.Price</strong>
</td>
<td>
@if (product.StockQuantity > 0)
{
<span class="badge bg-success">@product.StockQuantity in stock</span>
}
else
{
<span class="badge bg-warning text-dark">Out of stock</span>
}
</td>
<td>
@product.Weight @product.WeightUnit.ToString().ToLower()
</td>

View File

@ -11,6 +11,7 @@ public class ProductDto
public decimal Price { get; set; }
public decimal Weight { get; set; }
public ProductWeightUnit WeightUnit { get; set; }
public int StockQuantity { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
@ -34,17 +35,23 @@ public class CreateProductDto
[StringLength(200)]
public string Name { get; set; } = string.Empty;
[Required]
[Required(AllowEmptyStrings = true)]
public string Description { get; set; } = string.Empty;
[Range(0.01, double.MaxValue)]
[Required]
[Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than 0")]
public decimal Price { get; set; }
[Required]
[Range(0.01, double.MaxValue, ErrorMessage = "Weight must be greater than 0")]
public decimal Weight { get; set; }
public ProductWeightUnit WeightUnit { get; set; }
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Grams;
[Required]
[Range(0, int.MaxValue)]
public int StockQuantity { get; set; } = 0;
[Required(ErrorMessage = "Please select a category")]
public Guid CategoryId { get; set; }
}
@ -62,6 +69,9 @@ public class UpdateProductDto
public ProductWeightUnit? WeightUnit { get; set; }
[Range(0, int.MaxValue)]
public int? StockQuantity { get; set; }
public Guid? CategoryId { get; set; }
public bool? IsActive { get; set; }

View File

@ -13,7 +13,6 @@ public class Product
[StringLength(200)]
public string Name { get; set; } = string.Empty;
[Required]
public string Description { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,2)")]
@ -24,6 +23,8 @@ public class Product
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Kilogram;
public int StockQuantity { get; set; } = 0;
public Guid CategoryId { get; set; }
public bool IsActive { get; set; } = true;

View File

@ -167,10 +167,12 @@ app.MapControllerRoute(
app.MapControllers(); // API routes
// Ensure database is created
// Apply database migrations and seed data
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<LittleShopContext>();
// Ensure database is created (temporary while fixing migrations)
context.Database.EnsureCreated();
// Seed default admin user

View File

@ -80,6 +80,7 @@ public class DataSeederService : IDataSeederService
Price = 89.99m,
Weight = 250,
WeightUnit = ProductWeightUnit.Grams,
StockQuantity = 10,
CategoryId = categories[0].Id,
IsActive = true,
CreatedAt = DateTime.UtcNow,
@ -93,6 +94,7 @@ public class DataSeederService : IDataSeederService
Price = 19.99m,
Weight = 50,
WeightUnit = ProductWeightUnit.Grams,
StockQuantity = 10,
CategoryId = categories[0].Id,
IsActive = true,
CreatedAt = DateTime.UtcNow,
@ -106,6 +108,7 @@ public class DataSeederService : IDataSeederService
Price = 24.99m,
Weight = 200,
WeightUnit = ProductWeightUnit.Grams,
StockQuantity = 15,
CategoryId = categories[1].Id,
IsActive = true,
CreatedAt = DateTime.UtcNow,
@ -119,6 +122,7 @@ public class DataSeederService : IDataSeederService
Price = 59.99m,
Weight = 500,
WeightUnit = ProductWeightUnit.Grams,
StockQuantity = 15,
CategoryId = categories[1].Id,
IsActive = true,
CreatedAt = DateTime.UtcNow,
@ -132,6 +136,7 @@ public class DataSeederService : IDataSeederService
Price = 34.99m,
Weight = 800,
WeightUnit = ProductWeightUnit.Grams,
StockQuantity = 25,
CategoryId = categories[2].Id,
IsActive = true,
CreatedAt = DateTime.UtcNow,

View File

@ -95,6 +95,7 @@ public class ProductService : IProductService
Price = product.Price,
Weight = product.Weight,
WeightUnit = product.WeightUnit,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
CategoryName = product.Category.Name,
CreatedAt = product.CreatedAt,
@ -117,10 +118,11 @@ public class ProductService : IProductService
{
Id = Guid.NewGuid(),
Name = createProductDto.Name,
Description = createProductDto.Description,
Description = string.IsNullOrEmpty(createProductDto.Description) ? " " : createProductDto.Description,
Price = createProductDto.Price,
Weight = createProductDto.Weight,
WeightUnit = createProductDto.WeightUnit,
StockQuantity = createProductDto.StockQuantity,
CategoryId = createProductDto.CategoryId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
@ -169,6 +171,9 @@ public class ProductService : IProductService
if (updateProductDto.WeightUnit.HasValue)
product.WeightUnit = updateProductDto.WeightUnit.Value;
if (updateProductDto.StockQuantity.HasValue)
product.StockQuantity = updateProductDto.StockQuantity.Value;
if (updateProductDto.CategoryId.HasValue)
product.CategoryId = updateProductDto.CategoryId.Value;

View File

@ -268,48 +268,50 @@ h1 {
}
/* Mobile Table - Stack on Small Screens */
.table-responsive {
border: none;
}
@media (max-width: 768px) {
.table-responsive {
border: none;
}
.table {
font-size: 0.875rem;
}
.table {
font-size: 0.875rem;
}
.table thead {
display: none;
}
.table thead {
display: none;
}
.table tbody,
.table tbody tr,
.table tbody td {
display: block;
width: 100%;
}
.table tbody,
.table tbody tr,
.table tbody td {
display: block;
width: 100%;
}
.table tbody tr {
background: white;
border: 1px solid var(--grey-200);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
}
.table tbody tr {
background: white;
border: 1px solid var(--grey-200);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
box-shadow: var(--shadow-sm);
}
.table tbody td {
border: none;
padding: var(--spacing-sm) 0;
position: relative;
padding-left: 40%;
}
.table tbody td {
border: none;
padding: var(--spacing-sm) 0;
position: relative;
padding-left: 40%;
}
.table tbody td:before {
content: attr(data-label) ": ";
position: absolute;
left: 0;
width: 35%;
font-weight: 600;
color: var(--grey-600);
.table tbody td:before {
content: attr(data-label) ": ";
position: absolute;
left: 0;
width: 35%;
font-weight: 600;
color: var(--grey-600);
}
}
/* Mobile Navigation */

View File

@ -37,12 +37,13 @@ class PWAManager {
// Setup push notifications
this.setupPushNotifications();
// Show manual install option after 3 seconds if no prompt appeared and app not installed
// Show manual install option after 5 seconds if no prompt appeared and app not installed
setTimeout(() => {
if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) {
console.log('PWA: No install prompt appeared, showing manual install guide');
this.showManualInstallButton();
}
}, 3000);
}, 5000);
}
setupInstallPrompt() {
@ -70,6 +71,9 @@ class PWAManager {
this.hideInstallButton();
} else {
console.log('PWA: App is not installed, waiting for install prompt...');
console.log('PWA: Current URL:', window.location.href);
console.log('PWA: Display mode:', window.matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser');
console.log('PWA: User agent:', navigator.userAgent);
}
// Periodically check if app becomes installed (for cases where user installs via browser menu)
@ -210,7 +214,27 @@ class PWAManager {
`;
installBtn.addEventListener('click', () => {
alert('To install this app:\\n\\n1. Click the browser menu (⋮)\\n2. Select "Install LittleShop Admin"\\n\\nOr look for the install icon in the address bar!');
const isChrome = navigator.userAgent.includes('Chrome');
const isEdge = navigator.userAgent.includes('Edge');
const isFirefox = navigator.userAgent.includes('Firefox');
let instructions = 'To install this app:\\n\\n';
if (isChrome || isEdge) {
instructions += '1. Look for the install icon (⬇️) in the address bar\\n';
instructions += '2. Or click the browser menu (⋮) → "Install LittleShop Admin"\\n';
instructions += '3. Or check if there\'s an "Install app" option in the browser menu';
} else if (isFirefox) {
instructions += '1. Firefox doesn\'t support PWA installation yet\\n';
instructions += '2. You can bookmark this page for easy access\\n';
instructions += '3. Or use Chrome/Edge for the full PWA experience';
} else {
instructions += '1. Look for an install or "Add to Home Screen" option\\n';
instructions += '2. Check your browser menu for app installation\\n';
instructions += '3. Or bookmark this page for quick access';
}
alert(instructions);
});
document.body.appendChild(installBtn);
@ -230,25 +254,21 @@ class PWAManager {
}
try {
// Check if user has dismissed push notifications recently
const dismissedUntil = localStorage.getItem('pushNotificationsDismissedUntil');
if (dismissedUntil && new Date() < new Date(dismissedUntil)) {
console.log('PWA: Push notifications dismissed by user, skipping setup');
return;
}
// Get VAPID public key from server
await this.getVapidPublicKey();
// Check if user is already subscribed
await this.checkPushSubscription();
// Only show setup UI if not subscribed and not recently dismissed
const isSubscribedFromCache = localStorage.getItem('pushNotificationsSubscribed') === 'true';
if (!this.pushSubscription && !isSubscribedFromCache) {
this.showPushNotificationSetup();
// Simple logic: only show prompt if user is not subscribed
if (!this.pushSubscription) {
// Check if we've already asked this session
if (!sessionStorage.getItem('pushNotificationPromptShown')) {
this.showPushNotificationSetup();
sessionStorage.setItem('pushNotificationPromptShown', 'true');
}
} else {
console.log('PWA: User already subscribed to push notifications, skipping setup UI');
console.log('PWA: User already subscribed to push notifications');
}
} catch (error) {
@ -280,34 +300,9 @@ class PWAManager {
try {
this.pushSubscription = await this.swRegistration.pushManager.getSubscription();
if (this.pushSubscription) {
console.log('PWA: Browser has push subscription');
// Verify server-side subscription still exists by trying to send a test
try {
const response = await fetch('/api/push/subscriptions', {
credentials: 'same-origin'
});
if (response.ok) {
const subscriptions = await response.json();
const hasServerSubscription = subscriptions.some(sub =>
sub.endpoint && this.pushSubscription.endpoint.includes(sub.endpoint.substring(0, 50))
);
if (!hasServerSubscription) {
console.log('PWA: Server subscription missing, will re-subscribe on next attempt');
localStorage.removeItem('pushNotificationsSubscribed');
} else {
console.log('PWA: Server subscription confirmed');
localStorage.setItem('pushNotificationsSubscribed', 'true');
}
}
} catch (error) {
console.log('PWA: Could not verify server subscription:', error.message);
}
console.log('PWA: User has active push subscription');
} else {
console.log('PWA: User is not subscribed to push notifications');
localStorage.removeItem('pushNotificationsSubscribed');
}
} catch (error) {
console.error('PWA: Error checking push subscription:', error);
@ -358,12 +353,7 @@ class PWAManager {
if (response.ok) {
this.pushSubscription = subscription;
console.log('PWA: Successfully subscribed to push notifications');
// Cache subscription state
localStorage.setItem('pushNotificationsSubscribed', 'true');
localStorage.setItem('pushNotificationsSubscribedAt', new Date().toISOString());
this.updatePushNotificationUI();
this.hidePushNotificationSetup();
return true;
} else {
throw new Error('Failed to save push subscription to server');
@ -397,12 +387,6 @@ class PWAManager {
this.pushSubscription = null;
console.log('PWA: Successfully unsubscribed from push notifications');
// Clear subscription cache
localStorage.removeItem('pushNotificationsSubscribed');
localStorage.removeItem('pushNotificationsSubscribedAt');
this.updatePushNotificationUI();
return true;
} catch (error) {
console.error('PWA: Failed to unsubscribe from push notifications:', error);
@ -427,32 +411,24 @@ class PWAManager {
max-width: 350px;
`;
const isSubscribed = !!this.pushSubscription;
setupDiv.innerHTML = `
<div class="d-flex align-items-center">
<i class="fas fa-bell me-2"></i>
<div class="flex-grow-1">
<strong>Push Notifications</strong><br>
<small>${isSubscribed ? 'You are subscribed to notifications' : 'Get notified of new orders and updates'}</small>
<small>Get notified of new orders and updates</small>
</div>
<div class="ms-2">
${isSubscribed ?
'<button type="button" class="btn btn-sm btn-outline-danger" id="unsubscribe-push-btn">Turn Off</button>' :
'<div class="d-flex flex-column gap-1"><button type="button" class="btn btn-sm btn-primary" id="subscribe-push-btn">Turn On</button><button type="button" class="btn btn-sm btn-outline-secondary" id="dismiss-push-btn">Not Now</button></div>'
}
<button type="button" class="btn btn-sm btn-primary" id="subscribe-push-btn">Enable</button>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" id="close-push-setup"></button>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(setupDiv);
// Add event listeners
// Add event listener for subscribe button
const subscribeBtn = document.getElementById('subscribe-push-btn');
const unsubscribeBtn = document.getElementById('unsubscribe-push-btn');
const dismissBtn = document.getElementById('dismiss-push-btn');
const closeBtn = document.getElementById('close-push-setup');
if (subscribeBtn) {
subscribeBtn.addEventListener('click', async () => {
@ -475,76 +451,17 @@ class PWAManager {
alert('Failed to enable push notifications: ' + userMessage);
subscribeBtn.disabled = false;
subscribeBtn.innerHTML = 'Turn On';
subscribeBtn.innerHTML = 'Enable';
}
});
}
if (unsubscribeBtn) {
unsubscribeBtn.addEventListener('click', async () => {
unsubscribeBtn.disabled = true;
unsubscribeBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Disabling...';
try {
await this.unsubscribeFromPushNotifications();
this.showNotification('Push notifications disabled', {
body: 'You will no longer receive push notifications.'
});
} catch (error) {
alert('Failed to disable push notifications: ' + error.message);
unsubscribeBtn.disabled = false;
unsubscribeBtn.innerHTML = 'Turn Off';
}
});
}
// Handle dismiss button
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
// Dismiss for 24 hours
const dismissUntil = new Date();
dismissUntil.setHours(dismissUntil.getHours() + 24);
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
const element = document.getElementById('push-notification-setup');
if (element) {
element.remove();
}
console.log('PWA: Push notifications dismissed for 24 hours');
});
}
// Handle close button
if (closeBtn) {
closeBtn.addEventListener('click', () => {
// Dismiss for 1 hour
const dismissUntil = new Date();
dismissUntil.setHours(dismissUntil.getHours() + 1);
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
console.log('PWA: Push notifications dismissed for 1 hour');
});
}
// Auto-hide after 15 seconds
setTimeout(() => {
const element = document.getElementById('push-notification-setup');
if (element) {
element.remove();
// Auto-dismiss for 2 hours if user ignores
const dismissUntil = new Date();
dismissUntil.setHours(dismissUntil.getHours() + 2);
localStorage.setItem('pushNotificationsDismissedUntil', dismissUntil.toISOString());
console.log('PWA: Push notifications auto-dismissed for 2 hours');
}
}, 15000);
}
updatePushNotificationUI() {
hidePushNotificationSetup() {
const setupDiv = document.getElementById('push-notification-setup');
if (setupDiv) {
setupDiv.remove();
this.showPushNotificationSetup();
console.log('PWA: Push notification setup hidden');
}
}

View File

@ -1,12 +1,9 @@
// LittleShop Admin - Service Worker
// Provides offline functionality and app-like experience
const CACHE_NAME = 'littleshop-admin-v1';
const CACHE_NAME = 'littleshop-admin-v2';
const urlsToCache = [
'/Admin/Dashboard',
'/Admin/Orders',
'/Admin/Products',
'/Admin/Categories',
// Only cache static assets, not dynamic admin pages
'/lib/bootstrap/css/bootstrap.min.css',
'/lib/fontawesome/css/all.min.css',
'/lib/bootstrap-icons/bootstrap-icons.css',
@ -14,6 +11,7 @@ const urlsToCache = [
'/lib/jquery/jquery.min.js',
'/lib/bootstrap/js/bootstrap.bundle.min.js',
'/js/modern-mobile.js',
'/js/pwa.js',
'/lib/fontawesome/webfonts/fa-solid-900.woff2',
'/lib/fontawesome/webfonts/fa-brands-400.woff2'
];
@ -121,13 +119,27 @@ self.addEventListener('notificationclick', (event) => {
);
});
// Fetch event - serve from cache, fallback to network
// Fetch event - network-first for admin pages, cache-first for assets
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
// Network-first strategy for admin pages (always fresh data)
if (url.pathname.startsWith('/Admin/')) {
event.respondWith(
fetch(event.request).catch(() => {
// If network fails, try cache as fallback
return caches.match(event.request);
})
);
return;
}
// Cache-first strategy for static assets
event.respondWith(
caches.match(event.request)
.then((response) => {