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() 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(); var products = await _productService.GetAllProductsAsync();
return View(products); return View(products);
} }
@ -34,17 +39,31 @@ public class ProductsController : Controller
[HttpPost] [HttpPost]
public async Task<IActionResult> Create(CreateProductDto model) 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}"); Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
// Remove Description validation errors since it's optional
ModelState.Remove("Description");
if (!ModelState.IsValid) 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(); var categories = await _categoryService.GetAllCategoriesAsync();
ViewData["Categories"] = categories.Where(c => c.IsActive); ViewData["Categories"] = categories.Where(c => c.IsActive);
return View(model); return View(model);
} }
await _productService.CreateProductAsync(model); var createdProduct = await _productService.CreateProductAsync(model);
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
@ -68,6 +87,7 @@ public class ProductsController : Controller
WeightUnit = product.WeightUnit, WeightUnit = product.WeightUnit,
Weight = product.Weight, Weight = product.Weight,
Price = product.Price, Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId, CategoryId = product.CategoryId,
IsActive = product.IsActive IsActive = product.IsActive
}; };

View File

@ -17,46 +17,86 @@
<div class="card-body"> <div class="card-body">
<form method="post" asp-area="Admin" asp-controller="Products" asp-action="Create"> <form method="post" asp-area="Admin" asp-controller="Products" asp-action="Create">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
@if (ViewData.ModelState[""] != null && ViewData.ModelState[""].Errors.Count > 0)
@if (!ViewData.ModelState.IsValid)
{ {
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
@foreach (var error in ViewData.ModelState[""].Errors) <h6><i class="fas fa-exclamation-triangle"></i> Please fix the following errors:</h6>
{ <ul class="mb-0">
<div>@error.ErrorMessage</div> @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
} {
<li>@error.ErrorMessage</li>
}
</ul>
</div> </div>
} }
<div class="mb-3"> <div class="mb-3">
<label for="Name" class="form-label">Product Name</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="Description" class="form-label">Description</label> <label for="Description" class="form-label">Description <small class="text-muted">(optional)</small></label>
<textarea name="Description" id="Description" class="form-control" rows="4" required></textarea> <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>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label for="Price" class="form-label">Price (£)</label> <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> </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"> <div class="mb-3">
<label for="CategoryId" class="form-label">Category</label> <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> <option value="">Select a category</option>
@if (categories != null) @if (categories != null)
{ {
@foreach (var category in categories) @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> </select>
@if(ViewData.ModelState["CategoryId"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["CategoryId"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
</div> </div>
</div> </div>
</div> </div>
@ -65,21 +105,33 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="Weight" class="form-label">Weight/Volume</label> <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> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="WeightUnit" class="form-label">Unit</label> <label for="WeightUnit" class="form-label">Unit</label>
<select name="WeightUnit" id="WeightUnit" class="form-select"> <select name="WeightUnit" id="WeightUnit" class="form-select @(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0 ? "is-invalid" : "")">
<option value="0">Unit</option> <option value="0" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Unit)">Unit</option>
<option value="1">Micrograms</option> <option value="1" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Micrograms)">Micrograms</option>
<option value="2">Grams</option> <option value="2" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Grams)">Grams</option>
<option value="3">Ounces</option> <option value="3" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Ounces)">Ounces</option>
<option value="4">Pounds</option> <option value="4" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Pounds)">Pounds</option>
<option value="5">Millilitres</option> <option value="5" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Millilitres)">Millilitres</option>
<option value="6">Litres</option> <option value="6" selected="@(Model?.WeightUnit == LittleShop.Enums.ProductWeightUnit.Litres)">Litres</option>
</select> </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>
@ -105,13 +157,99 @@
<div class="card-body"> <div class="card-body">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><strong>Name:</strong> Unique product identifier</li> <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>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>Weight/Volume:</strong> Used for shipping calculations</li>
<li><strong>Category:</strong> Product organization</li> <li><strong>Category:</strong> Product organization</li>
<li><strong>Photos:</strong> Can be added after creating the product</li>
</ul> </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>
</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>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label for="Price" class="form-label">Price (£)</label> <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 /> <input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" class="form-control" required />
</div> </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" type="number" min="0" class="form-control" required />
</div>
</div>
<div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
<label for="CategoryId" class="form-label">Category</label> <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" required>

