Refactor payment verification to manual workflow and add comprehensive cleanup tools

Major changes:
• Remove BTCPay Server integration in favor of SilverPAY manual verification
• Add test data cleanup mechanisms (API endpoints and shell scripts)
• Fix compilation errors in TestController (IdentityReference vs CustomerIdentity)
• Add deployment automation scripts for Hostinger VPS
• Enhance integration testing with comprehensive E2E validation
• Add Blazor components and mobile-responsive CSS for admin interface
• Create production environment configuration scripts

Key Features Added:
• Manual payment verification through Admin panel Order Details
• Bulk test data cleanup with proper cascade handling
• Deployment automation with systemd service configuration
• Comprehensive E2E testing suite with SilverPAY integration validation
• Mobile-first admin interface improvements

Security & Production:
• Environment variable configuration for production secrets
• Proper JWT and VAPID key management
• SilverPAY API integration with live credentials
• Database cleanup and maintenance tools

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-25 19:29:00 +01:00
parent 1588c79df0
commit 127be759c8
46 changed files with 3470 additions and 971 deletions

8
LittleShop/App.razor Normal file
View File

@@ -0,0 +1,8 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
<p role="alert">Sorry, there's nothing at this address.</p>
</NotFound>
</Router>

View File

@@ -0,0 +1,377 @@
@page "/admin/products/blazor"
@page "/admin/products/blazor/{ProductId:guid}"
@using Microsoft.AspNetCore.Components.Forms
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject NavigationManager Navigation
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-box"></i> Products - Enhanced UI</h1>
</div>
</div>
<div class="nav nav-tabs nav-tabs-mobile mb-4">
<button class="nav-link @(selectedTab == "details" ? "active" : "")"
@onclick="@(() => SetActiveTab("details"))">
<i class="fas fa-edit"></i>
Product Details
</button>
<button class="nav-link @(selectedTab == "variants" ? "active" : "")"
@onclick="@(() => SetActiveTab("variants"))"
disabled="@isNewProduct">
<i class="fas fa-layer-group"></i>
Variants
</button>
<button class="nav-link @(selectedTab == "multibuys" ? "active" : "")"
@onclick="@(() => SetActiveTab("multibuys"))"
disabled="@isNewProduct">
<i class="fas fa-percentage"></i>
Multi-Buys
</button>
<button class="nav-link @(selectedTab == "photos" ? "active" : "")"
@onclick="@(() => SetActiveTab("photos"))"
disabled="@isNewProduct">
<i class="fas fa-camera"></i>
Photos
</button>
</div>
@if (selectedTab == "details")
{
<div class="card">
<div class="card-body">
<EditForm Model="product" OnValidSubmit="OnSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label">Product Name</label>
<InputText @bind-Value="product.Name" class="form-control" />
<ValidationMessage For="() => product.Name" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<InputTextArea @bind-Value="product.Description" class="form-control" rows="4" />
<ValidationMessage For="() => product.Description" />
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Price (£)</label>
<InputNumber @bind-Value="product.Price" class="form-control" />
<ValidationMessage For="() => product.Price" />
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Stock Quantity</label>
<InputNumber @bind-Value="product.StockQuantity" class="form-control" />
<ValidationMessage For="() => product.StockQuantity" />
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="product.CategoryId" class="form-select">
<option value="">Select a category</option>
@foreach (var category in categories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
<ValidationMessage For="() => product.CategoryId" />
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Weight/Volume</label>
<InputNumber @bind-Value="product.Weight" class="form-control" />
<ValidationMessage For="() => product.Weight" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Unit</label>
<InputSelect @bind-Value="product.WeightUnit" class="form-select">
@foreach (var unit in Enum.GetValues<ProductWeightUnit>())
{
<option value="@unit">@unit.ToString()</option>
}
</InputSelect>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<InputCheckbox @bind-Value="product.IsActive" class="form-check-input" />
<label class="form-check-label">Active (visible in catalog)</label>
</div>
</div>
<div class="btn-group-mobile">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save
</button>
<button type="button" class="btn btn-success" @onclick="OnSaveAndAddNew">
<i class="fas fa-plus"></i> Save + Add New
</button>
<button type="button" class="btn btn-secondary" @onclick="OnCancel">
<i class="fas fa-times"></i> Cancel
</button>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Quick Actions</h5>
</div>
<div class="card-body">
@if (!isNewProduct)
{
<p><strong>Product ID:</strong> @ProductId</p>
<button class="btn btn-outline-primary btn-sm w-100 mb-2"
@onclick="@(() => SetActiveTab("variants"))">
<i class="fas fa-layer-group"></i> Manage Variants
</button>
<button class="btn btn-outline-success btn-sm w-100 mb-2"
@onclick="@(() => SetActiveTab("multibuys"))">
<i class="fas fa-percentage"></i> Setup Multi-Buys
</button>
<button class="btn btn-outline-info btn-sm w-100"
@onclick="@(() => SetActiveTab("photos"))">
<i class="fas fa-camera"></i> Manage Photos
</button>
}
else
{
<div class="alert alert-info">
<small>Save the product first to enable variants, multi-buys, and photo management.</small>
</div>
}
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
}
@if (selectedTab == "variants" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-layer-group"></i> Product Variants</h5>
</div>
<div class="card-body">
<p class="text-info">This is where product variants would be managed. Integration pending with existing variation system.</p>
<button class="btn btn-primary">
<i class="fas fa-plus"></i> Add Variant
</button>
</div>
</div>
}
@if (selectedTab == "multibuys" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-percentage"></i> Multi-Buy Offers</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(2, 10)">
Buy 2 Get 10% Off
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(3, 15)">
Buy 3 Get 15% Off
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(5, 20)">
Buy 5 Get 20% Off
</button>
</div>
</div>
</div>
</div>
}
@if (selectedTab == "photos" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-camera"></i> Product Photos</h5>
</div>
<div class="card-body">
<p class="text-info">Photo management integration pending.</p>
<input type="file" class="form-control" accept="image/*" multiple />
<small class="form-text text-muted">Select multiple images to upload.</small>
</div>
</div>
}
</div>
@code {
[Parameter] public Guid? ProductId { get; set; }
private ProductFormModel product = new();
private List<CategoryDto> categories = new();
private string selectedTab = "details";
private bool isNewProduct => ProductId == null || ProductId == Guid.Empty;
public class ProductFormModel
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; } = 0.01m;
public int StockQuantity { get; set; } = 0;
public Guid CategoryId { get; set; } = Guid.Empty;
public decimal Weight { get; set; } = 0.01m;
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Unit;
public bool IsActive { get; set; } = true;
}
protected override async Task OnInitializedAsync()
{
await LoadCategories();
if (!isNewProduct)
{
await LoadProduct();
}
else
{
InitializeNewProduct();
}
}
private void InitializeNewProduct()
{
product = new ProductFormModel
{
Name = string.Empty,
Description = string.Empty,
Price = 0.01m,
StockQuantity = 0,
CategoryId = Guid.Empty,
Weight = 0.01m,
WeightUnit = ProductWeightUnit.Unit,
IsActive = true
};
}
private async Task LoadCategories()
{
categories = (await CategoryService.GetAllCategoriesAsync()).ToList();
}
private async Task LoadProduct()
{
try
{
var existingProduct = await ProductService.GetProductByIdAsync(ProductId!.Value);
if (existingProduct != null)
{
product = new ProductFormModel
{
Name = existingProduct.Name,
Description = existingProduct.Description,
Price = existingProduct.Price,
StockQuantity = existingProduct.StockQuantity,
CategoryId = existingProduct.CategoryId,
Weight = existingProduct.Weight,
WeightUnit = existingProduct.WeightUnit,
IsActive = existingProduct.IsActive
};
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading product: {ex.Message}");
}
}
private void SetActiveTab(string tab)
{
selectedTab = tab;
}
private async Task OnSubmit()
{
try
{
if (isNewProduct)
{
var createDto = new CreateProductDto
{
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
Weight = product.Weight,
WeightUnit = product.WeightUnit
};
var newProduct = await ProductService.CreateProductAsync(createDto);
Navigation.NavigateTo($"/admin/products/blazor/{newProduct.Id}", false);
}
else
{
var updateDto = new UpdateProductDto
{
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
Weight = product.Weight,
WeightUnit = product.WeightUnit,
IsActive = product.IsActive
};
await ProductService.UpdateProductAsync(ProductId!.Value, updateDto);
// Show success message or stay on current view
}
}
catch (Exception ex)
{
Console.WriteLine($"Error saving product: {ex.Message}");
}
}
private async Task OnSaveAndAddNew()
{
await OnSubmit();
// Reset form for new product
InitializeNewProduct();
selectedTab = "details";
Navigation.NavigateTo("/admin/products/blazor", false);
}
private void OnCancel()
{
Navigation.NavigateTo("/Admin/Products");
}
private async Task CreateQuickMultiBuy(int quantity, decimal discountPercent)
{
// TODO: Implement multi-buy creation
Console.WriteLine($"Creating multi-buy: {quantity} items with {discountPercent}% discount");
}
}

