Configure push notifications for internal-only access

- Changed VAPID subject from public URL to mailto format
- Updated docker-compose.yml to use mailto:admin@littleshop.local
- Removed dependency on thebankofdebbie.giize.com public domain
- All push notifications now work through VPN (admin.dark.side) only
- Added update-push-internal.sh helper script for deployment
- Improved security by keeping all admin traffic internal

Push notifications will continue working normally through FCM,
but all configuration and management stays on the internal network.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 21:15:42 +01:00
parent 021cfc4edc
commit 5e90b86d8c
183 changed files with 522322 additions and 6190 deletions

View File

@@ -1,145 +1,145 @@
@model IEnumerable<LittleShop.DTOs.ProductDto>
@{
ViewData["Title"] = "Products";
}
<div class="row mb-4">
<div class="col">
<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>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Image</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Variations</th>
<th>Stock</th>
<th>Weight</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model)
{
<tr>
<td>
@if (product.Photos.Any())
{
<img src="@product.Photos.First().FilePath" alt="@product.Photos.First().AltText" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
}
else
{
<div class="bg-light d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
<i class="fas fa-image text-muted"></i>
</div>
}
</td>
<td>
<strong>@product.Name</strong>
<br><small class="text-muted">@product.Description.Substring(0, Math.Min(50, product.Description.Length))@(product.Description.Length > 50 ? "..." : "")</small>
</td>
<td>
<span class="badge bg-secondary">@product.CategoryName</span>
</td>
<td>
<strong>£@product.Price</strong>
</td>
<td>
@if (product.MultiBuys.Any())
{
<span class="badge bg-info">@product.MultiBuys.Count() multi-buys</span>
}
@if (product.Variants.Any())
{
<span class="badge bg-success">@product.Variants.Count() variants</span>
}
@if (!product.MultiBuys.Any() && !product.Variants.Any())
{
<span class="text-muted">None</span>
}
</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>
<td>
@if (product.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</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>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-box fa-3x text-muted mb-3"></i>
<p class="text-muted">No products found. <a href="@Url.Action("Create")">Create your first product</a>.</p>
</div>
}
</div>
@model IEnumerable<LittleShop.DTOs.ProductDto>
@{
ViewData["Title"] = "Products";
}
<div class="row mb-4">
<div class="col">
<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>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Image</th>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Variations</th>
<th>Stock</th>
<th>Weight</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model)
{
<tr>
<td>
@if (product.Photos.Any())
{
<img src="@product.Photos.First().FilePath" alt="@product.Photos.First().AltText" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
}
else
{
<div class="bg-light d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
<i class="fas fa-image text-muted"></i>
</div>
}
</td>
<td>
<strong>@product.Name</strong>
<br><small class="text-muted">@product.Description.Substring(0, Math.Min(50, product.Description.Length))@(product.Description.Length > 50 ? "..." : "")</small>
</td>
<td>
<span class="badge bg-secondary">@product.CategoryName</span>
</td>
<td>
<strong>£@product.Price</strong>
</td>
<td>
@if (product.MultiBuys.Any())
{
<span class="badge bg-info">@product.MultiBuys.Count() multi-buys</span>
}
@if (product.Variants.Any())
{
<span class="badge bg-success">@product.Variants.Count() variants</span>
}
@if (!product.MultiBuys.Any() && !product.Variants.Any())
{
<span class="text-muted">None</span>
}
</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>
<td>
@if (product.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</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>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-box fa-3x text-muted mb-3"></i>
<p class="text-muted">No products found. <a href="@Url.Action("Create")">Create your first product</a>.</p>
</div>
}
</div>
</div>

View File

@@ -1,22 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LittleShop.Migrations
{
/// <inheritdoc />
public partial class AddVariantCollectionsAndSalesLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LittleShop.Migrations
{
/// <inheritdoc />
public partial class AddVariantCollectionsAndSalesLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LittleShop.Migrations
{
/// <inheritdoc />
public partial class AddWeightToProductVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "Weight",
table: "ProductVariants",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WeightUnit",
table: "ProductVariants",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Weight",
table: "ProductVariants");
migrationBuilder.DropColumn(
name: "WeightUnit",
table: "ProductVariants");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LittleShop.Migrations
{
/// <inheritdoc />
public partial class AddWeightToProductVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "Weight",
table: "ProductVariants",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WeightUnit",
table: "ProductVariants",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Weight",
table: "ProductVariants");
migrationBuilder.DropColumn(
name: "WeightUnit",
table: "ProductVariants");
}
}
}