View File

@ -27,6 +27,7 @@
<th>Name</th> <th>Name</th>
<th>Category</th> <th>Category</th>
<th>Price</th> <th>Price</th>
<th>Stock</th>
<th>Weight</th> <th>Weight</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
@ -58,6 +59,16 @@
<td> <td>
<strong>£@product.Price</strong> <strong>£@product.Price</strong>
</td> </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> <td>
@product.Weight @product.WeightUnit.ToString().ToLower() @product.Weight @product.WeightUnit.ToString().ToLower()
</td> </td>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,12 +37,13 @@ class PWAManager {
// Setup push notifications // Setup push notifications
this.setupPushNotifications(); 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(() => { setTimeout(() => {
if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) { if (!document.getElementById('pwa-install-btn') && !this.isInstalled()) {
console.log('PWA: No install prompt appeared, showing manual install guide');
this.showManualInstallButton(); this.showManualInstallButton();
} }
}, 3000); }, 5000);
} }
setupInstallPrompt() { setupInstallPrompt() {
@ -70,6 +71,9 @@ class PWAManager {
this.hideInstallButton(); this.hideInstallButton();
} else { } else {
console.log('PWA: App is not installed, waiting for install prompt...'); 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) // Periodically check if app becomes installed (for cases where user installs via browser menu)
@ -210,7 +214,27 @@ class PWAManager {
`; `;
installBtn.addEventListener('click', () => { 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); document.body.appendChild(installBtn);
@ -230,25 +254,21 @@ class PWAManager {
} }
try { 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 // Get VAPID public key from server
await this.getVapidPublicKey(); await this.getVapidPublicKey();
// Check if user is already subscribed // Check if user is already subscribed
await this.checkPushSubscription(); await this.checkPushSubscription();
// Only show setup UI if not subscribed and not recently dismissed // Simple logic: only show prompt if user is not subscribed
const isSubscribedFromCache = localStorage.getItem('pushNotificationsSubscribed') === 'true'; if (!this.pushSubscription) {
if (!this.pushSubscription && !isSubscribedFromCache) { // Check if we've already asked this session
this.showPushNotificationSetup(); if (!sessionStorage.getItem('pushNotificationPromptShown')) {
this.showPushNotificationSetup();
sessionStorage.setItem('pushNotificationPromptShown', 'true');
}
} else { } else {
console.log('PWA: User already subscribed to push notifications, skipping setup UI'); console.log('PWA: User already subscribed to push notifications');
} }
} catch (error) { } catch (error) {
@ -280,34 +300,9 @@ class PWAManager {
try { try {
this.pushSubscription = await this.swRegistration.pushManager.getSubscription(); this.pushSubscription = await this.swRegistration.pushManager.getSubscription();
if (this.pushSubscription) { if (this.pushSubscription) {
console.log('PWA: Browser has push subscription'); console.log('PWA: User has active 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);
}
} else { } else {
console.log('PWA: User is not subscribed to push notifications'); console.log('PWA: User is not subscribed to push notifications');
localStorage.removeItem('pushNotificationsSubscribed');
} }
} catch (error) { } catch (error) {
console.error('PWA: Error checking push subscription:', error); console.error('PWA: Error checking push subscription:', error);
@ -358,12 +353,7 @@ class PWAManager {
if (response.ok) { if (response.ok) {
this.pushSubscription = subscription; this.pushSubscription = subscription;
console.log('PWA: Successfully subscribed to push notifications'); console.log('PWA: Successfully subscribed to push notifications');
this.hidePushNotificationSetup();
// Cache subscription state
localStorage.setItem('pushNotificationsSubscribed', 'true');
localStorage.setItem('pushNotificationsSubscribedAt', new Date().toISOString());
this.updatePushNotificationUI();
return true; return true;
} else { } else {
throw new Error('Failed to save push subscription to server'); throw new Error('Failed to save push subscription to server');
@ -397,12 +387,6 @@ class PWAManager {
this.pushSubscription = null; this.pushSubscription = null;
console.log('PWA: Successfully unsubscribed from push notifications'); console.log('PWA: Successfully unsubscribed from push notifications');
// Clear subscription cache
localStorage.removeItem('pushNotificationsSubscribed');
localStorage.removeItem('pushNotificationsSubscribedAt');
this.updatePushNotificationUI();
return true; return true;
} catch (error) { } catch (error) {
console.error('PWA: Failed to unsubscribe from push notifications:', error); console.error('PWA: Failed to unsubscribe from push notifications:', error);
@ -427,32 +411,24 @@ class PWAManager {
max-width: 350px; max-width: 350px;
`; `;
const isSubscribed = !!this.pushSubscription;
setupDiv.innerHTML = ` setupDiv.innerHTML = `
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<i class="fas fa-bell me-2"></i> <i class="fas fa-bell me-2"></i>
<div class="flex-grow-1"> <div class="flex-grow-1">
<strong>Push Notifications</strong><br> <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>
<div class="ms-2"> <div class="ms-2">
${isSubscribed ? <button type="button" class="btn btn-sm btn-primary" id="subscribe-push-btn">Enable</button>
'<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>'
}
</div> </div>
</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); document.body.appendChild(setupDiv);
// Add event listeners // Add event listener for subscribe button
const subscribeBtn = document.getElementById('subscribe-push-btn'); 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) { if (subscribeBtn) {
subscribeBtn.addEventListener('click', async () => { subscribeBtn.addEventListener('click', async () => {
@ -475,76 +451,17 @@ class PWAManager {
alert('Failed to enable push notifications: ' + userMessage); alert('Failed to enable push notifications: ' + userMessage);
subscribeBtn.disabled = false; 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'); const setupDiv = document.getElementById('push-notification-setup');
if (setupDiv) { if (setupDiv) {
setupDiv.remove(); setupDiv.remove();
this.showPushNotificationSetup(); console.log('PWA: Push notification setup hidden');
} }
} }

View File

@ -1,12 +1,9 @@
// LittleShop Admin - Service Worker // LittleShop Admin - Service Worker
// Provides offline functionality and app-like experience // Provides offline functionality and app-like experience
const CACHE_NAME = 'littleshop-admin-v1'; const CACHE_NAME = 'littleshop-admin-v2';
const urlsToCache = [ const urlsToCache = [
'/Admin/Dashboard', // Only cache static assets, not dynamic admin pages
'/Admin/Orders',
'/Admin/Products',
'/Admin/Categories',
'/lib/bootstrap/css/bootstrap.min.css', '/lib/bootstrap/css/bootstrap.min.css',
'/lib/fontawesome/css/all.min.css', '/lib/fontawesome/css/all.min.css',
'/lib/bootstrap-icons/bootstrap-icons.css', '/lib/bootstrap-icons/bootstrap-icons.css',
@ -14,6 +11,7 @@ const urlsToCache = [
'/lib/jquery/jquery.min.js', '/lib/jquery/jquery.min.js',
'/lib/bootstrap/js/bootstrap.bundle.min.js', '/lib/bootstrap/js/bootstrap.bundle.min.js',
'/js/modern-mobile.js', '/js/modern-mobile.js',
'/js/pwa.js',
'/lib/fontawesome/webfonts/fa-solid-900.woff2', '/lib/fontawesome/webfonts/fa-solid-900.woff2',
'/lib/fontawesome/webfonts/fa-brands-400.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) => { self.addEventListener('fetch', (event) => {
// Only handle GET requests // Only handle GET requests
if (event.request.method !== 'GET') { if (event.request.method !== 'GET') {
return; 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( event.respondWith(
caches.match(event.request) caches.match(event.request)
.then((response) => { .then((response) => {