View File

@@ -27,11 +27,17 @@ public class ProductsController : Controller
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);
}
public IActionResult Blazor(Guid? id)
{
ViewData["ProductId"] = id;
return View();
}
public async Task<IActionResult> Create()
{
var categories = await _categoryService.GetAllCategoriesAsync();

View File

@@ -0,0 +1,35 @@
@{
ViewData["Title"] = "Products Management";
Layout = "~/Areas/Admin/Views/Shared/_Layout.cshtml";
}
<div id="blazor-products-container" data-blazor-component="products">
<component type="typeof(LittleShop.Areas.Admin.Components.Products.ProductsBlazorSimple)"
render-mode="ServerPrerendered"
param-ProductId="@ViewData["ProductId"]" />
</div>
@section Scripts {
<script>
// Initialize Blazor Server
window.addEventListener('DOMContentLoaded', function () {
if (window.Blazor && window.Blazor.start) {
console.log('Starting Blazor...');
window.Blazor.start();
} else {
console.log('Blazor not available, attempting manual start...');
// Fallback - load the blazor script if not already loaded
if (!document.querySelector('script[src*="blazor.server.js"]')) {
var script = document.createElement('script');
script.src = '/_framework/blazor.server.js';
script.onload = function() {
if (window.Blazor) {
window.Blazor.start();
}
};
document.head.appendChild(script);
}
}
});
</script>
}

View File

@@ -10,14 +10,17 @@
</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> Add Product
<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> Import CSV
<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> Export CSV
<i class="fas fa-download"></i> <span class="d-none d-sm-inline">Export</span>
</a>
</div>
</div>

View File

@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="/" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>@ViewData["Title"] - TeleShop Admin</title>
@@ -33,7 +34,10 @@
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/lib/fontawesome/css/all.min.css" rel="stylesheet">
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<link href="/_content/Radzen.Blazor/css/material-base.css" rel="stylesheet">
<link href="/css/modern-admin.css" rel="stylesheet">
<link href="/css/mobile-admin.css" rel="stylesheet">
@await RenderSectionAsync("Head", required: false)
</head>
<body>
<header>
@@ -131,9 +135,130 @@
<script src="/lib/jquery/jquery.min.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/_framework/blazor.server.js" autostart="false"></script>
<script src="/_content/Radzen.Blazor/Radzen.Blazor.js"></script>
<script src="/js/pwa.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/modern-mobile.js"></script>
<script src="/js/blazor-integration.js"></script>
@await RenderSectionAsync("Scripts", required: false)
<!-- Mobile Bottom Navigation -->
<nav class="mobile-bottom-nav">
<ul class="mobile-bottom-nav-items">
<li class="mobile-nav-item">
<a href="@Url.Action("Index", "Orders", new { area = "Admin" })" class="mobile-nav-link @(ViewContext.RouteData.Values["controller"]?.ToString() == "Orders" ? "active" : "")">
<i class="fas fa-shopping-cart"></i>
<span>Orders</span>
<span class="mobile-nav-badge" id="mobile-orders-badge" style="display: none;">0</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="@Url.Action("Index", "Reviews", new { area = "Admin" })" class="mobile-nav-link @(ViewContext.RouteData.Values["controller"]?.ToString() == "Reviews" ? "active" : "")">
<i class="fas fa-star"></i>
<span>Reviews</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="@Url.Action("Index", "Messages", new { area = "Admin" })" class="mobile-nav-link @(ViewContext.RouteData.Values["controller"]?.ToString() == "Messages" ? "active" : "")">
<i class="fas fa-comments"></i>
<span>Messages</span>
<span class="mobile-nav-badge" id="mobile-messages-badge" style="display: none;">0</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="@Url.Action("LiveView", "BotActivity", new { area = "Admin" })" class="mobile-nav-link @(ViewContext.RouteData.Values["controller"]?.ToString() == "BotActivity" ? "active" : "")">
<i class="fas fa-satellite-dish"></i>
<span>Live</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="#" class="mobile-nav-link" onclick="toggleSettingsDrawer(); return false;">
<i class="fas fa-cog"></i>
<span>Settings</span>
</a>
</li>
</ul>
</nav>
<!-- Settings Drawer -->
<div class="drawer-overlay" onclick="toggleSettingsDrawer()"></div>
<div class="settings-drawer" id="settingsDrawer">
<div class="settings-drawer-header">
<h5>Settings</h5>
<button class="settings-drawer-close" onclick="toggleSettingsDrawer()">
<i class="fas fa-times"></i>
</button>
</div>
<ul class="settings-menu-list">
<li class="settings-menu-item">
<a href="@Url.Action("Index", "Dashboard", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-tachometer-alt"></i>
Dashboard
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "Categories", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-tags"></i>
Categories
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "Products", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-box"></i>
Products
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-truck"></i>
Shipping
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "Users", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-users"></i>
Users
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "Bots", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-robot"></i>
Bots
</a>
</li>
<li class="settings-menu-item">
<a href="@Url.Action("Index", "SystemSettings", new { area = "Admin" })" class="settings-menu-link">
<i class="fas fa-sliders-h"></i>
System Settings
</a>
</li>
<li class="settings-menu-item">
<form method="post" action="@Url.Action("Logout", "Account", new { area = "Admin" })">
<button type="submit" class="settings-menu-link" style="width: 100%; border: none; background: none; text-align: left;">
<i class="fas fa-sign-out-alt"></i>
Logout
</button>
</form>
</li>
</ul>
</div>
<script>
// Settings Drawer Toggle
function toggleSettingsDrawer() {
const drawer = document.getElementById('settingsDrawer');
const overlay = document.querySelector('.drawer-overlay');
drawer.classList.toggle('open');
overlay.classList.toggle('show');
// Prevent body scroll when drawer is open
if (drawer.classList.contains('open')) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
</script>
</body>
</html>

View File

@@ -1,279 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.AspNetCore.Authorization;
using Newtonsoft.Json.Linq;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/btcpay-test")]
[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")]
public class BTCPayTestController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IBTCPayServerService _btcPayService;
private readonly ILogger<BTCPayTestController> _logger;
public BTCPayTestController(
IConfiguration configuration,
IBTCPayServerService btcPayService,
ILogger<BTCPayTestController> logger)
{
_configuration = configuration;
_btcPayService = btcPayService;
_logger = logger;
}
[HttpGet("connection")]
public async Task<IActionResult> TestConnection()
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(apiKey))
{
return BadRequest(new { error = "BTCPay Server configuration missing" });
}
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Test basic connection by getting server info
var serverInfo = await client.GetServerInfo();
return Ok(new
{
status = "Connected",
baseUrl = baseUrl,
serverVersion = serverInfo?.Version,
supportedPaymentMethods = serverInfo?.SupportedPaymentMethods,
message = "BTCPay Server connection successful"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name,
baseUrl = _configuration["BTCPayServer:BaseUrl"]
});
}
}
[HttpGet("stores")]
public async Task<IActionResult> GetStores()
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Get available stores
var stores = await client.GetStores();
return Ok(new
{
stores = stores.Select(s => new
{
id = s.Id,
name = s.Name,
website = s.Website,
defaultCurrency = s.DefaultCurrency
}).ToList(),
message = "Stores retrieved successfully"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpGet("invoice/{invoiceId}")]
public async Task<IActionResult> GetInvoiceDetails(string invoiceId)
{
try
{
var invoice = await _btcPayService.GetInvoiceAsync(invoiceId);
if (invoice == null)
{
return NotFound(new { error = "Invoice not found" });
}
// BTCPay Server v2 manages addresses internally
// Customers use the CheckoutLink for payments
var paymentInfo = new
{
checkoutMethod = "BTCPay Checkout",
info = "Use the checkout link to complete payment"
};
return Ok(new
{
invoiceId = invoice.Id,
status = invoice.Status,
amount = invoice.Amount,
currency = invoice.Currency,
checkoutLink = invoice.CheckoutLink,
expiresAt = invoice.ExpirationTime,
paymentMethods = paymentInfo,
metadata = invoice.Metadata,
message = "Invoice details retrieved successfully"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get invoice {InvoiceId}", invoiceId);
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpPost("test-invoice")]
public async Task<IActionResult> CreateTestInvoice([FromBody] TestInvoiceRequest request)
{
try
{
var baseUrl = _configuration["BTCPayServer:BaseUrl"];
var apiKey = _configuration["BTCPayServer:ApiKey"];
var storeId = _configuration["BTCPayServer:StoreId"];
if (string.IsNullOrEmpty(storeId))
{
return BadRequest(new { error = "Store ID not configured" });
}
// Create HttpClient with certificate bypass for internal networks
var httpClient = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
});
var client = new BTCPayServerClient(new Uri(baseUrl), apiKey, httpClient);
// Create test invoice
var invoiceRequest = new CreateInvoiceRequest
{
Amount = request.Amount,
Currency = request.Currency ?? "GBP",
Metadata = JObject.FromObject(new
{
orderId = $"test-{Guid.NewGuid()}",
source = "LittleShop-Test"
})
};
var invoice = await client.CreateInvoice(storeId, invoiceRequest);
return Ok(new
{
status = "Invoice Created",
invoiceId = invoice.Id,
amount = invoice.Amount,
currency = invoice.Currency,
checkoutLink = invoice.CheckoutLink,
expiresAt = invoice.ExpirationTime,
message = "Test invoice created successfully"
});
}
catch (Exception ex)
{
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name
});
}
}
[HttpPost("test-payment")]
public async Task<IActionResult> CreateTestPayment([FromBody] TestPaymentRequest request)
{
try
{
// Create a test order ID
var testOrderId = $"test-order-{Guid.NewGuid():N}".Substring(0, 20);
_logger.LogInformation("Creating test payment for {Currency} with amount {Amount} GBP",
request.CryptoCurrency, request.Amount);
// Use the actual service to create an invoice
var invoiceId = await _btcPayService.CreateInvoiceAsync(
request.Amount,
request.CryptoCurrency,
testOrderId,
"Test payment from BTCPay diagnostic endpoint"
);
// Get the invoice details
var invoice = await _btcPayService.GetInvoiceAsync(invoiceId);
// BTCPay Server v2 uses checkout links instead of exposing raw addresses
var checkoutUrl = invoice?.CheckoutLink;
return Ok(new
{
status = "Success",
invoiceId = invoiceId,
orderId = testOrderId,
amount = request.Amount,
currency = "GBP",
requestedCrypto = request.CryptoCurrency.ToString(),
checkoutLink = checkoutUrl,
paymentUrl = checkoutUrl ?? $"https://{_configuration["BTCPayServer:BaseUrl"]}/i/{invoiceId}",
message = !string.IsNullOrEmpty(checkoutUrl)
? "✅ Test payment created successfully - Use checkout link to complete payment"
: "⚠️ Invoice created but checkout link not available - Check BTCPay configuration"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create test payment");
return StatusCode(500, new
{
error = ex.Message,
type = ex.GetType().Name,
hint = "Check that BTCPay Server has wallets configured for the requested currency"
});
}
}
}
public class TestInvoiceRequest
{
public decimal Amount { get; set; } = 0.01m;
public string? Currency { get; set; } = "GBP";
}
public class TestPaymentRequest
{
public decimal Amount { get; set; } = 10.00m;
public CryptoCurrency CryptoCurrency { get; set; } = CryptoCurrency.BTC;
}

View File

@@ -1,180 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LittleShop.DTOs;
using LittleShop.Services;
using LittleShop.Enums;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/btcpay")]
public class BTCPayWebhookController : ControllerBase
{
private readonly ICryptoPaymentService _cryptoPaymentService;
private readonly IBTCPayServerService _btcPayService;
private readonly IConfiguration _configuration;
private readonly ILogger<BTCPayWebhookController> _logger;
public BTCPayWebhookController(
ICryptoPaymentService cryptoPaymentService,
IBTCPayServerService btcPayService,
IConfiguration configuration,
ILogger<BTCPayWebhookController> logger)
{
_cryptoPaymentService = cryptoPaymentService;
_btcPayService = btcPayService;
_configuration = configuration;
_logger = logger;
}
[HttpPost("webhook")]
public async Task<IActionResult> ProcessWebhook()
{
try
{
// Read the raw request body
using var reader = new StreamReader(Request.Body);
var requestBody = await reader.ReadToEndAsync();
// Get webhook signature from headers
var signature = Request.Headers["BTCPAY-SIG"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
_logger.LogWarning("Webhook received without signature");
return BadRequest("Missing webhook signature");
}
// Validate webhook signature
var webhookSecret = _configuration["BTCPayServer:WebhookSecret"];
if (string.IsNullOrEmpty(webhookSecret))
{
_logger.LogError("BTCPay webhook secret not configured");
return StatusCode(500, "Webhook validation not configured");
}
if (!ValidateWebhookSignature(requestBody, signature, webhookSecret))
{
_logger.LogWarning("Invalid webhook signature");
return BadRequest("Invalid webhook signature");
}
// Parse webhook data
var webhookData = JsonSerializer.Deserialize<BTCPayWebhookDto>(requestBody, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (webhookData == null)
{
_logger.LogWarning("Unable to parse webhook data");
return BadRequest("Invalid webhook data");
}
_logger.LogInformation("Processing BTCPay webhook: Type={Type}, InvoiceId={InvoiceId}, StoreId={StoreId}",
webhookData.Type, webhookData.InvoiceId, webhookData.StoreId);
// Process the webhook based on event type
var success = await ProcessWebhookEvent(webhookData);
if (!success)
{
return BadRequest("Failed to process webhook");
}
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing BTCPay webhook");
return StatusCode(500, "Internal server error");
}
}
private bool ValidateWebhookSignature(string payload, string signature, string secret)
{
try
{
// BTCPay Server uses HMAC-SHA256 with format "sha256=<hex>"
if (!signature.StartsWith("sha256="))
{
return false;
}
var expectedHash = signature.Substring(7); // Remove "sha256=" prefix
var secretBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
var computedHash = hmac.ComputeHash(payloadBytes);
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
return expectedHash.Equals(computedHashHex, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating webhook signature");
return false;
}
}
private async Task<bool> ProcessWebhookEvent(BTCPayWebhookDto webhookData)
{
try
{
// Map BTCPay webhook event types to our payment status
var paymentStatus = MapWebhookEventToPaymentStatus(webhookData.Type);
if (!paymentStatus.HasValue)
{
_logger.LogInformation("Ignoring webhook event type: {Type}", webhookData.Type);
return true; // Not an error, just not a status we care about
}
// Extract payment details
var amount = webhookData.Payment?.PaymentMethodPaid ?? 0;
var transactionHash = webhookData.Payment?.TransactionData?.TransactionHash;
// Process the payment update
var success = await _cryptoPaymentService.ProcessPaymentWebhookAsync(
webhookData.InvoiceId,
paymentStatus.Value,
amount,
transactionHash);
if (success)
{
_logger.LogInformation("Successfully processed webhook for invoice {InvoiceId} with status {Status}",
webhookData.InvoiceId, paymentStatus.Value);
}
else
{
_logger.LogWarning("Failed to process webhook for invoice {InvoiceId}", webhookData.InvoiceId);
}
return success;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing webhook event for invoice {InvoiceId}", webhookData.InvoiceId);
return false;
}
}
private static PaymentStatus? MapWebhookEventToPaymentStatus(string eventType)
{
return eventType switch
{
"InvoiceCreated" => PaymentStatus.Pending,
"InvoiceReceivedPayment" => PaymentStatus.Processing,
"InvoicePaymentSettled" => PaymentStatus.Completed,
"InvoiceProcessing" => PaymentStatus.Processing,
"InvoiceExpired" => PaymentStatus.Expired,
"InvoiceSettled" => PaymentStatus.Completed,
"InvoiceInvalid" => PaymentStatus.Failed,
_ => null // Unknown event type
};
}
}

View File

@@ -98,28 +98,28 @@ public class TestController : ControllerBase
{
// Get count before cleanup
var totalBots = await _context.Bots.CountAsync();
// Keep only the most recent active bot per platform
var keepBots = await _context.Bots
.Where(b => b.IsActive && b.Status == Enums.BotStatus.Active)
.GroupBy(b => b.PlatformId)
.Select(g => g.OrderByDescending(b => b.LastSeenAt ?? b.CreatedAt).First())
.ToListAsync();
var keepBotIds = keepBots.Select(b => b.Id).ToList();
// Delete old/inactive bots and related data
var botsToDelete = await _context.Bots
.Where(b => !keepBotIds.Contains(b.Id))
.ToListAsync();
_context.Bots.RemoveRange(botsToDelete);
await _context.SaveChangesAsync();
var deletedCount = botsToDelete.Count;
var remainingCount = keepBots.Count;
return Ok(new {
return Ok(new {
message = "Bot cleanup completed",
totalBots = totalBots,
deletedBots = deletedCount,
@@ -138,4 +138,117 @@ public class TestController : ControllerBase
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("cleanup-test-data")]
public async Task<IActionResult> CleanupTestData()
{
try
{
// Get counts before cleanup
var totalOrders = await _context.Orders.CountAsync();
var totalCryptoPayments = await _context.CryptoPayments.CountAsync();
var totalOrderItems = await _context.OrderItems.CountAsync();
// Find test orders (identity references starting with "test-")
var testOrders = await _context.Orders
.Where(o => o.IdentityReference != null && o.IdentityReference.StartsWith("test-"))
.ToListAsync();
var testOrderIds = testOrders.Select(o => o.Id).ToList();
// Remove crypto payments for test orders
var cryptoPaymentsToDelete = await _context.CryptoPayments
.Where(cp => testOrderIds.Contains(cp.OrderId))
.ToListAsync();
// Remove order items for test orders
var orderItemsToDelete = await _context.OrderItems
.Where(oi => testOrderIds.Contains(oi.OrderId))
.ToListAsync();
// Delete all related data
_context.CryptoPayments.RemoveRange(cryptoPaymentsToDelete);
_context.OrderItems.RemoveRange(orderItemsToDelete);
_context.Orders.RemoveRange(testOrders);
await _context.SaveChangesAsync();
// Get counts after cleanup
var remainingOrders = await _context.Orders.CountAsync();
var remainingCryptoPayments = await _context.CryptoPayments.CountAsync();
var remainingOrderItems = await _context.OrderItems.CountAsync();
return Ok(new {
message = "Test data cleanup completed",
before = new {
orders = totalOrders,
cryptoPayments = totalCryptoPayments,
orderItems = totalOrderItems
},
after = new {
orders = remainingOrders,
cryptoPayments = remainingCryptoPayments,
orderItems = remainingOrderItems
},
deleted = new {
orders = testOrders.Count,
cryptoPayments = cryptoPaymentsToDelete.Count,
orderItems = orderItemsToDelete.Count
},
testOrdersFound = testOrders.Select(o => new {
id = o.Id,
identityReference = o.IdentityReference,
createdAt = o.CreatedAt,
total = o.Total
})
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("database")]
public async Task<IActionResult> DatabaseHealthCheck()
{
try
{
// Test database connectivity by executing a simple query
var canConnect = await _context.Database.CanConnectAsync();
if (!canConnect)
{
return StatusCode(503, new {
status = "unhealthy",
message = "Cannot connect to database",
timestamp = DateTime.UtcNow
});
}
// Test actual query execution
var categoryCount = await _context.Categories.CountAsync();
var productCount = await _context.Products.CountAsync();
var orderCount = await _context.Orders.CountAsync();
return Ok(new {
status = "healthy",
message = "Database connection successful",
stats = new {
categories = categoryCount,
products = productCount,
orders = orderCount
},
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
return StatusCode(503, new {
status = "unhealthy",
error = ex.Message,
timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,94 +0,0 @@
using System.Text.Json.Serialization;
namespace LittleShop.DTOs;
/// <summary>
/// DTO for BTCPay Server webhook events
/// Based on BTCPay Server webhook documentation
/// </summary>
public class BTCPayWebhookDto
{
[JsonPropertyName("deliveryId")]
public string DeliveryId { get; set; } = string.Empty;
[JsonPropertyName("webhookId")]
public string WebhookId { get; set; } = string.Empty;
[JsonPropertyName("originalDeliveryId")]
public string? OriginalDeliveryId { get; set; }
[JsonPropertyName("isRedelivery")]
public bool IsRedelivery { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("storeId")]
public string StoreId { get; set; } = string.Empty;
[JsonPropertyName("invoiceId")]
public string InvoiceId { get; set; } = string.Empty;
[JsonPropertyName("afterExpiration")]
public bool? AfterExpiration { get; set; }
[JsonPropertyName("manuallyMarked")]
public bool? ManuallyMarked { get; set; }
[JsonPropertyName("overPaid")]
public bool? OverPaid { get; set; }
[JsonPropertyName("partiallyPaid")]
public bool? PartiallyPaid { get; set; }
[JsonPropertyName("payment")]
public BTCPayWebhookPayment? Payment { get; set; }
}
public class BTCPayWebhookPayment
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("receivedDate")]
public long ReceivedDate { get; set; }
[JsonPropertyName("value")]
public decimal Value { get; set; }
[JsonPropertyName("fee")]
public decimal? Fee { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("destination")]
public string? Destination { get; set; }
[JsonPropertyName("paymentMethod")]
public string PaymentMethod { get; set; } = string.Empty;
[JsonPropertyName("paymentMethodPaid")]
public decimal PaymentMethodPaid { get; set; }
[JsonPropertyName("transactionData")]
public BTCPayWebhookTransactionData? TransactionData { get; set; }
}
public class BTCPayWebhookTransactionData
{
[JsonPropertyName("transactionHash")]
public string? TransactionHash { get; set; }
[JsonPropertyName("blockHash")]
public string? BlockHash { get; set; }
[JsonPropertyName("blockHeight")]
public int? BlockHeight { get; set; }
[JsonPropertyName("confirmations")]
public int? Confirmations { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System;
namespace LittleShop.DTOs
{
public class ProductVariationDto
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public string Name { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Price { get; set; }
public decimal PricePerUnit { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class CreateProductVariationDto
{
public string Name { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Price { get; set; }
}
public class UpdateProductVariationDto
{
public string Name { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal Price { get; set; }
public bool IsActive { get; set; } = true;
}
}

View File

@@ -21,12 +21,12 @@
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Radzen.Blazor" Version="5.8.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
<PackageReference Include="BTCPayServer.Client" Version="2.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="WebPush" Version="1.0.12" />

View File

@@ -0,0 +1,377 @@
@page "/blazor/admin/products"
@page "/blazor/admin/products/{ProductId:guid}"
@layout AdminLayout
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Forms
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject NavigationManager Navigation
@attribute [Authorize(Policy = "AdminOnly")]
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-box"></i> Products - Enhanced UI</h1>
</div>
</div>
<div class="nav nav-tabs nav-tabs-mobile mb-4">
<button class="nav-link @(selectedTab == "details" ? "active" : "")"
@onclick="@(() => SetActiveTab("details"))">
<i class="fas fa-edit"></i>
Product Details
</button>
<button class="nav-link @(selectedTab == "variants" ? "active" : "")"
@onclick="@(() => SetActiveTab("variants"))"
disabled="@isNewProduct">
<i class="fas fa-layer-group"></i>
Variants
</button>
<button class="nav-link @(selectedTab == "multibuys" ? "active" : "")"
@onclick="@(() => SetActiveTab("multibuys"))"
disabled="@isNewProduct">
<i class="fas fa-percentage"></i>
Multi-Buys
</button>
<button class="nav-link @(selectedTab == "photos" ? "active" : "")"
@onclick="@(() => SetActiveTab("photos"))"
disabled="@isNewProduct">
<i class="fas fa-camera"></i>
Photos
</button>
</div>
@if (selectedTab == "details")
{
<div class="card">
<div class="card-body">
<EditForm Model="product" OnValidSubmit="OnSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label class="form-label">Product Name</label>
<InputText @bind-Value="product.Name" class="form-control" />
<ValidationMessage For="() => product.Name" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<InputTextArea @bind-Value="product.Description" class="form-control" rows="4" />
<ValidationMessage For="() => product.Description" />
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Price (£)</label>
<InputNumber @bind-Value="product.Price" class="form-control" />
<ValidationMessage For="() => product.Price" />
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Stock Quantity</label>
<InputNumber @bind-Value="product.StockQuantity" class="form-control" />
<ValidationMessage For="() => product.StockQuantity" />
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label">Category</label>
<InputSelect @bind-Value="product.CategoryId" class="form-select">
<option value="">Select a category</option>
@foreach (var category in categories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
<ValidationMessage For="() => product.CategoryId" />
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Weight/Volume</label>
<InputNumber @bind-Value="product.Weight" class="form-control" />
<ValidationMessage For="() => product.Weight" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Unit</label>
<InputSelect @bind-Value="product.WeightUnit" class="form-select">
@foreach (var unit in Enum.GetValues<ProductWeightUnit>())
{
<option value="@unit">@unit.ToString()</option>
}
</InputSelect>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<InputCheckbox @bind-Value="product.IsActive" class="form-check-input" />
<label class="form-check-label">Active (visible in catalog)</label>
</div>
</div>
<div class="btn-group-mobile">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save
</button>
<button type="button" class="btn btn-success" @onclick="OnSaveAndAddNew">
<i class="fas fa-plus"></i> Save + Add New
</button>
<button type="button" class="btn btn-secondary" @onclick="OnCancel">
<i class="fas fa-times"></i> Cancel
</button>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Quick Actions</h5>
</div>
<div class="card-body">
@if (!isNewProduct)
{
<p><strong>Product ID:</strong> @ProductId</p>
<button class="btn btn-outline-primary btn-sm w-100 mb-2"
@onclick="@(() => SetActiveTab("variants"))">
<i class="fas fa-layer-group"></i> Manage Variants
</button>
<button class="btn btn-outline-success btn-sm w-100 mb-2"
@onclick="@(() => SetActiveTab("multibuys"))">
<i class="fas fa-percentage"></i> Setup Multi-Buys
</button>
<button class="btn btn-outline-info btn-sm w-100"
@onclick="@(() => SetActiveTab("photos"))">
<i class="fas fa-camera"></i> Manage Photos
</button>
}
else
{
<div class="alert alert-info">
<small>Save the product first to enable variants, multi-buys, and photo management.</small>
</div>
}
</div>
</div>
</div>
</div>
</EditForm>
</div>
</div>
}
@if (selectedTab == "variants" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-layer-group"></i> Product Variants</h5>
</div>
<div class="card-body">
<p class="text-info">This is where product variants would be managed. Integration pending with existing variation system.</p>
<button class="btn btn-primary">
<i class="fas fa-plus"></i> Add Variant
</button>
</div>
</div>
}
@if (selectedTab == "multibuys" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-percentage"></i> Multi-Buy Offers</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(2, 10)">
Buy 2 Get 10% Off
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(3, 15)">
Buy 3 Get 15% Off
</button>
</div>
<div class="col-md-4">
<button class="btn btn-outline-secondary w-100 mb-2" @onclick="() => CreateQuickMultiBuy(5, 20)">
Buy 5 Get 20% Off
</button>
</div>
</div>
</div>
</div>
}
@if (selectedTab == "photos" && !isNewProduct)
{
<div class="card">
<div class="card-header">
<h5><i class="fas fa-camera"></i> Product Photos</h5>
</div>
<div class="card-body">
<p class="text-info">Photo management integration pending.</p>
<input type="file" class="form-control" accept="image/*" multiple />
<small class="form-text text-muted">Select multiple images to upload.</small>
</div>
</div>
}
</div>
@code {
[Parameter] public Guid? ProductId { get; set; }
private ProductFormModel product = new();
private List<CategoryDto> categories = new();
private string selectedTab = "details";
private bool isNewProduct => ProductId == null || ProductId == Guid.Empty;
public class ProductFormModel
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; } = 0.01m;
public int StockQuantity { get; set; } = 0;
public Guid CategoryId { get; set; } = Guid.Empty;
public decimal Weight { get; set; } = 0.01m;
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Unit;
public bool IsActive { get; set; } = true;
}
protected override async Task OnInitializedAsync()
{
await LoadCategories();
if (!isNewProduct)
{
await LoadProduct();
}
else
{
InitializeNewProduct();
}
}
private void InitializeNewProduct()
{
product = new ProductFormModel
{
Name = string.Empty,
Description = string.Empty,
Price = 0.01m,
StockQuantity = 0,
CategoryId = Guid.Empty,
Weight = 0.01m,
WeightUnit = ProductWeightUnit.Unit,
IsActive = true
};
}
private async Task LoadCategories()
{
categories = (await CategoryService.GetAllCategoriesAsync()).ToList();
}
private async Task LoadProduct()
{
try
{
var existingProduct = await ProductService.GetProductByIdAsync(ProductId!.Value);
if (existingProduct != null)
{
product = new ProductFormModel
{
Name = existingProduct.Name,
Description = existingProduct.Description,
Price = existingProduct.Price,
StockQuantity = existingProduct.StockQuantity,
CategoryId = existingProduct.CategoryId,
Weight = existingProduct.Weight,
WeightUnit = existingProduct.WeightUnit,
IsActive = existingProduct.IsActive
};
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading product: {ex.Message}");
}
}
private void SetActiveTab(string tab)
{
selectedTab = tab;
}
private async Task OnSubmit()
{
try
{
if (isNewProduct)
{
var createDto = new CreateProductDto
{
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
Weight = product.Weight,
WeightUnit = product.WeightUnit
};
var newProduct = await ProductService.CreateProductAsync(createDto);
Navigation.NavigateTo($"/blazor/admin/products/{newProduct.Id}", false);
}
else
{
var updateDto = new UpdateProductDto
{
Name = product.Name,
Description = product.Description,
Price = product.Price,
StockQuantity = product.StockQuantity,
CategoryId = product.CategoryId,
Weight = product.Weight,
WeightUnit = product.WeightUnit,
IsActive = product.IsActive
};
await ProductService.UpdateProductAsync(ProductId!.Value, updateDto);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error saving product: {ex.Message}");
}
}
private async Task OnSaveAndAddNew()
{
await OnSubmit();
InitializeNewProduct();
selectedTab = "details";
Navigation.NavigateTo("/blazor/admin/products", false);
}
private void OnCancel()
{
Navigation.NavigateTo("/Admin/Products");
}
private async Task CreateQuickMultiBuy(int quantity, decimal discountPercent)
{
Console.WriteLine($"Creating multi-buy: {quantity} items with {discountPercent}% discount");
}
}

View File

@@ -0,0 +1,22 @@
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
<div class="blazor-admin-wrapper">
@Body
<div id="blazor-error-ui">
<environment include="Staging,Production">
An error has occurred. This application may no longer respond until reloaded.
</environment>
<environment include="Development">
An unhandled exception has occurred. See browser dev tools for details.
</environment>
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
</div>
@code {
}

View File

@@ -0,0 +1,5 @@
@inherits LayoutComponentBase
<div class="blazor-container">
@Body
</div>

View File

@@ -0,0 +1,14 @@
@page "/blazor"
@namespace LittleShop.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = "~/Areas/Admin/Views/Shared/_Layout.cshtml";
ViewData["Title"] = "Admin";
}
<component type="typeof(App)" render-mode="ServerPrerendered" />
@section Scripts {
<script src="/_framework/blazor.server.js"></script>
<script src="/_content/Radzen.Blazor/Radzen.Blazor.js"></script>
}

View File

@@ -20,6 +20,8 @@ builder.Host.UseSerilog();
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddControllersWithViews(); // Add MVC for Admin Panel
builder.Services.AddRazorPages(); // Add Razor Pages for Blazor
builder.Services.AddServerSideBlazor(); // Add Blazor Server
// Configure Antiforgery
builder.Services.AddAntiforgery(options =>
@@ -268,6 +270,9 @@ app.MapControllerRoute(
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllers(); // API routes
app.MapBlazorHub(); // Map Blazor Server hub
app.MapRazorPages(); // Enable Razor Pages for Blazor
app.MapFallbackToPage("/blazor/{*path}", "/_Host"); // Fallback for all Blazor routes
// Map SignalR hub
app.MapHub<LittleShop.Hubs.ActivityHub>("/activityHub");

View File

@@ -1,181 +0,0 @@
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using LittleShop.Enums;
using Newtonsoft.Json.Linq;
namespace LittleShop.Services;
public interface IBTCPayServerService
{
Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null);
Task<InvoiceData?> GetInvoiceAsync(string invoiceId);
Task<bool> ValidateWebhookAsync(string payload, string signature);
}
public class BTCPayServerService : IBTCPayServerService
{
private readonly BTCPayServerClient _client;
private readonly IConfiguration _configuration;
private readonly ILogger<BTCPayServerService> _logger;
private readonly string _storeId;
private readonly string _webhookSecret;
private readonly string _baseUrl;
public BTCPayServerService(IConfiguration configuration, ILogger<BTCPayServerService> logger)
{
_configuration = configuration;
_logger = logger;
_baseUrl = _configuration["BTCPayServer:BaseUrl"] ?? throw new ArgumentException("BTCPayServer:BaseUrl not configured");
var apiKey = _configuration["BTCPayServer:ApiKey"] ?? throw new ArgumentException("BTCPayServer:ApiKey not configured");
_storeId = _configuration["BTCPayServer:StoreId"] ?? throw new ArgumentException("BTCPayServer:StoreId not configured");
_webhookSecret = _configuration["BTCPayServer:WebhookSecret"] ?? "";
_logger.LogInformation("Initializing BTCPay Server connection to {BaseUrl} with Store ID: {StoreId}", _baseUrl, _storeId);
// Create HttpClient with proper SSL validation
var httpClientHandler = new HttpClientHandler();
// Only allow insecure SSL in development mode with explicit configuration
var allowInsecureSSL = _configuration.GetValue<bool>("Security:AllowInsecureSSL", false);
if (allowInsecureSSL)
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (environment == "Development")
{
_logger.LogWarning("SECURITY WARNING: SSL certificate validation is disabled for development. This should NEVER be used in production!");
httpClientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
}
else
{
_logger.LogError("Attempted to disable SSL certificate validation in non-development environment. This is not allowed.");
throw new InvalidOperationException("SSL certificate validation cannot be disabled in production environments");
}
}
var httpClient = new HttpClient(httpClientHandler);
_client = new BTCPayServerClient(new Uri(_baseUrl), apiKey, httpClient);
}
public async Task<string> CreateInvoiceAsync(decimal amount, CryptoCurrency currency, string orderId, string? description = null)
{
var paymentMethod = GetPaymentMethod(currency);
var metadata = new JObject
{
["orderId"] = orderId,
["requestedCurrency"] = currency.ToString(),
["paymentMethod"] = paymentMethod
};
if (!string.IsNullOrEmpty(description))
{
metadata["itemDesc"] = description;
}
// Create invoice in GBP (fiat) - BTCPay will handle crypto conversion
var request = new CreateInvoiceRequest
{
Amount = amount,
Currency = "GBP", // Always use fiat currency for the amount
Metadata = metadata,
Checkout = new CreateInvoiceRequest.CheckoutOptions
{
Expiration = TimeSpan.FromHours(24),
PaymentMethods = new[] { paymentMethod }, // Specify which crypto to accept
DefaultPaymentMethod = paymentMethod
}
};
try
{
_logger.LogDebug("Creating BTCPay invoice - Amount: {Amount} GBP, Payment Method: {PaymentMethod}, Order: {OrderId}",
amount, paymentMethod, orderId);
var invoice = await _client.CreateInvoice(_storeId, request);
_logger.LogInformation("✅ Created BTCPay invoice {InvoiceId} for Order {OrderId} - Amount: {Amount} GBP, Method: {PaymentMethod}, Checkout: {CheckoutLink}",
invoice.Id, orderId, amount, paymentMethod, invoice.CheckoutLink);
return invoice.Id;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to create BTCPay invoice - Amount: {Amount} GBP, Method: {PaymentMethod}, Store: {StoreId}, BaseUrl: {BaseUrl}",
amount, paymentMethod, _storeId, _baseUrl);
// Always throw - never generate fake invoices
throw;
}
}
public async Task<InvoiceData?> GetInvoiceAsync(string invoiceId)
{
try
{
return await _client.GetInvoice(_storeId, invoiceId);
}
catch
{
return null;
}
}
public Task<bool> ValidateWebhookAsync(string payload, string signature)
{
try
{
// BTCPay Server uses HMAC-SHA256 with format "sha256=<hex>"
if (!signature.StartsWith("sha256="))
{
return Task.FromResult(false);
}
var expectedHash = signature.Substring(7); // Remove "sha256=" prefix
var secretBytes = System.Text.Encoding.UTF8.GetBytes(_webhookSecret);
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
using var hmac = new System.Security.Cryptography.HMACSHA256(secretBytes);
var computedHash = hmac.ComputeHash(payloadBytes);
var computedHashHex = Convert.ToHexString(computedHash).ToLowerInvariant();
return Task.FromResult(expectedHash.Equals(computedHashHex, StringComparison.OrdinalIgnoreCase));
}
catch
{
return Task.FromResult(false);
}
}
private static string GetCurrencyCode(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT",
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
private static string GetPaymentMethod(CryptoCurrency currency)
{
return currency switch
{
CryptoCurrency.BTC => "BTC",
CryptoCurrency.XMR => "XMR",
CryptoCurrency.USDT => "USDT_ETH", // USDT on Ethereum
CryptoCurrency.LTC => "LTC",
CryptoCurrency.ETH => "ETH",
CryptoCurrency.ZEC => "ZEC",
CryptoCurrency.DASH => "DASH",
CryptoCurrency.DOGE => "DOGE",
_ => "BTC"
};
}
}

18
LittleShop/_Imports.razor Normal file
View File

@@ -0,0 +1,18 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using LittleShop
@using LittleShop.Areas.Admin.Components
@using LittleShop.Areas.Admin.Components.Products
@using LittleShop.Models
@using LittleShop.DTOs
@using LittleShop.Services
@using LittleShop.Enums
@using LittleShop.Pages.Shared
@using Radzen
@using Radzen.Blazor

View File

@@ -2,4 +2,4 @@
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8EuTJxpD_M1FlZqQ5anN7O_HRIgSBb-xpi5fb6C7RkkUGXYZDnXJwrE8SzrYPVZMVePsro-9t2mZBzv2P4QylUMwt6Ovpd0kgxEatefnx3k64cqRSQMTsxU6X5P_1JjNccDpPwqsmxX_l_aBH_PvmnAjxMCeTEaZ1frmRWHLdOkKFrCWQbgDrso1ZelLuvewDn-5Yr9neq4Dp4dwczSs8EXtdcs_XArBHaDeIylzyjHbHBNdIiZeN2JeEcvcwabixeXefhaGVrq26pvG7YHWvpkjC1Np_IW76YSM3xe_RN5E5wOODfscPLWfPeOahZFlgxH6oWmr9NVfBEVa9CQc2msO1cSrtEypeygtZyoJZIqePPWVfFunMTzjKflheQAdDYRBKJP4moZ2eVvirkC6BZ-fq33FgVcKM7AwmX3RBWPHQhJSYq7bJsw4zS-r6vu93RAgTWxzFzSznt6hp8KeRzRjahIOzs6gO6g_7ihtfogphbt-joCNQeFKqCTSFkhudxMT2pG_n7QJHrO_ECriqms3lrrMq2wDddjcMySg02Uw
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8OZGzJDh-FtIgYN_FUICYtu8BfkzDd18onCtyOuqoU3P3YGzw6nG9lT7Q-STTSg8xC9U2RR9fB0rY445YHyqKzsHAn2FtIvsgFiL5smcbiZSiQBH9qkrbYwAgik5spmOX6XNZHpF9KwfRg-dLtpLRfFnKpeeh4TeQVceVXkscnqR8oThQexUUZlTKfnbwH5xfGEOWV4tsnaj_6mKmcHorVZH0mV4UmdlygktppTv3Ulz5LoP13sRpEnKOHPtu3ZnZfJsohqtFvDWs1bB7w7KmdM1TamocwA1DYIOSFDRwvgQ7DeZlHd4cgLAhCMvT1x6XKnm49YJxQ52BCnsRvkotUm7CgLFcBImqSSEFklwQxBFE64Hjmi_LxDC6vpxQnT4B89tQDqkuYJGEhA174c2OoG1IS1gjd02cfujG5fOO8eYcEFyuARkA4spzU4KTvg59N18C0H59ZAEoV0iIVHaTMHSFPh4jkrLgJBvpp9l8lU3QKKcDQ9V7v8ZUlEP0jfdoyudLEnmYcAuD-xDSepSauX-VxexVpWsZdL51BGilkue

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,457 @@
/* Mobile-First Admin Styles */
:root {
--mobile-nav-height: 60px;
--touch-target-size: 44px;
--primary-color: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
}
/* Mobile Bottom Navigation Bar */
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--mobile-nav-height);
background: white;
border-top: 1px solid #e5e7eb;
display: none;
z-index: 1000;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.mobile-bottom-nav-items {
display: flex;
justify-content: space-around;
align-items: center;
height: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.mobile-nav-item {
flex: 1;
height: 100%;
}
.mobile-nav-link {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #6b7280;
text-decoration: none;
font-size: 10px;
transition: all 0.2s;
}
.mobile-nav-link i {
font-size: 20px;
margin-bottom: 2px;
}
.mobile-nav-link.active {
color: var(--primary-color);
}
.mobile-nav-link:active {
background: rgba(37, 99, 235, 0.1);
}
.mobile-nav-badge {
position: absolute;
top: 5px;
right: calc(50% - 15px);
background: var(--danger-color);
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 9px;
font-weight: bold;
min-width: 16px;
text-align: center;
}
/* Settings Menu Drawer */
.settings-drawer {
position: fixed;
top: 0;
right: -300px;
width: 300px;
height: 100%;
background: white;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
transition: right 0.3s ease;
z-index: 1001;
overflow-y: auto;
}
.settings-drawer.open {
right: 0;
}
.settings-drawer-header {
padding: 20px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-drawer-close {
background: none;
border: none;
font-size: 24px;
color: #6b7280;
padding: 0;
width: var(--touch-target-size);
height: var(--touch-target-size);
}
.settings-menu-list {
list-style: none;
padding: 0;
margin: 0;
}
.settings-menu-item {
border-bottom: 1px solid #f3f4f6;
}
.settings-menu-link {
display: flex;
align-items: center;
padding: 15px 20px;
color: #374151;
text-decoration: none;
min-height: var(--touch-target-size);
}
.settings-menu-link i {
margin-right: 15px;
width: 20px;
text-align: center;
}
.settings-menu-link:active {
background: #f3f4f6;
}
/* Drawer Overlay */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 1000;
}
.drawer-overlay.show {
display: block;
}
/* Touch-Friendly Forms */
@media (max-width: 768px) {
.form-control, .form-select, .btn {
min-height: var(--touch-target-size);
font-size: 16px; /* Prevents zoom on iOS */
}
.btn {
padding: 12px 20px;
}
/* Larger checkboxes and radios */
.form-check-input {
width: 24px;
height: 24px;
margin-top: 0;
}
.form-check-label {
padding-left: 10px;
line-height: 24px;
}
}
/* Mobile Cards for Orders/Products */
.mobile-card {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
}
.mobile-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.mobile-card-title {
font-weight: 600;
font-size: 16px;
}
.mobile-card-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-pending {
background: #fef3c7;
color: #92400e;
}
.status-accepted {
background: #d1fae5;
color: #065f46;
}
.status-packing {
background: #dbeafe;
color: #1e40af;
}
.status-dispatched {
background: #e9d5ff;
color: #6b21a8;
}
/* Swipe Actions */
.swipeable {
position: relative;
overflow: hidden;
}
.swipe-actions {
position: absolute;
top: 0;
right: -200px;
width: 200px;
height: 100%;
display: flex;
transition: right 0.3s ease;
}
.swipeable.swiped .swipe-actions {
right: 0;
}
.swipe-action {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 500;
border: none;
}
.swipe-action-accept {
background: var(--success-color);
}
.swipe-action-reject {
background: var(--danger-color);
}
/* Responsive adjustments */
@media (max-width: 768px) {
/* Hide desktop nav on mobile */
.navbar-collapse {
display: none !important;
}
/* Show mobile bottom nav */
.mobile-bottom-nav {
display: block;
}
/* Adjust main content for bottom nav */
main {
padding-bottom: calc(var(--mobile-nav-height) + 20px) !important;
}
/* Full-width buttons on mobile */
.btn-group-mobile {
display: flex;
gap: 10px;
}
.btn-group-mobile .btn {
flex: 1;
}
/* Responsive tables to cards */
.table-responsive-mobile {
display: none;
}
.cards-responsive-mobile {
display: block;
}
}
@media (min-width: 769px) {
/* Hide mobile-only elements on desktop */
.mobile-bottom-nav,
.settings-drawer,
.drawer-overlay,
.cards-responsive-mobile {
display: none !important;
}
.table-responsive-mobile {
display: block;
}
}
/* Pull to Refresh */
.pull-to-refresh {
position: absolute;
top: -60px;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
transition: top 0.3s ease;
}
.pull-to-refresh.active {
top: 0;
}
.pull-to-refresh-spinner {
width: 30px;
height: 30px;
border: 3px solid #e5e7eb;
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Floating Action Button */
.fab {
position: fixed;
bottom: calc(var(--mobile-nav-height) + 20px);
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary-color);
color: white;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
transition: transform 0.2s;
z-index: 999;
}
.fab:active {
transform: scale(0.95);
}
/* Tab Navigation - Mobile Optimized */
.nav-tabs-mobile {
display: flex;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 20px;
}
.nav-tabs-mobile::-webkit-scrollbar {
display: none;
}
.nav-tabs-mobile .nav-link {
white-space: nowrap;
padding: 12px 20px;
color: #6b7280;
border: none;
border-bottom: 2px solid transparent;
min-width: fit-content;
}
.nav-tabs-mobile .nav-link.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: none;
}
/* Improved Touch Feedback */
button, a, .clickable {
-webkit-tap-highlight-color: rgba(37, 99, 235, 0.2);
}
/* Loading States */
.skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Product Variants Tab */
.variants-container {
padding: 20px 0;
}
.variant-card {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.variant-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.variant-quantity {
font-size: 24px;
font-weight: bold;
color: var(--primary-color);
}
.variant-price {
font-size: 20px;
font-weight: 600;
}
.variant-unit-price {
color: #6b7280;
font-size: 14px;
margin-top: 5px;
}

View File

@@ -0,0 +1,15 @@
// Blazor Server Integration Script
document.addEventListener('DOMContentLoaded', function() {
// Check if we're on a page that should use Blazor
const blazorContainers = document.querySelectorAll('[data-blazor-component]');
if (blazorContainers.length > 0 || window.location.pathname.includes('/Admin/Products/Blazor')) {
// Start Blazor
Blazor.start();
}
});
// Helper function to navigate to Blazor components from MVC
window.navigateToBlazor = function(componentPath) {
window.location.href = '/blazor#' + componentPath;
};

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="/" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blazor Assets Test</title>
</head>
<body>
<div id="test-results">
<h1>Blazor Asset Loading Test</h1>
<div id="results">
<p id="blazor-status">❓ Checking Blazor Server JS...</p>
<p id="radzen-status">❓ Checking Radzen Blazor JS...</p>
<p id="signalr-status">❓ Checking SignalR connection...</p>
</div>
</div>
<!-- Test Blazor Server JS -->
<script src="/_framework/blazor.server.js"></script>
<!-- Test Radzen JS -->
<script src="/_content/Radzen.Blazor/Radzen.Blazor.js"></script>
<script>
// Check if Blazor is available
window.addEventListener('load', function() {
setTimeout(function() {
const blazorStatus = document.getElementById('blazor-status');
const radzenStatus = document.getElementById('radzen-status');
const signalrStatus = document.getElementById('signalr-status');
if (typeof window.Blazor !== 'undefined') {
blazorStatus.innerHTML = '✅ Blazor Server JS loaded successfully';
blazorStatus.style.color = 'green';
} else {
blazorStatus.innerHTML = '❌ Blazor Server JS failed to load';
blazorStatus.style.color = 'red';
}
if (typeof window.Radzen !== 'undefined') {
radzenStatus.innerHTML = '✅ Radzen Blazor JS loaded successfully';
radzenStatus.style.color = 'green';
} else {
radzenStatus.innerHTML = '❌ Radzen Blazor JS failed to load';
radzenStatus.style.color = 'red';
}
// Check SignalR
if (window.Blazor && window.Blazor.start) {
signalrStatus.innerHTML = '✅ SignalR connection methods available';
signalrStatus.style.color = 'green';
} else {
signalrStatus.innerHTML = '❌ SignalR connection methods not available';
signalrStatus.style.color = 'red';
}
console.log('Blazor available:', typeof window.Blazor !== 'undefined');
console.log('Radzen available:', typeof window.Radzen !== 'undefined');
console.log('All window objects:', Object.keys(window).filter(key => key.includes('Blazor') || key.includes('Radzen')));
}, 2000);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="/" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blazor Asset Test</title>
<!-- Bootstrap -->
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<!-- FontAwesome -->
<link href="/lib/fontawesome/css/all.min.css" rel="stylesheet">
<!-- Radzen Blazor CSS -->
<link href="/_content/Radzen.Blazor/css/material-base.css" rel="stylesheet">
<!-- Mobile Admin CSS -->
<link href="/css/mobile-admin.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid">
<div class="row mb-4">
<div class="col">
<h1><i class="fas fa-box"></i> Blazor Asset Loading Test</h1>
</div>
</div>
<div class="alert alert-info">
<h4>Testing Asset Loading</h4>
<ul>
<li><strong>Bootstrap:</strong> <span class="badge bg-primary">This should be styled</span></li>
<li><strong>FontAwesome:</strong> <i class="fas fa-check-circle text-success"></i> Icon should appear</li>
<li><strong>Radzen CSS:</strong> Check browser dev tools for 404 errors</li>
<li><strong>Mobile CSS:</strong> <span style="font-size: var(--touch-target-size, 44px);">Touch target size</span></li>
</ul>
</div>
<div class="card">
<div class="card-body">
<p>If you can see proper styling and no 404 errors in the browser console, the assets are loading correctly.</p>
<h5>Mobile Navigation Test</h5>
<nav class="mobile-bottom-nav">
<ul class="mobile-bottom-nav-items">
<li class="mobile-nav-item">
<a href="#" class="mobile-nav-link">
<i class="fas fa-shopping-cart"></i>
<span>Orders</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="#" class="mobile-nav-link active">
<i class="fas fa-box"></i>
<span>Products</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<!-- jQuery -->
<script src="/lib/jquery/jquery.min.js"></script>
<!-- Bootstrap JS -->
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Blazor Server JS -->
<script src="/_framework/blazor.server.js"></script>
<!-- Radzen JS -->
<script src="/_content/Radzen.Blazor/Radzen.Blazor.js"></script>
<script>
console.log('Asset loading test loaded');
console.log('jQuery available:', typeof $ !== 'undefined');
console.log('Bootstrap available:', typeof bootstrap !== 'undefined');
console.log('Blazor available:', typeof window.Blazor !== 'undefined');
</script>
</body>
</html>