View File

@@ -1,170 +1,170 @@
using Microsoft.Extensions.Options;
namespace LittleShop.Services;
/// <summary>
/// Validates critical configuration settings on startup to prevent security issues
/// </summary>
public class ConfigurationValidationService
{
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<ConfigurationValidationService> _logger;
public ConfigurationValidationService(
IConfiguration configuration,
IWebHostEnvironment environment,
ILogger<ConfigurationValidationService> logger)
{
_configuration = configuration;
_environment = environment;
_logger = logger;
}
/// <summary>
/// Validates all critical configuration settings on startup
/// Throws exceptions for security-critical misconfigurations
/// </summary>
public void ValidateConfiguration()
{
_logger.LogInformation("🔍 Validating application configuration...");
// JWT validation is critical in production, optional in development/testing
if (_environment.IsProduction() || !string.IsNullOrEmpty(_configuration["Jwt:Key"]))
{
ValidateJwtConfiguration();
}
else if (_environment.IsDevelopment())
{
_logger.LogWarning("⚠️ JWT validation skipped in development. Configure Jwt:Key for production readiness.");
}
ValidateSilverPayConfiguration();
ValidateProductionSafeguards();
ValidateEnvironmentConfiguration();
_logger.LogInformation("✅ Configuration validation completed successfully");
}
private void ValidateJwtConfiguration()
{
var jwtKey = _configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey))
{
throw new InvalidOperationException("🚨 CRITICAL: JWT Key not configured. Set Jwt:Key in appsettings.json");
}
// Check for the old hardcoded key
if (jwtKey.Contains("ThisIsASuperSecretKey"))
{
throw new InvalidOperationException("🚨 CRITICAL: Default JWT key detected. Generate a new secure key!");
}
// Require minimum key length for security
if (jwtKey.Length < 32)
{
throw new InvalidOperationException("🚨 CRITICAL: JWT key too short. Must be at least 32 characters.");
}
_logger.LogInformation("✅ JWT configuration validated");
}
private void ValidateSilverPayConfiguration()
{
var baseUrl = _configuration["SilverPay:BaseUrl"];
var apiKey = _configuration["SilverPay:ApiKey"];
if (string.IsNullOrEmpty(baseUrl))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay BaseUrl not configured");
}
if (string.IsNullOrEmpty(apiKey))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay ApiKey not configured");
}
// Check for test/mock indicators in production
if (_environment.IsProduction())
{
if (baseUrl.Contains("localhost") || baseUrl.Contains("127.0.0.1"))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay configured with localhost in production!");
}
if (apiKey.Contains("test") || apiKey.Contains("mock") || apiKey.Contains("demo"))
{
_logger.LogWarning("⚠️ WARNING: SilverPay API key contains test/mock indicators in production");
}
}
_logger.LogInformation("✅ SilverPay configuration validated");
}
private void ValidateProductionSafeguards()
{
// Ensure no mock services can be accidentally enabled
var mockServiceConfig = _configuration.GetSection("SilverPay").GetChildren()
.Where(x => x.Key.ToLower().Contains("mock") || x.Key.ToLower().Contains("test"))
.ToList();
if (mockServiceConfig.Any())
{
foreach (var config in mockServiceConfig)
{
_logger.LogWarning("⚠️ Found mock/test configuration: {Key} = {Value}", config.Key, config.Value);
}
}
// In production, absolutely no mock configurations should exist
if (_environment.IsProduction())
{
var useMockService = _configuration.GetValue<bool>("SilverPay:UseMockService", false);
if (useMockService)
{
throw new InvalidOperationException("🚨 CRITICAL: Mock service enabled in production! Set SilverPay:UseMockService to false");
}
// Check for any configuration that might enable testing/mocking
var dangerousConfigs = new[]
{
"Testing:Enabled",
"Mock:Enabled",
"Development:MockPayments",
"Debug:MockServices"
};
foreach (var configKey in dangerousConfigs)
{
if (_configuration.GetValue<bool>(configKey, false))
{
throw new InvalidOperationException($"🚨 CRITICAL: Dangerous test configuration enabled in production: {configKey}");
}
}
}
_logger.LogInformation("✅ Production safeguards validated");
}
private void ValidateEnvironmentConfiguration()
{
// Log current environment for verification
_logger.LogInformation("🌍 Environment: {Environment}", _environment.EnvironmentName);
// Validate database connection
var connectionString = _configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("🚨 CRITICAL: Database connection string not configured");
}
// Check for development database in production
if (_environment.IsProduction() && connectionString.Contains("littleshop.db"))
{
_logger.LogWarning("⚠️ WARNING: Using SQLite database in production. Consider PostgreSQL/SQL Server for production.");
}
_logger.LogInformation("✅ Environment configuration validated");
}
using Microsoft.Extensions.Options;
namespace LittleShop.Services;
/// <summary>
/// Validates critical configuration settings on startup to prevent security issues
/// </summary>
public class ConfigurationValidationService
{
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<ConfigurationValidationService> _logger;
public ConfigurationValidationService(
IConfiguration configuration,
IWebHostEnvironment environment,
ILogger<ConfigurationValidationService> logger)
{
_configuration = configuration;
_environment = environment;
_logger = logger;
}
/// <summary>
/// Validates all critical configuration settings on startup
/// Throws exceptions for security-critical misconfigurations
/// </summary>
public void ValidateConfiguration()
{
_logger.LogInformation("🔍 Validating application configuration...");
// JWT validation is critical in production, optional in development/testing
if (_environment.IsProduction() || !string.IsNullOrEmpty(_configuration["Jwt:Key"]))
{
ValidateJwtConfiguration();
}
else if (_environment.IsDevelopment())
{
_logger.LogWarning("⚠️ JWT validation skipped in development. Configure Jwt:Key for production readiness.");
}
ValidateSilverPayConfiguration();
ValidateProductionSafeguards();
ValidateEnvironmentConfiguration();
_logger.LogInformation("✅ Configuration validation completed successfully");
}
private void ValidateJwtConfiguration()
{
var jwtKey = _configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey))
{
throw new InvalidOperationException("🚨 CRITICAL: JWT Key not configured. Set Jwt:Key in appsettings.json");
}
// Check for the old hardcoded key
if (jwtKey.Contains("ThisIsASuperSecretKey"))
{
throw new InvalidOperationException("🚨 CRITICAL: Default JWT key detected. Generate a new secure key!");
}
// Require minimum key length for security
if (jwtKey.Length < 32)
{
throw new InvalidOperationException("🚨 CRITICAL: JWT key too short. Must be at least 32 characters.");
}
_logger.LogInformation("✅ JWT configuration validated");
}
private void ValidateSilverPayConfiguration()
{
var baseUrl = _configuration["SilverPay:BaseUrl"];
var apiKey = _configuration["SilverPay:ApiKey"];
if (string.IsNullOrEmpty(baseUrl))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay BaseUrl not configured");
}
if (string.IsNullOrEmpty(apiKey))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay ApiKey not configured");
}
// Check for test/mock indicators in production
if (_environment.IsProduction())
{
if (baseUrl.Contains("localhost") || baseUrl.Contains("127.0.0.1"))
{
throw new InvalidOperationException("🚨 CRITICAL: SilverPay configured with localhost in production!");
}
if (apiKey.Contains("test") || apiKey.Contains("mock") || apiKey.Contains("demo"))
{
_logger.LogWarning("⚠️ WARNING: SilverPay API key contains test/mock indicators in production");
}
}
_logger.LogInformation("✅ SilverPay configuration validated");
}
private void ValidateProductionSafeguards()
{
// Ensure no mock services can be accidentally enabled
var mockServiceConfig = _configuration.GetSection("SilverPay").GetChildren()
.Where(x => x.Key.ToLower().Contains("mock") || x.Key.ToLower().Contains("test"))
.ToList();
if (mockServiceConfig.Any())
{
foreach (var config in mockServiceConfig)
{
_logger.LogWarning("⚠️ Found mock/test configuration: {Key} = {Value}", config.Key, config.Value);
}
}
// In production, absolutely no mock configurations should exist
if (_environment.IsProduction())
{
var useMockService = _configuration.GetValue<bool>("SilverPay:UseMockService", false);
if (useMockService)
{
throw new InvalidOperationException("🚨 CRITICAL: Mock service enabled in production! Set SilverPay:UseMockService to false");
}
// Check for any configuration that might enable testing/mocking
var dangerousConfigs = new[]
{
"Testing:Enabled",
"Mock:Enabled",
"Development:MockPayments",
"Debug:MockServices"
};
foreach (var configKey in dangerousConfigs)
{
if (_configuration.GetValue<bool>(configKey, false))
{
throw new InvalidOperationException($"🚨 CRITICAL: Dangerous test configuration enabled in production: {configKey}");
}
}
}
_logger.LogInformation("✅ Production safeguards validated");
}
private void ValidateEnvironmentConfiguration()
{
// Log current environment for verification
_logger.LogInformation("🌍 Environment: {Environment}", _environment.EnvironmentName);
// Validate database connection
var connectionString = _configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("🚨 CRITICAL: Database connection string not configured");
}
// Check for development database in production
if (_environment.IsProduction() && connectionString.Contains("littleshop.db"))
{
_logger.LogWarning("⚠️ WARNING: Using SQLite database in production. Consider PostgreSQL/SQL Server for production.");
}
_logger.LogInformation("✅ Environment configuration validated");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,32 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=littleshop.db"
},
"Jwt": {
"Key": "ThisIsATemporaryKeyFor-TestingPurposesOnlyGenerateSecureKey1234567890ABCDEF",
"Issuer": "LittleShop",
"Audience": "LittleShop",
"ExpiryInHours": 24
},
"RoyalMail": {
"ClientId": "",
"ClientSecret": "",
"BaseUrl": "https://api.royalmail.net/",
"SenderAddress1": "SilverLabs Ltd, 123 Business Street",
"SenderCity": "London",
"SenderPostCode": "SW1A 1AA",
"SenderCountry": "United Kingdom"
},
"WebPush": {
"VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4",
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
"Subject": "mailto:admin@littleshop.local"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=littleshop.db"
},
"Jwt": {
"Key": "ThisIsATemporaryKeyFor-TestingPurposesOnlyGenerateSecureKey1234567890ABCDEF",
"Issuer": "LittleShop",
"Audience": "LittleShop",
"ExpiryInHours": 24
},
"RoyalMail": {
"ClientId": "",
"ClientSecret": "",
"BaseUrl": "https://api.royalmail.net/",
"SenderAddress1": "SilverLabs Ltd, 123 Business Street",
"SenderCity": "London",
"SenderPostCode": "SW1A 1AA",
"SenderCountry": "United Kingdom"
},
"WebPush": {
"VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4",
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
"Subject": "mailto:admin@littleshop.local"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,118 @@
BEGIN TRANSACTION;
ALTER TABLE "Products" ADD "VariantCollectionId" TEXT NULL;
ALTER TABLE "Products" ADD "VariantsJson" TEXT NULL;
ALTER TABLE "OrderItems" ADD "SelectedVariants" TEXT NULL;
CREATE TABLE "SalesLedgers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SalesLedgers" PRIMARY KEY,
"OrderId" TEXT NOT NULL,
"ProductId" TEXT NOT NULL,
"ProductName" TEXT NOT NULL,
"Quantity" INTEGER NOT NULL,
"SalePriceFiat" decimal(18,2) NOT NULL,
"FiatCurrency" TEXT NOT NULL,
"SalePriceBTC" decimal(18,8) NULL,
"Cryptocurrency" TEXT NULL,
"SoldAt" TEXT NOT NULL,
CONSTRAINT "FK_SalesLedgers_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_SalesLedgers_Products_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Products" ("Id") ON DELETE RESTRICT
);
CREATE TABLE "VariantCollections" (
"Id" TEXT NOT NULL CONSTRAINT "PK_VariantCollections" PRIMARY KEY,
"Name" TEXT NOT NULL,
"PropertiesJson" TEXT NOT NULL,
"IsActive" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
CREATE INDEX "IX_Products_VariantCollectionId" ON "Products" ("VariantCollectionId");
CREATE INDEX "IX_SalesLedgers_OrderId" ON "SalesLedgers" ("OrderId");
CREATE INDEX "IX_SalesLedgers_ProductId" ON "SalesLedgers" ("ProductId");
CREATE INDEX "IX_SalesLedgers_ProductId_SoldAt" ON "SalesLedgers" ("ProductId", "SoldAt");
CREATE INDEX "IX_SalesLedgers_SoldAt" ON "SalesLedgers" ("SoldAt");
CREATE INDEX "IX_VariantCollections_IsActive" ON "VariantCollections" ("IsActive");
CREATE INDEX "IX_VariantCollections_Name" ON "VariantCollections" ("Name");
CREATE TABLE "ef_temp_OrderItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY,
"OrderId" TEXT NOT NULL,
"ProductId" TEXT NOT NULL,
"ProductMultiBuyId" TEXT NULL,
"Quantity" INTEGER NOT NULL,
"SelectedVariants" TEXT NULL,
"TotalPrice" decimal(18,2) NOT NULL,
"UnitPrice" decimal(18,2) NOT NULL,
CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_OrderItems_ProductMultiBuys_ProductMultiBuyId" FOREIGN KEY ("ProductMultiBuyId") REFERENCES "ProductMultiBuys" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_OrderItems_Products_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Products" ("Id") ON DELETE RESTRICT
);
INSERT INTO "ef_temp_OrderItems" ("Id", "OrderId", "ProductId", "ProductMultiBuyId", "Quantity", "SelectedVariants", "TotalPrice", "UnitPrice")
SELECT "Id", "OrderId", "ProductId", "ProductMultiBuyId", "Quantity", "SelectedVariants", "TotalPrice", "UnitPrice"
FROM "OrderItems";
CREATE TABLE "ef_temp_Products" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Products" PRIMARY KEY,
"CategoryId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"Description" TEXT NOT NULL,
"IsActive" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"Price" decimal(18,2) NOT NULL,
"StockQuantity" INTEGER NOT NULL,
"UpdatedAt" TEXT NOT NULL,
"VariantCollectionId" TEXT NULL,
"VariantsJson" TEXT NULL,
"Weight" decimal(18,4) NOT NULL,
"WeightUnit" INTEGER NOT NULL,
CONSTRAINT "FK_Products_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_Products_VariantCollections_VariantCollectionId" FOREIGN KEY ("VariantCollectionId") REFERENCES "VariantCollections" ("Id")
);
INSERT INTO "ef_temp_Products" ("Id", "CategoryId", "CreatedAt", "Description", "IsActive", "Name", "Price", "StockQuantity", "UpdatedAt", "VariantCollectionId", "VariantsJson", "Weight", "WeightUnit")
SELECT "Id", "CategoryId", "CreatedAt", "Description", "IsActive", "Name", "Price", "StockQuantity", "UpdatedAt", "VariantCollectionId", "VariantsJson", "Weight", "WeightUnit"
FROM "Products";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "OrderItems";
ALTER TABLE "ef_temp_OrderItems" RENAME TO "OrderItems";
DROP TABLE "Products";
ALTER TABLE "ef_temp_Products" RENAME TO "Products";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE INDEX "IX_OrderItems_OrderId" ON "OrderItems" ("OrderId");
CREATE INDEX "IX_OrderItems_ProductId" ON "OrderItems" ("ProductId");
CREATE INDEX "IX_OrderItems_ProductMultiBuyId" ON "OrderItems" ("ProductMultiBuyId");
CREATE INDEX "IX_Products_CategoryId" ON "Products" ("CategoryId");
CREATE INDEX "IX_Products_VariantCollectionId" ON "Products" ("VariantCollectionId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250928014546_AddVariantCollectionsAndSalesLedger', '9.0.9');

View File

@@ -1,221 +1,221 @@
// Modern Mobile Enhancements
// Clean, simple mobile-friendly functionality
class ModernMobile {
constructor() {
this.init();
}
init() {
this.setupMobileTableLabels();
this.setupResponsiveNavigation();
this.setupFormEnhancements();
this.setupSmoothInteractions();
}
// Add data labels for mobile table stacking
setupMobileTableLabels() {
const tables = document.querySelectorAll('.table');
tables.forEach(table => {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (headers[index]) {
cell.setAttribute('data-label', headers[index]);
}
});
});
});
}
// Enhanced mobile navigation
setupResponsiveNavigation() {
const navbar = document.querySelector('.navbar');
const toggler = document.querySelector('.navbar-toggler');
const collapse = document.querySelector('.navbar-collapse');
if (toggler && collapse) {
// Smooth collapse animation
toggler.addEventListener('click', () => {
collapse.classList.toggle('show');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
collapse.classList.remove('show');
}
});
}
}
// Form enhancements for better mobile UX
setupFormEnhancements() {
// Auto-focus first input on desktop
if (window.innerWidth > 768) {
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
if (firstInput) {
firstInput.focus();
}
}
// Enhanced form validation feedback
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
// Skip validation for file upload forms
if (form.enctype === 'multipart/form-data') {
console.log('Mobile: Skipping validation for file upload form');
return;
}
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
e.preventDefault();
console.log('Mobile: Form validation failed, focusing on first invalid input');
invalidInputs[0].focus();
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
});
// Floating labels effect
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
if (input.value) {
input.parentElement.classList.add('has-value');
}
input.addEventListener('blur', () => {
if (input.value) {
input.parentElement.classList.add('has-value');
} else {
input.parentElement.classList.remove('has-value');
}
});
});
}
// Smooth interactions and feedback
setupSmoothInteractions() {
// Button click feedback
const buttons = document.querySelectorAll('.btn');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
if (!button.disabled) {
button.style.transform = 'scale(0.95)';
setTimeout(() => {
button.style.transform = '';
}, 100);
}
});
});
// Card hover enhancement
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
// Smooth scroll for anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
}
// Show toast notification
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show`;
toast.style.cssText = `
margin-bottom: 0.5rem;
animation: slideInRight 0.3s ease;
`;
toast.innerHTML = `
${message}
<button type="button" class="btn-close" aria-label="Close"></button>
`;
toastContainer.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
}, 4000);
// Manual close
const closeBtn = toast.querySelector('.btn-close');
closeBtn.addEventListener('click', () => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
max-width: 300px;
`;
document.body.appendChild(container);
return container;
}
}
// CSS for toast animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade {
opacity: 0 !important;
transition: opacity 0.15s linear !important;
}
`;
document.head.appendChild(style);
// Initialize
const modernMobile = new ModernMobile();
// Modern Mobile Enhancements
// Clean, simple mobile-friendly functionality
class ModernMobile {
constructor() {
this.init();
}
init() {
this.setupMobileTableLabels();
this.setupResponsiveNavigation();
this.setupFormEnhancements();
this.setupSmoothInteractions();
}
// Add data labels for mobile table stacking
setupMobileTableLabels() {
const tables = document.querySelectorAll('.table');
tables.forEach(table => {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (headers[index]) {
cell.setAttribute('data-label', headers[index]);
}
});
});
});
}
// Enhanced mobile navigation
setupResponsiveNavigation() {
const navbar = document.querySelector('.navbar');
const toggler = document.querySelector('.navbar-toggler');
const collapse = document.querySelector('.navbar-collapse');
if (toggler && collapse) {
// Smooth collapse animation
toggler.addEventListener('click', () => {
collapse.classList.toggle('show');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
collapse.classList.remove('show');
}
});
}
}
// Form enhancements for better mobile UX
setupFormEnhancements() {
// Auto-focus first input on desktop
if (window.innerWidth > 768) {
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
if (firstInput) {
firstInput.focus();
}
}
// Enhanced form validation feedback
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
// Skip validation for file upload forms
if (form.enctype === 'multipart/form-data') {
console.log('Mobile: Skipping validation for file upload form');
return;
}
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
e.preventDefault();
console.log('Mobile: Form validation failed, focusing on first invalid input');
invalidInputs[0].focus();
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
});
// Floating labels effect
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
if (input.value) {
input.parentElement.classList.add('has-value');
}
input.addEventListener('blur', () => {
if (input.value) {
input.parentElement.classList.add('has-value');
} else {
input.parentElement.classList.remove('has-value');
}
});
});
}
// Smooth interactions and feedback
setupSmoothInteractions() {
// Button click feedback
const buttons = document.querySelectorAll('.btn');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
if (!button.disabled) {
button.style.transform = 'scale(0.95)';
setTimeout(() => {
button.style.transform = '';
}, 100);
}
});
});
// Card hover enhancement
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
// Smooth scroll for anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
}
// Show toast notification
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show`;
toast.style.cssText = `
margin-bottom: 0.5rem;
animation: slideInRight 0.3s ease;
`;
toast.innerHTML = `
${message}
<button type="button" class="btn-close" aria-label="Close"></button>
`;
toastContainer.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
}, 4000);
// Manual close
const closeBtn = toast.querySelector('.btn-close');
closeBtn.addEventListener('click', () => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
max-width: 300px;
`;
document.body.appendChild(container);
return container;
}
}
// CSS for toast animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade {
opacity: 0 !important;
transition: opacity 0.15s linear !important;
}
`;
document.head.appendChild(style);
// Initialize
const modernMobile = new ModernMobile();
window.modernMobile = modernMobile;

View File

@@ -0,0 +1,281 @@
// Service Worker for LittleShop Admin PWA
const CACHE_NAME = 'littleshop-admin-v1';
const urlsToCache = [
'/Admin/Dashboard',
'/manifest.json',
'/lib/bootstrap/css/bootstrap.min.css',
'/lib/fontawesome/css/all.min.css',
'/css/modern-admin.css',
'/css/mobile-admin.css',
'/lib/jquery/jquery.min.js',
'/lib/bootstrap/js/bootstrap.bundle.min.js',
'/js/pwa.js',
'/js/notifications.js',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png'
];
// Install event - cache essential files
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Service Worker: Caching essential files');
return cache.addAll(urlsToCache);
})
.then(() => self.skipWaiting())
);
});
// Activate event - clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(cacheName => cacheName !== CACHE_NAME)
.map(cacheName => caches.delete(cacheName))
);
}).then(() => self.clients.claim())
);
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', event => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip API requests from cache
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.catch(() => {
// If offline and it's an API request, return offline message
return new Response(
JSON.stringify({ error: 'Offline - Please check your connection' }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// For everything else, try network first, then cache
event.respondWith(
fetch(event.request)
.then(response => {
// Don't cache if not a successful response
if (!response || response.status !== 200 || response.type === 'error') {
return response;
}
// Clone the response as it can only be consumed once
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
// If network fails, try cache
return caches.match(event.request)
.then(response => {
if (response) {
return response;
}
// If not in cache, return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});
// Push notification event
self.addEventListener('push', event => {
console.log('Push notification received:', event);
let notificationData = {
title: 'LittleShop Admin',
body: 'You have a new notification',
icon: '/icons/icon-192x192.png',
badge: '/icons/icon-96x96.png',
vibrate: [200, 100, 200],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
}
};
if (event.data) {
try {
const data = event.data.json();
notificationData = {
title: data.title || notificationData.title,
body: data.body || notificationData.body,
icon: data.icon || notificationData.icon,
badge: data.badge || notificationData.badge,
vibrate: data.vibrate || notificationData.vibrate,
data: data.data || notificationData.data,
tag: data.tag,
requireInteraction: data.requireInteraction || false,
actions: data.actions || []
};
} catch (e) {
console.error('Error parsing push data:', e);
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, notificationData)
);
});
// Notification click event
self.addEventListener('notificationclick', event => {
console.log('Notification clicked:', event);
event.notification.close();
// Handle action clicks
if (event.action) {
switch (event.action) {
case 'view':
clients.openWindow(event.notification.data.url || '/Admin/Dashboard');
break;
case 'dismiss':
// Just close the notification
break;
default:
clients.openWindow('/Admin/Dashboard');
}
return;
}
// Default click - open the app or focus if already open
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(clientList => {
// Check if there's already a window open
for (let client of clientList) {
if (client.url.includes('/Admin/') && 'focus' in client) {
return client.focus();
}
}
// If no window is open, open a new one
if (clients.openWindow) {
return clients.openWindow(event.notification.data?.url || '/Admin/Dashboard');
}
})
);
});
// Background sync for offline actions
self.addEventListener('sync', event => {
console.log('Background sync triggered:', event.tag);
if (event.tag === 'sync-orders') {
event.waitUntil(syncOrders());
} else if (event.tag === 'sync-products') {
event.waitUntil(syncProducts());
}
});
// Sync orders when back online
async function syncOrders() {
try {
// Get any pending orders from IndexedDB
const db = await openDB();
const tx = db.transaction('pending-orders', 'readonly');
const store = tx.objectStore('pending-orders');
const pendingOrders = await store.getAll();
for (const order of pendingOrders) {
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
// Remove from pending after successful sync
const deleteTx = db.transaction('pending-orders', 'readwrite');
await deleteTx.objectStore('pending-orders').delete(order.id);
}
// Show notification of successful sync
self.registration.showNotification('Orders Synced', {
body: `${pendingOrders.length} orders synchronized successfully`,
icon: '/icons/icon-192x192.png'
});
} catch (error) {
console.error('Error syncing orders:', error);
}
}
// Sync products when back online
async function syncProducts() {
try {
const response = await fetch('/api/catalog/products');
if (response.ok) {
const products = await response.json();
// Update cached products
const cache = await caches.open(CACHE_NAME);
await cache.put('/api/catalog/products', new Response(JSON.stringify(products)));
console.log('Products synced successfully');
}
} catch (error) {
console.error('Error syncing products:', error);
}
}
// Helper function to open IndexedDB
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('LittleShopDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pending-orders')) {
db.createObjectStore('pending-orders', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('pending-products')) {
db.createObjectStore('pending-products', { keyPath: 'id', autoIncrement: true });
}
};
});
}
// Message event for communication with the app
self.addEventListener('message', event => {
console.log('Service Worker received message:', event.data);
if (event.data.action === 'skipWaiting') {
self.skipWaiting();
}
if (event.data.action === 'clearCache') {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
}).then(() => {
event.ports[0].postMessage({ success: true });
})
);
}
});
console.log('Service Worker loaded successfully');