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:
8
LittleShop/App.razor
Normal file
8
LittleShop/App.razor
Normal 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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
35
LittleShop/Areas/Admin/Views/Products/Blazor.cshtml
Normal file
35
LittleShop/Areas/Admin/Views/Products/Blazor.cshtml
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
32
LittleShop/DTOs/ProductVariationDto.cs
Normal file
32
LittleShop/DTOs/ProductVariationDto.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
377
LittleShop/Pages/Admin/Products/ProductsBlazor.razor
Normal file
377
LittleShop/Pages/Admin/Products/ProductsBlazor.razor
Normal 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");
|
||||
}
|
||||
}
|
||||
22
LittleShop/Pages/Shared/AdminLayout.razor
Normal file
22
LittleShop/Pages/Shared/AdminLayout.razor
Normal 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 {
|
||||
|
||||
}
|
||||
5
LittleShop/Pages/Shared/MainLayout.razor
Normal file
5
LittleShop/Pages/Shared/MainLayout.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="blazor-container">
|
||||
@Body
|
||||
</div>
|
||||
14
LittleShop/Pages/_Host.cshtml
Normal file
14
LittleShop/Pages/_Host.cshtml
Normal 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>
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
18
LittleShop/_Imports.razor
Normal 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
|
||||
@@ -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
|
||||
|
||||
BIN
LittleShop/littleshop-dev.db-shm
Normal file
BIN
LittleShop/littleshop-dev.db-shm
Normal file
Binary file not shown.
BIN
LittleShop/littleshop-dev.db-wal
Normal file
BIN
LittleShop/littleshop-dev.db-wal
Normal file
Binary file not shown.
457
LittleShop/wwwroot/css/mobile-admin.css
Normal file
457
LittleShop/wwwroot/css/mobile-admin.css
Normal 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;
|
||||
}
|
||||
15
LittleShop/wwwroot/js/blazor-integration.js
Normal file
15
LittleShop/wwwroot/js/blazor-integration.js
Normal 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;
|
||||
};
|
||||
64
LittleShop/wwwroot/test-blazor-assets.html
Normal file
64
LittleShop/wwwroot/test-blazor-assets.html
Normal 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>
|
||||
77
LittleShop/wwwroot/test-blazor.html
Normal file
77
LittleShop/wwwroot/test-blazor.html
Normal 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>
|
||||
Reference in New Issue
Block a user