Compare commits

..

80 Commits

Author SHA1 Message Date
Khwezi Mngoma 8d2efbeb4a Added legal pages, contact and abut us
continuous-integration/drone/pr Build is passing
Redesigned account, checkout
Added stock management design elements
2026-06-16 23:32:44 +02:00
Khwezi Mngoma 5d614d2a94 Checkout page clean up
continuous-integration/drone/pr Build is passing
2026-06-16 21:12:12 +02:00
Khwezi Mngoma 554741c2e5 Added user feedback toaster messages
continuous-integration/drone/pr Build is passing
2026-06-16 14:49:03 +02:00
Khwezi Mngoma 02294d36e8 Implemented AddToCart functionality on home page 2026-06-16 14:37:44 +02:00
Khwezi Mngoma 31423ea48d Solved double entry issue on order confirmation
continuous-integration/drone/pr Build is passing
2026-06-16 13:47:03 +02:00
Khwezi Mngoma 6ca781759f Stable payment and order process 2026-06-16 13:39:40 +02:00
Khwezi Mngoma cb03b91c6c Docker image cache test
continuous-integration/drone/pr Build is passing
2026-06-16 10:58:03 +02:00
Khwezi Mngoma b0ea5ea098 Specified buildkit inline caching
continuous-integration/drone/pr Build is passing
2026-06-16 10:44:27 +02:00
Khwezi Mngoma 32eeb24558 Added caching to docker build stage
continuous-integration/drone/pr Build is passing
2026-06-16 10:32:25 +02:00
Khwezi Mngoma bead8314da ensured that untranslatable claims do not crash signalr
continuous-integration/drone/pr Build is passing
2026-06-16 00:14:45 +02:00
Khwezi Mngoma 5c3ceeeb83 Added automati revision history pruning
continuous-integration/drone/pr Build is passing
2026-06-15 23:27:23 +02:00
Khwezi Mngoma fcc0b3845e Removed https failsafe
continuous-integration/drone/pr Build is passing
2026-06-15 22:53:55 +02:00
Khwezi Mngoma a23292f02a Removed secrets from manifest since they are hosted in the cluster
continuous-integration/drone/pr Build is passing
2026-06-15 22:39:41 +02:00
Khwezi Mngoma 4954ead02d Added https failsafe
continuous-integration/drone/pr Build is passing
2026-06-15 17:22:20 +02:00
Khwezi Mngoma 79eebea7b1 Removed UseHttpsRedirection
continuous-integration/drone/pr Build is passing
2026-06-15 17:11:48 +02:00
Khwezi Mngoma 7199a6651b Implemented cart hydration and refactored paynow flow
continuous-integration/drone/pr Build is passing
2026-06-15 16:42:09 +02:00
Khwezi Mngoma 11e0176e40 Enabled sticky sessions
continuous-integration/drone/pr Build is passing
2026-06-15 00:45:25 +02:00
Khwezi Mngoma 160c23ab8b Removed failsafe 2026-06-15 00:36:46 +02:00
Khwezi Mngoma 2b1d862d3b Added proto handling fail safe 2026-06-15 00:31:51 +02:00
Khwezi Mngoma c6fc228c66 Refactored fowarded header config in app
continuous-integration/drone/pr Build is passing
2026-06-15 00:26:06 +02:00
Khwezi Mngoma dc3dd4a40b Removed invalid manifest field
continuous-integration/drone/pr Build is passing
2026-06-15 00:05:22 +02:00
Khwezi Mngoma 1bb1b0d476 Refactored manifest
continuous-integration/drone/pr Build is failing
2026-06-14 23:57:06 +02:00
Khwezi Mngoma 0bb5da3513 Updates app pipelining and cleaned up service registration
continuous-integration/drone/pr Build is passing
2026-06-14 23:40:47 +02:00
Khwezi Mngoma c3e6f9801b Updated multi pod handling of sticky sessions
continuous-integration/drone/pr Build is passing
2026-06-14 23:14:41 +02:00
Khwezi Mngoma d323bd866c Updated handling of fowarded header and fixed base64 encoding of certificate
continuous-integration/drone/pr Build is passing
2026-06-14 22:56:23 +02:00
Khwezi Mngoma a6a41eaeac Moved kerstel definition to the service defitniton section
continuous-integration/drone/pr Build is failing
2026-06-14 18:01:42 +02:00
Khwezi Mngoma 17a74ca750 Refactore the entire k8s manifest for pure https routing
continuous-integration/drone/pr Build is failing
2026-06-14 17:48:39 +02:00
Khwezi Mngoma 53b3018d9e Update cookie policies
continuous-integration/drone/pr Build is passing
2026-06-14 13:15:30 +02:00
Khwezi Mngoma 8002920a07 Updated cookie policies
continuous-integration/drone/pr Build is passing
2026-06-14 12:56:09 +02:00
Khwezi Mngoma 285cb29867 Reordered service registration
continuous-integration/drone/pr Build is passing
2026-06-14 12:42:22 +02:00
Khwezi Mngoma 596ab396a4 Refactored starup pipeline
continuous-integration/drone/pr Build is passing
2026-06-14 12:23:23 +02:00
Khwezi Mngoma 9cbde6e622 Encapsulated the cert string in a base 64 string
continuous-integration/drone/pr Build is passing
2026-06-14 12:05:21 +02:00
Khwezi Mngoma 8ddf769fab Refactored manifest
continuous-integration/drone/pr Build is passing
2026-06-14 11:49:08 +02:00
Khwezi Mngoma 44741d2162 Added data protection keys and cert encryption to them
continuous-integration/drone/pr Build is passing
2026-06-14 11:33:04 +02:00
Khwezi Mngoma 5204816370 Added data protection key persistance
continuous-integration/drone/pr Build is passing
2026-06-13 23:51:21 +02:00
Khwezi Mngoma ec4c9d9689 Fixed login and logout redirect issue
continuous-integration/drone/pr Build is passing
2026-06-13 23:20:02 +02:00
Khwezi Mngoma ff826f0b73 Moved RedirectToLogin code to code behind 2026-06-13 22:14:21 +02:00
Khwezi Mngoma 6d76442dcf Reordered solution 2026-06-13 21:54:15 +02:00
Khwezi Mngoma 5ffe9793e8 Stable payfast interaction 2026-06-13 21:50:29 +02:00
Khwezi Mngoma 0765e63d8a Using shared service for Cart management
continuous-integration/drone/pr Build is passing
2026-06-12 08:54:53 +02:00
Khwezi Mngoma 234fb0f2f3 Stable checkout page
continuous-integration/drone/pr Build is passing
2026-06-11 14:24:42 +02:00
Khwezi Mngoma e7acb05027 Completed Cart page design
continuous-integration/drone/pr Build is passing
2026-06-11 00:23:57 +02:00
Khwezi Mngoma 64e0fcba27 Wired up CartDrawel and ProductView to cart service and local storage
continuous-integration/drone/pr Build is passing
2026-06-10 23:01:21 +02:00
Khwezi Mngoma 3bce80c963 Implemented cart service with state tracker and linked to main layout
continuous-integration/drone/pr Build is passing
2026-06-09 23:39:49 +02:00
Khwezi Mngoma d3e9b30be5 Updated nuget packaged to includ the CartService 2026-06-09 20:51:56 +02:00
Khwezi Mngoma a3bc2bc3f9 Fixed manifest secret name
continuous-integration/drone/pr Build is passing
2026-06-07 16:51:07 +02:00
Khwezi Mngoma a66a84af75 Stable user session management
continuous-integration/drone/pr Build is passing
2026-06-07 15:41:54 +02:00
Khwezi Mngoma 895d99a2e5 Updated packages
continuous-integration/drone/pr Build is passing
2026-06-05 09:24:45 +02:00
Khwezi Mngoma de714d2271 Build trigger
continuous-integration/drone/pr Build is passing
2026-06-05 09:07:50 +02:00
Khwezi Mngoma 64b46865cf Removd proto handling from login process 2026-06-05 08:59:00 +02:00
Khwezi Mngoma db74ffbebe Simplified logn and logout process
continuous-integration/drone/pr Build is passing
2026-06-05 08:21:50 +02:00
Khwezi Mngoma 8f68d8c60e Added port stripping
continuous-integration/drone/pr Build is passing
2026-06-05 07:39:58 +02:00
Khwezi Mngoma 58dc67e680 Refactored https logni proto handling
continuous-integration/drone/pr Build is passing
2026-06-05 06:43:36 +02:00
Khwezi Mngoma 5123a4d3ac Added support for forwarded headers
continuous-integration/drone/pr Build is passing
2026-06-05 06:29:55 +02:00
Khwezi Mngoma ae51a3a864 Fixed secrets mappings
continuous-integration/drone/pr Build is passing
2026-06-05 06:16:42 +02:00
Khwezi Mngoma 31a640d672 Stable security
continuous-integration/drone/pr Build is passing
2026-06-05 05:58:05 +02:00
Khwezi Mngoma 097ecd6421 Configured security 2026-06-04 14:45:33 +02:00
Khwezi Mngoma 9b3e889d89 Upgraded quartz
continuous-integration/drone/pr Build is passing
2026-06-03 11:53:31 +02:00
Khwezi Mngoma e35a68f7e8 Updated backend
continuous-integration/drone/pr Build is passing
2026-06-02 00:30:47 +02:00
Khwezi Mngoma b70ecab9ea Fixed event service discovery issue
continuous-integration/drone/pr Build is passing
2026-06-01 23:39:05 +02:00
Khwezi Mngoma 209947d70f Updated backend
continuous-integration/drone/pr Build is passing
2026-06-01 22:58:10 +02:00
Khwezi Mngoma edb9c281ef Ensured appsettings aligns with k8s config
continuous-integration/drone/pr Build is passing
2026-06-01 11:57:44 +02:00
Khwezi Mngoma b189b26b62 Refactored app k8s manifest
continuous-integration/drone/pr Build is passing
2026-06-01 11:15:54 +02:00
Khwezi Mngoma 52e3ab16bf Refactored manifest to include s3 bucket and HasherService configs and secrets
continuous-integration/drone/pr Build is failing
Upgraded nuget packages to bring in new Payment and Product service functionality
2026-06-01 09:50:14 +02:00
Khwezi Mngoma 26cd12532c Authors now showing on the listing
continuous-integration/drone/pr Build is passing
Back to the top works
2026-05-30 22:19:35 +02:00
Khwezi Mngoma 01a0264452 Working search
continuous-integration/drone/pr Build is passing
2026-05-30 21:00:13 +02:00
Khwezi Mngoma af02cbc649 Working filter 2026-05-30 19:48:06 +02:00
Khwezi Mngoma 7a1a7566d6 Stable running home page with product view
continuous-integration/drone/pr Build is passing
2026-05-30 19:06:14 +02:00
Khwezi Mngoma f2ff7e3647 Added category support in backend 2026-05-30 16:12:47 +02:00
Khwezi Mngoma e5358160dd Updated nuget packages 2026-05-30 15:42:16 +02:00
Khwezi Mngoma 07749bd68c Moved Home page code to code-behind 2026-05-30 10:17:47 +02:00
Khwezi Mngoma 0a3dd61ce0 Upgraded backend services
continuous-integration/drone/pr Build is passing
2026-05-30 00:31:54 +02:00
Khwezi Mngoma 2f8afc380e Light refactor
continuous-integration/drone/pr Build is passing
2026-05-24 11:29:49 +02:00
Khwezi Mngoma cda1d225bc Final design touches 2026-05-24 11:27:59 +02:00
Khwezi Mngoma 051992accf Completed UX/UI design
continuous-integration/drone/pr Build is passing
2026-05-24 10:46:43 +02:00
Khwezi Mngoma e6880959d9 Added the payment confirmation page 2026-05-23 17:12:14 +02:00
Khwezi Mngoma 384daa0e54 Added cart page 2026-05-23 17:05:59 +02:00
Khwezi Mngoma 0cb77f87af Styled not found page 2026-05-23 16:52:27 +02:00
Khwezi Mngoma 5e839dca03 Implemented main home design and cart viewer 2026-05-23 15:37:44 +02:00
Khwezi Mngoma d8cb68e8bf Updated UAT url
continuous-integration/drone/pr Build is passing
2026-05-23 12:17:07 +02:00
57 changed files with 5741 additions and 428 deletions
+5 -1
View File
@@ -23,7 +23,7 @@ trigger:
kind: pipeline
type: docker
name: package
steps:
- name: docker-build
image: plugins/docker
@@ -31,6 +31,10 @@ steps:
registry: nexus.khongisa.co.za
repo: nexus.khongisa.co.za/midrandbooks
tags: [ latest, "1.${DRONE_BUILD_NUMBER}" ]
use_cache: true
cache_from: nexus.khongisa.co.za/midrandbooks:latest
build_args:
- BUILDKIT_INLINE_CACHE=1
custom_labels:
- org.opencontainers.image.source=https://gitea.khongisa.co.za/litecharms/midrandbooks
- org.opencontainers.image.version=1.${DRONE_BUILD_NUMBER}
+1
View File
@@ -1,6 +1,7 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".drone.yml" />
<File Path=".editorconfig" />
<File Path="Dockerfile" />
<File Path="midrandbooks-uat.yml" />
<File Path="README.md" />
+4 -2
View File
@@ -6,18 +6,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["MidrandBookshop.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes />
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<div class="col-12 col-md-6 col-lg-4 mb-4 h-100">
<div class="d-flex flex-column h-100 justify-content-between">
<div class="card border-0 p-4 position-relative book-grid-card d-flex flex-column justify-content-between"
style="background-color: #F1F1F1; border-radius: var(--mb-radius); cursor: pointer; min-height: 280px;"
@onclick="OnCardClick">
<div class="d-flex justify-content-between align-items-center mb-3">
@if (IsNew)
{
<span class="badge rounded-pill px-3 py-2 badge-new-arrival" style="background-color: var(--mb-accent-red); font-weight: 500;">New</span>
}
else
{
<div></div>
}
<div class="d-flex align-items-center gap-2">
<button class="btn bg-white rounded-circle d-flex align-items-center justify-content-center p-2 shadow-sm border-0 btn-cart-icon"
style="width: 32px; height: 32px; transition: all 0.2s ease;"
title="Add to Cart"
data-bs-toggle="tooltip"
data-bs-placement="top"
@onclick="HandleAddToCart" @onclick:stopPropagation>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--mb-text-dark)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
</button>
<button class="btn bg-white rounded-circle d-flex align-items-center justify-content-center p-2 shadow-sm border-0"
style="width: 32px; height: 32px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--mb-text-dark)" stroke-width="2.5">
<line x1="7" y1="17" x2="17" y2="7" />
<polyline points="7,7 17,7 17,17" />
</svg>
</button>
</div>
</div>
<div class="d-flex justify-content-center align-items-center flex-grow-1 my-2">
@if (!string.IsNullOrWhiteSpace(BookImageUrl))
{
<img src="@BookImageUrl" class="img-fluid book-shadow" style="max-height: 240px; object-fit: contain;" alt="@Title" />
}
else
{
<div class="book-spine-fallback bg-dark d-flex align-items-center justify-content-center text-center p-3 text-white-50 shadow-sm"
style="width: 50%; max-width: 140px; aspect-ratio: 2 / 3; border-radius: 6px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.05em; line-height: 1.4; box-shadow: 0 10px 24px rgba(0,0,0,0.16);">
<div style="max-width: 100%; overflow: hidden; word-break: break-word;">
@Category.ToUpper()<br><span class="opacity-50" style="font-size: 0.55rem;">EDITION</span>
</div>
</div>
}
</div>
</div>
<div class="d-flex justify-content-between align-items-start mt-3 mb-2 px-2" style="cursor: pointer;" @onclick="OnCardClick">
<div style="max-width: 72%;">
<span class="fw-medium text-dark d-block text-truncate product-card-title" style="font-size: 0.95rem; line-height: 1.25;">@Title</span>
<span class="text-muted small d-block text-truncate mt-1" style="font-size: 0.8rem;">by @Author</span>
</div>
<span class="font-monospace fw-semibold text-secondary pt-0.5" style="font-size: 0.9rem; white-space: nowrap;">R @Price.ToString("N0")</span>
</div>
</div>
</div>
@@ -0,0 +1,44 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Products;
namespace MidrandBookshop.Components;
public partial class BookCard
{
[Parameter] public long Id { get; set; }
[Parameter] public string Title { get; set; } = string.Empty;
[Parameter] public string Author { get; set; } = string.Empty;
[Parameter] public decimal Price { get; set; }
[Parameter] public string Category { get; set; } = string.Empty;
[Parameter] public bool IsNew { get; set; }
[Parameter] public string BookImageUrl { get; set; } = string.Empty;
[Parameter] public EventCallback OnCardClick { get; set; }
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private AuthorService AuthorService { get; set; } = default!;
[Inject] private BooksService BooksService { get; set; } = default!;
[Inject] private IToastService ToastService { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
private async Task HandleAddToCart()
{
try
{
var bookFetch = await BooksService.GetBookByProductIdAsync(Id, CancellationToken);
var authorFetch = await AuthorService.GetAuthorAsync(bookFetch.Value.AuthorId, CancellationToken);
var productPriceFetch = await ProductService.GetProductPriceAsync(Id, CancellationToken);
CartService.AddItem(productPriceFetch.Value, bookFetch.Value.Product!, authorFetch.Value);
ToastService.ShowSuccess($"Added '{Title}' to your order.", "Cart Changed");
}
catch
{
ToastService.ShowError("Could not update cart. Please try again.");
}
}
}
@@ -0,0 +1,9 @@
.book-shadow {
filter: drop-shadow(5px 10px 15px rgba(0, 0, 0, 0.15)) drop-shadow(1px 2px 4px rgba(0, 0, 0, 0.1));
}
.sm-icon {
width: 14px;
height: 14px;
vertical-align: middle;
}
@@ -1,13 +1,299 @@
@inherits LayoutComponentBase
@using Blazored.Toast
@inherits LayoutComponentBase
@inject NavigationManager Navigation
<div class="page">
<main>
<div class="top-row px-4">
<h2>Midrand Books</h2>
<div class="position-relative vh-100 d-flex flex-column justify-content-between overflow-hidden" style="background-color: #F9F9F9;">
<div class="cart-overlay @(IsCartOpen ? "is-visible" : "")" @onclick="ToggleCart"></div>
<div class="cart-drawer @(IsCartOpen ? "is-open" : "") d-flex flex-column bg-white shadow-lg">
<div class="cart-header d-flex align-items-center justify-content-between p-4 border-bottom">
<h5 class="fw-bold m-0 text-dark tracking-tight" style="font-family: 'Inter', sans-serif; font-size: 1.1rem;">
YOUR CART (@ShoppingCart.Items.Count())
</h5>
<button class="btn btn-sm text-dark p-1 border-0" @onclick="ToggleCart" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<article class="content px-4">
@Body
</article>
</main>
<div class="cart-body flex-grow-1 overflow-y-auto p-4">
@if (!ShoppingCart.Items.Any())
{
<div class="h-100 d-flex flex-column align-items-center justify-content-center text-muted py-5">
<svg class="mb-3 opacity-50" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z M3 6h18 M16 10a4 4 0 0 1-8 0" />
</svg>
<span class="small tracking-wide">Your collection is empty.</span>
</div>
}
else
{
<div class="d-flex flex-column gap-4">
@foreach (var item in ShoppingCart.Items)
{
<div class="cart-item d-flex gap-3 align-items-start pb-3 border-bottom-dashed">
<div class="cart-item-thumb bg-dark text-white-50 d-flex align-items-center justify-content-center px-2 text-center" style="width: 54px; height: 74px; font-size: 0.45rem; letter-spacing: 0.5px;">
@if (!string.IsNullOrWhiteSpace(item.Product!.ImageUrl))
{
<img src="@item.Product!.ImageUrl" class="img-fluid book-shadow" style="max-height: 240px; object-fit: contain;" alt="@item.Product.Name" />
}
else
{
@:[COVER]
}
</div>
<div class="flex-grow-1">
<h6 class="text-dark small fw-bold mb-0 text-truncate" style="max-width: 180px;">@item.Product!.Name</h6>
<p class="text-muted xx-small mb-2">by @($"{item.Author!.Name} {item.Author.LastName}")</p>
<div class="d-flex align-items-center justify-content-between">
<div class="quantity-picker d-flex align-items-center border rounded-pill bg-light">
<button class="btn btn-sm py-0 px-2 text-dark border-0" @onclick="() => ChangeQuantity(item, -1)" type="button">-</button>
<span class="px-1 text-dark fw-medium" style="font-size: 0.75rem;">@ShoppingCart.Items.FirstOrDefault(i => i.Price!.Id == item.Price!.Id)!.Quantity</span>
<button class="btn btn-sm py-0 px-2 text-dark border-0" @onclick="() => ChangeQuantity(item, 1)" type="button">+</button>
</div>
<span class="small fw-semibold text-dark">R @(item.Price!.Amount * item.Quantity)</span>
</div>
</div>
<button class="btn text-muted p-0 border-0 mt-1 align-self-start" style="background: none;" @onclick="() => RemoveFromCart(item)" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
}
</div>
}
</div>
@if (ShoppingCart.Items.Any())
{
<div class="cart-footer p-4 bg-light border-top mt-auto">
<div class="d-flex align-items-center justify-content-between mb-4">
<span class="text-secondary small fw-medium tracking-wider">TOTAL DUE</span>
<span class="text-dark h5 fw-bold mb-0">R @GetCartTotal()</span>
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-dark w-100 rounded-pill py-2.5 fw-medium shadow-sm small tracking-wide" @onclick="RedirectToCheckout" type="button">
Proceed to Checkout
</button>
<button class="btn btn-outline-dark w-100 rounded-pill py-2.5 fw-medium bg-white small tracking-wide" @onclick="RedirectToCart" type="button">
Go to Cart Page
</button>
</div>
</div>
}
</div>
<div class="w-100 position-relative flex-shrink-0" style="z-index: 1020;">
<div class="position-absolute top-0 start-0 overflow-hidden d-none d-md-block" style="z-index: -1; pointer-events: none; opacity: 0.08; transform: translate(-10%, -10%);">
<svg width="480" height="480" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="98" stroke="#1A1A1A" stroke-width="0.25" stroke-dasharray="0.5 1.5" />
<circle cx="100" cy="100" r="92" stroke="#1A1A1A" stroke-width="0.4" />
<circle cx="100" cy="100" r="86" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="2 1" />
<circle cx="100" cy="100" r="80" stroke="#1A1A1A" stroke-width="0.3" />
<path d="M100 20 A80 80 0 0 1 180 100 A80 80 0 0 1 100 180 A80 80 0 0 1 20 100 A80 80 0 0 1 100 20" stroke="#1A1A1A" stroke-width="0.15" />
<path d="M100 20 A80 80 0 0 0 180 100 A80 80 0 0 0 100 180 A80 80 0 0 0 20 100 A80 80 0 0 0 100 20" stroke="#1A1A1A" stroke-width="0.15" />
<line x1="40" y1="100" x2="160" y2="100" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="1 4" />
<line x1="100" y1="40" x2="100" y2="160" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="1 4" />
<g transform="translate(65, 82)" stroke="#1A1A1A" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M16 18 C 16 10, 24 10, 24 18" stroke-width="0.5" />
<line x1="20" y1="10" x2="20" y2="4" />
<path d="M28 18 C 28 14, 34 14, 34 18" stroke-width="0.5" />
<line x1="31" y1="14" x2="31" y2="7" />
<rect x="3" y="8" width="5" height="10" stroke-width="0.5" />
<rect x="11" y="5" width="5" height="13" stroke-width="0.5" />
<rect x="38" y="3" width="7" height="15" stroke-width="0.5" />
<polygon points="38,3 41.5,0 45,3" stroke-width="0.4" />
<rect x="49" y="9" width="6" height="9" stroke-width="0.5" />
<line x1="0" y1="18" x2="70" y2="18" stroke-width="0.75" />
</g>
</svg>
</div>
<nav class="navbar navbar-expand-lg py-3" style="pointer-events: auto;">
<div class="container-fluid px-md-5">
<a class="navbar-brand d-flex align-items-center" href="/" style="transform: scale(1.6); transform-origin: left center; margin-right: 4rem;">
<svg class="me-2" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17c-2.5 0-4.5-1.8-4.5-4s2-4 4.5-4 4.5 1.8 4.5 4-2 4-4.5 4Z" />
<path d="M12 9c2.2 0 4-1.6 4-3.5S14.2 2 12 2 8 3.6 8 5.5 9.8 9 12 9Z" />
<path d="M2 12h20" stroke-width="1" stroke-dasharray="1 2" opacity="0.5" />
</svg>
<span class="fw-bold tracking-tight text-dark" style="font-size: 0.65rem; letter-spacing: 0.8px; font-family: 'Inter', sans-serif;">MIDRAND BOOKS</span>
</a>
<div class="collapse navbar-collapse justify-content-center" id="navbarNav">
<div class="nav-pill-wrapper d-flex align-items-center">
<a class="nav-pill-link active" href="/">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>
Books
</a>
<a class="nav-pill-link" href="/audiobooks">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" /></svg>
Audiobooks
</a>
<a class="nav-pill-link" href="/ebooks">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12.01" y2="18" /></svg>
E-Books
</a>
</div>
</div>
<div class="d-flex align-items-center gap-2 position-relative">
<div class="search-input-container d-flex align-items-center @(IsSearchActive ? "is-active" : "")">
<input type="text"
class="form-control form-control-sm rounded-pill border-light-subtle px-3 text-dark bg-light custom-search-field"
placeholder="Search by ISBN, Author, Title..."
@bind="SearchInputBuffer"
@bind:event="oninput"
@onkeydown="HandleSearchKeyDown" />
</div>
<button class="btn btn-link text-dark p-1 me-2 border-0 header-action-btn"
@onclick="ToggleGlobalSearch"
type="button"
aria-label="Toggle Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button class="btn d-flex align-items-center justify-content-center p-2 position-relative border-0"
style="background: transparent; color: #1A1A1A; width: 36px; height: 36px;"
@onclick="ToggleCart"
type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z M3 6h18 M16 10a4 4 0 0 1-8 0" />
</svg>
@if (ShoppingCart.Items.Any())
{
<span class="cart-badge">@ShoppingCart.Items.Count</span>
}
</button>
<div class="dropdown header-account-dropdown">
<button class="btn btn-sm d-flex align-items-center gap-1.5 p-1 text-dark border-0 dropdown-toggle no-caret"
type="button"
id="accountDropdownMenu"
data-bs-toggle="dropdown"
aria-expanded="false">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span class="d-none d-md-inline font-monospace text-uppercase" style="font-size: 0.8rem; letter-spacing: 0.05em;">Account</span>
</button>
<ul class="dropdown-menu dropdown-menu-end brand-dropdown-pane p-0 m-0" aria-labelledby="accountDropdownMenu">
<AuthorizeView>
<Authorized>
<li class="dropdown-header-identity p-3 border-bottom bg-light">
<span class="d-block text-muted text-uppercase font-monospace xx-small" style="font-size: 0.6rem; letter-spacing: 0.1em;">Active Identity</span>
<span class="fw-bold text-dark text-truncate d-block" style="font-size: 0.85rem;">@(context.User.FindFirst("given_name")?.Value ?? context.User.Identity?.Name ?? "Reader")</span>
</li>
<li>
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-dark d-flex align-items-center gap-2" href="/account">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
My Orders
</a>
</li>
<li>
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-dark d-flex align-items-center gap-2" href="https://sts.security.khongisa.co.za/Manage/Index?returnUrl=https://midrandbooks.co.za/account" target="_blank">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
Security Center
</a>
</li>
<li><hr class="dropdown-divider m-0" /></li>
<li>
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-danger d-flex align-items-center gap-2" href="javascript:void(0)" @onclick:preventDefault @onclick="HandleLogout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</a>
</li>
</Authorized>
<NotAuthorized>
<li>
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-dark d-flex align-items-center gap-2" href="javascript:void(0)" @onclick:preventDefault @onclick="HandleLogin">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>
Sign In
</a>
</li>
<li>
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-muted d-flex align-items-center gap-2" href="javascript:void(0)" @onclick:preventDefault @onclick="RedirectToAccount">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
Order History
</a>
</li>
</NotAuthorized>
</AuthorizeView>
</ul>
</div>
</div>
</div>
</nav>
</div>
<div class="w-100 flex-grow-1 overflow-y-auto d-flex flex-column justify-content-between">
<main class="position-relative" style="z-index: 5;">
<CascadingValue Value="GlobalSearchQuery">
@Body
</CascadingValue>
</main>
<footer class="custom-site-footer border-top mt-auto">
<div class="container-fluid px-md-5">
<div class="row g-4 pb-4 justify-content-between footer-content-section">
<div class="col-12 col-md-5">
<span class="footer-brand-title d-block mb-2">MIDRAND BOOKS</span>
<p class="footer-desc mb-3">An architectural destination for curated print, thoughtful reading culture, and global design perspectives.</p>
<div class="d-flex flex-column gap-2">
<a href="mailto:info@midrandbooks.co.za" class="footer-contact-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></svg>
info@midrandbooks.co.za
</a>
<a href="tel:+27872650198" class="footer-contact-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 .2.81.7A2 2 0 0 1 22 16.92z" /></svg>
+27 87 265 9463
</a>
</div>
</div>
<div class="col-12 col-md-6 col-lg-5 text-start">
<div class="row row-cols-2 justify-content-md-end g-3">
<div style="max-width: 160px;">
<span class="footer-section-heading d-block mb-2">PLATFORM</span>
<ul class="list-unstyled d-flex flex-column gap-1.5 m-0">
<li><a href="/about" class="footer-nav-link">About Us</a></li>
<li><a href="/" class="footer-nav-link">Browse Catalog</a></li>
<li><a href="/contact" class="footer-nav-link">Contact Us</a></li>
</ul>
</div>
<div style="max-width: 210px;">
<span class="footer-section-heading d-block mb-2">ENTERPRISE</span>
<ul class="list-unstyled d-flex flex-column gap-1.5 m-0">
<li class="footer-meta-item d-flex align-items-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /></svg>
Lite Charms (PTY) Ltd
</li>
<li><a href="/terms" class="footer-nav-link">Terms & Conditions</a></li>
<li><a href="/privacy" class="footer-nav-link">Privacy Policy</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="row align-items-center pt-3 footer-copyright-section">
<div class="col-12 text-center text-md-start">
<span class="footer-copyright">
&copy; @DateTime.Now.Year Midrand Books. All rights reserved.
</span>
</div>
</div>
</div>
</footer>
</div>
</div>
<BlazoredToasts />
@@ -0,0 +1,170 @@
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Layout;
public partial class MainLayout(CartService cartService) : IDisposable
{
[Inject] public IToastService ToastService { get; set; } = default!;
private Cart ShoppingCart => cartService.ShoppingCart;
private string SearchInputBuffer { get; set; } = string.Empty;
private string GlobalSearchQuery { get; set; } = string.Empty;
private bool IsSearchActive { get; set; } = false;
private bool IsCartOpen { get; set; } = false;
protected override async Task OnInitializedAsync()
{
Navigation.LocationChanged += OnLocationChanged;
cartService.OnCartChanged += CartService_OnCartChanged;
if (cartService.ShoppingCart.Items.Count == 0)
await cartService.LoadCartFromStorageAsync();
SyncSearchQueryFromUrl();
}
private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged);
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
SyncSearchQueryFromUrl();
StateHasChanged();
}
private void SyncSearchQueryFromUrl()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var queryParameters = QueryHelpers.ParseQuery(uri.Query);
if (queryParameters.TryGetValue("q", out var queryVal) && !string.IsNullOrWhiteSpace(queryVal))
{
GlobalSearchQuery = queryVal.ToString();
SearchInputBuffer = GlobalSearchQuery;
IsSearchActive = true;
}
else
{
GlobalSearchQuery = string.Empty;
SearchInputBuffer = string.Empty;
IsSearchActive = false;
}
}
private void ToggleGlobalSearch()
{
if (!IsSearchActive)
IsSearchActive = true;
else
CommitSearchNavigation();
}
private void HandleSearchKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") CommitSearchNavigation();
}
private void CommitSearchNavigation()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var queryParameters = QueryHelpers.ParseQuery(uri.Query);
var newRouteParams = new Dictionary<string, object?>();
foreach (var param in queryParameters)
{
if (param.Key != "q")
{
newRouteParams[param.Key] = param.Value.ToString();
}
}
if (!string.IsNullOrWhiteSpace(SearchInputBuffer))
newRouteParams["q"] = SearchInputBuffer.Trim();
else
newRouteParams["q"] = null;
var baseRoute = uri.AbsolutePath.StartsWith("/product/", StringComparison.OrdinalIgnoreCase) ? "/" : uri.AbsolutePath;
var updatedUri = Navigation.GetUriWithQueryParameters(baseRoute, newRouteParams);
Navigation.NavigateTo(updatedUri);
}
private void ToggleCart() => IsCartOpen = !IsCartOpen;
private async Task ChangeQuantity(CartItem item, int delta)
{
var peekQuantity = item.Quantity + delta;
if (peekQuantity < 1) return;
cartService.UpdateQuantity(item.Price!.Id, delta);
await cartService.SaveCartToStorageAsync();
}
private async Task RemoveFromCart(CartItem item)
{
cartService.RemoveOneItem(item.Price!.Id);
await cartService.SaveCartToStorageAsync();
ToastService.ShowSuccess($"Removed {item.Product!.Name} from cart", "Cart Changed");
}
private decimal GetCartTotal() => ShoppingCart?.TotalAmount ?? 0.00m;
private async Task RedirectToCart()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/cart");
}
private async Task RedirectToCheckout()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/checkout", forceLoad: true);
}
private async Task RedirectToAccount()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/account", forceLoad: true);
}
private async Task HandleLogin()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/login", forceLoad: true);
}
private async Task HandleLogout()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/logout", forceLoad: true);
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
cartService.OnCartChanged -= CartService_OnCartChanged;
GC.SuppressFinalize(this);
}
}
@@ -1,98 +1,225 @@
.page {
position: relative;
display: flex;
flex-direction: column;
.sticky-top {
position: -webkit-sticky !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
}
main {
flex: 1;
.cart-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 1040;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
.cart-overlay.is-visible {
opacity: 1;
pointer-events: auto;
}
.cart-drawer {
position: fixed;
top: 0;
right: -420px;
width: 100%;
max-width: 400px;
height: 100vh;
z-index: 1050;
pointer-events: none;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
.cart-drawer.is-open {
transform: translateX(-420px);
pointer-events: auto;
}
.cart-badge {
position: absolute;
top: 2px;
right: 2px;
background-color: #1A1A1A;
color: #FFF;
font-size: 0.62rem;
font-weight: 700;
border-radius: 50%;
width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
.xx-small {
font-size: 0.68rem;
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.border-bottom-dashed {
border-bottom: 1px dashed rgba(0, 0, 0, 0.12);
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
.quantity-picker {
padding: 2px 4px;
}
.quantity-picker button {
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
}
.quantity-picker button:hover {
background-color: rgba(0,0,0,0.05);
border-radius: 50%;
}
.custom-site-footer {
width: 100%;
z-index: 1000;
position: relative;
z-index: 10;
background-color: #1A1A1A !important;
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
padding: 3rem 0 2rem 0;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
.footer-content-section {
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
}
.footer-brand-title {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 1px;
color: #FFFFFF !important;
}
.footer-desc {
font-size: 0.8rem;
line-height: 1.6;
max-width: 360px;
color: #A0AEC0 !important;
}
.footer-contact-link {
text-decoration: none;
font-size: 0.8rem;
color: #A0AEC0 !important;
display: inline-flex;
align-items: center;
gap: 8px;
transition: color 0.2s ease;
}
.footer-contact-link:hover {
color: #FFFFFF !important;
}
.footer-section-heading {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 1.2px;
color: #FFFFFF !important;
}
.footer-nav-link {
text-decoration: none;
font-size: 0.8rem;
color: #A0AEC0 !important;
transition: color 0.2s ease;
}
.footer-nav-link:hover {
color: #FFFFFF !important;
}
.footer-meta-item {
font-size: 0.8rem;
font-weight: 500;
color: #FFFFFF !important;
}
.footer-copyright-section {
color: #718096 !important;
}
.footer-copyright {
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.2px;
}
.btn-back-to-top {
position: absolute;
bottom: 2rem;
right: 2rem;
width: 42px !important;
min-width: 42px !important;
max-width: 42px !important;
height: 42px !important;
border-radius: 50% !important;
background-color: #1A1A1A;
color: #FFFFFF;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1010;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.btn-back-to-top:hover {
background-color: #333333;
transform: translateY(-2px);
}
.btn-back-to-top:active {
transform: translateY(0);
}
.scroll-container {
scroll-behavior: smooth;
}
.no-caret::after {
display: none !important;
}
.brand-dropdown-pane {
border-radius: 0px !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
min-width: 190px;
z-index: 1050;
}
.brand-dropdown-pane .dropdown-item {
font-size: 0.72rem;
letter-spacing: 0.02em;
transition: background-color 0.15s ease, color 0.15s ease;
}
.brand-dropdown-pane .dropdown-item:hover {
background-color: #1A1A1A !important;
color: #FFFFFF !important;
}
.brand-dropdown-pane .dropdown-item.text-danger:hover {
background-color: #DC3545 !important;
color: #FFFFFF !important;
}
.dropdown-header-identity {
user-select: none;
}
@@ -1,31 +1,58 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
<div class="glassine-page-jacket"></div>
<div class="literary-sync-strip">
<div class="strip-container">
<div class="sync-status-indicator">
<svg class="literary-helix-loader" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path class="book-base-spine" d="M12 5v14M12 5c-1.5-2-4.5-2-7-2H2v14h3c2.5 0 5.5 0 7 2M12 5c1.5-2 4.5-2 7-2h3v14h-3c-2.5 0-5.5 0-7 2" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
<path class="flipping-leaf-vector" d="M12 5c-1-1.5-3-2-5-2H3v14h4c2 0 4 .5 5 2" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="font-monospace text-uppercase small-tracking">Catalog Circuit Sync</span>
</div>
<div class="sync-message-body">
<span class="components-reconnect-first-attempt-visible">
Connection interrupted. Re-indexing active reading stack...
</span>
<span class="components-reconnect-repeated-attempt-visible">
Sync delayed. Re-aligning database archives in <span id="components-seconds-to-next-attempt" class="fw-bold font-monospace">0</span>s...
</span>
<span class="components-reconnect-failed-visible text-crimson">
Archival path blocked. Automated sync offline.
</span>
<span class="components-pause-visible">
Reading layout paused by host environment node.
</span>
<span class="components-resume-failed-visible text-crimson">
State alignment broken.
</span>
</div>
<div class="sync-action-node">
<button id="components-reconnect-button" class="btn-strip-action components-reconnect-failed-visible">
<span>Retry Sync</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg>
</button>
<button id="components-resume-button" class="btn-strip-action components-pause-visible components-resume-failed-visible">
<span>Reload Session</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
</dialog>
@@ -1,157 +1,218 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
/* ==========================================================================
Midrand Books — Glassine Architectural Veil & Ribbon Strip
========================================================================== */
/* --- Native Dialog Element Layout Overrides --- */
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
margin: 0;
padding: 0;
border: none;
background: transparent;
z-index: 99999;
overflow: hidden;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
/* Remove default browser modal backdrop blockout to allow custom layering below */
#components-reconnect-modal::backdrop {
background: transparent;
}
100% {
/* --- Glassine Page Jacket Layer ---
Frosted translucent shield that preserves context visibility while blocking mouse actions
*/
.glassine-page-jacket {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(251, 251, 250, 0.4); /* Premium warm paper tint */
backdrop-filter: blur(5px); /* Elegant frosted glass sweep */
cursor: wait;
animation: glassFadeIn 0.35s ease-out forwards;
}
@keyframes glassFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(5px);
}
}
/* --- The Sliding Ribbon Banner --- */
.literary-sync-strip {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: #FFFFFF;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02);
padding: 1.1rem 2rem;
box-sizing: border-box;
z-index: 2;
/* Animation kinematics: smooth physical drop slide */
transform: translateY(-100%);
animation: stripSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes stripSlideDown {
to {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
/* --- Ribbon Layout Matrix Grid --- */
.strip-container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
gap: 2.5rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
/* Left Node Ticker Elements & Animated Vector */
.sync-status-indicator {
display: flex;
align-items: center;
gap: 0.85rem;
border-right: 1px solid rgba(0, 0, 0, 0.08);
padding-right: 2rem;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
.small-tracking {
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
letter-spacing: 0.12em;
color: #111111;
font-weight: 600;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
/* Animated Book Helix SVG */
.literary-helix-loader {
width: 18px;
height: 18px;
color: #111111;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.flipping-leaf-vector {
transform-origin: 12px 12px;
animation: svgLeafFlip 1.6s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
@keyframes svgLeafFlip {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
transform: scaleX(1);
opacity: 1;
}
50% {
transform: scaleX(0);
opacity: 0.3;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
transform: scaleX(-1);
opacity: 0;
}
}
/* Center Node Text Content */
.sync-message-body {
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
color: #333333;
font-style: italic;
}
.text-crimson {
color: #A34843;
font-style: normal;
font-weight: 500;
}
/* Right Node Fine-Press Button Trigger */
.btn-strip-action {
background: #111111;
color: #FFFFFF;
border: 1px solid #111111;
padding: 0.45rem 1.25rem;
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-strip-action:hover {
background: transparent;
color: #111111;
}
.btn-strip-action svg {
transition: transform 0.2s ease;
}
.btn-strip-action:hover svg {
transform: rotate(45deg);
}
/* --- Display Mechanics Matrix Controllers --- */
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible {
display: none !important;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: inline-block !important;
}
#components-reconnect-modal.components-reconnect-failed .btn-strip-action,
#components-reconnect-modal.components-reconnect-paused .btn-strip-action,
#components-reconnect-modal.components-reconnect-resume-failed .btn-strip-action {
display: inline-flex !important;
}
/* Tablet Parameters Response Collapse Matrix */
@media (max-width: 768px) {
.strip-container {
grid-template-columns: 1fr;
gap: 0.65rem;
text-align: center;
}
.sync-status-indicator {
border-right: none;
padding-right: 0;
justify-content: center;
}
.sync-action-node {
margin-top: 0.25rem;
}
}
@@ -1,4 +1,3 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
@@ -24,14 +23,8 @@ async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
@@ -40,7 +33,6 @@ async function retry() {
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
@@ -60,4 +52,4 @@ async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
}
@@ -0,0 +1,68 @@
@page "/about"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="editorial-page-container py-5">
<header class="editorial-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Our Story & Vision</span>
<h1 class="editorial-main-title fw-bold">For the Love of the Written Word</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is an independent literary imprint and online bookstore operated by <strong>Lite Charms (Pty) Ltd</strong>.
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="editorial-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("about-bookshelf")' class="index-btn text-start">1. The Bookshelf</button>
<button @onclick='() => ScrollToSection("about-publishing")' class="index-btn text-start">2. Independent Publishing</button>
<button @onclick='() => ScrollToSection("about-community")' class="index-btn text-start">3. Our Community</button>
</div>
</div>
<div class="col-lg-9">
<article class="editorial-article-body">
<section id="about-bookshelf" class="editorial-section mb-5">
<h3 class="section-title">1. A Curated Space for Readers</h3>
<p>
At Midrand Books, we believe that an online bookstore should feel just as warm, inspiring, and intentional as a physical corner shop. We arent interested in mass-market commercial algorithms; we are interested in books that leave a mark.
</p>
<p>
Operated proudly under Lite Charms (Pty) Ltd, our storefront is designed to showcase beautiful storytelling, critical histories, academic research, and deep technical disciplines. We source fine press editions and trusted literary brands, ensuring that every book we package and deliver across South Africa feels special from the moment it reaches your hands.
</p>
</section>
<section id="about-publishing" class="editorial-section mb-5">
<h3 class="section-title">2. Empowering New & Independent Voices</h3>
<p>
Beyond our role as a bookseller, our truest passion lies in cultivating the next chapter of South African literature. We know how daunting the modern publishing landscape can be for emerging storytellers, experts, and independent creators.
</p>
<p>
That is why we have integrated custom distribution channels into our platform to help self-publishers and local authors get their manuscripts beautifully styled, correctly indexed, and directly in front of avid readers. Side-by-side with heritage publishing houses, we champion the creative freedom of the indie writer.
</p>
</section>
<section id="about-community" class="editorial-section mb-5 protective-credo-callout">
<h3 class="section-title text-muted-serif">3. Our Creative Guarantee</h3>
<p>
Whether you are an established global author footprint looking for a seamless marketplace, a first-time writer ready to publish your debut book, or a reader hunting for your next great obsession, we welcome you. Midrand Books operates in full compliance with the South African Companies Act, ensuring that your data, transactions, and intellectual property are kept fully safe.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,85 @@
/* ==========================================================================
Midrand Books — About Page Fine Press Styles
========================================================================== */
.editorial-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.editorial-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Controls --- */
.editorial-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Narrative Typography --- */
.editorial-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem;
}
.text-muted-serif {
color: #555555;
}
.editorial-section p {
margin-bottom: 1.2rem;
}
.protective-credo-callout {
background-color: #FAFAFA;
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 2rem;
border-radius: 8px;
}
@@ -0,0 +1,240 @@
@page "/account"
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@rendermode InteractiveServer
@attribute [Authorize]
<div class="account-page-container py-5">
<header class="account-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Customer Dashboard</span>
<h1 class="account-main-title fw-bold">My Account</h1>
</header>
<div class="row g-5">
<div class="col-lg-3">
<div class="nav flex-column account-nav-stack gap-1" role="tablist">
<button class="nav-link active text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#orders" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"></path><line x1="3" y1="6" x2="21" y2="6"></line><path d="M16 10a4 4 0 0 1-8 0"></path></svg>
<span>Order History</span>
</button>
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#shipping" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
<span>Shipping Address</span>
</button>
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#profile" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
<span>Profile Settings</span>
</button>
<hr class="my-3 opacity-10" />
<button class="nav-link nav-logout text-danger text-start d-flex align-items-center gap-2" @onclick="TriggerLogout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>Logout Account</span>
</button>
</div>
</div>
<div class="col-lg-9">
<AuthorizeView>
<Authorized>
<div class="tab-content account-panels-deck">
<div class="tab-pane fade show active" id="orders" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4">Order History</h5>
@if (orderHistory == null || !orderHistory.Any())
{
<div class="text-center py-5 border rounded-3 bg-light bg-opacity-50">
<p class="text-muted small mb-0">You haven't placed any orders with us yet.</p>
</div>
}
else
{
<div class="orders-stack d-flex flex-column gap-4">
@foreach (var order in orderHistory)
{
<div class="premium-order-card p-4 border rounded-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 pb-3 border-b-dashed mb-3">
<div>
<span class="font-monospace text-dark fw-bold d-block h6 mb-1">@order.OrderId</span>
<small class="text-muted d-block mb-1">Ordered on @order.OrderDate.ToString("dd MMMM yyyy")</small>
<small class="text-secondary d-flex align-items-center gap-1" style="font-size: 0.8rem;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
<span>Shipped to: <span class="fw-semibold text-dark">@order.ShippingAddressName</span></span>
</small>
</div>
<div class="text-md-end d-flex flex-column align-items-md-end gap-2">
<div class="d-flex align-items-center gap-1.5 flex-wrap justify-content-md-end">
<span class="badge status-badge-base @GetPaymentStatusClass(order.PaymentStatus)">
Pay: @order.PaymentStatus
</span>
<span class="badge status-badge-base @GetStatusClass(order.Status)">
Logistics: @order.Status
</span>
</div>
@if (order.PaymentStatus?.ToLower() == "paid")
{
<button class="btn btn-outline-dark btn-premium-sm font-monospace text-uppercase d-inline-flex align-items-center gap-1.5 py-1 px-2.5"
style="font-size: 0.7rem;"
@onclick="() => DownloadInvoice(order.OrderId)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span>Invoice</span>
</button>
}
</div>
</div>
<div class="order-manifest-details d-flex align-items-center justify-content-between gap-4 py-1">
<div class="item-meta">
<h6 class="text-dark fw-bold mb-0 small" style="line-height: 1.4;">@order.ProductTitle</h6>
</div>
<div class="item-value text-end flex-shrink-0">
<span class="font-monospace text-dark fw-bold d-block">R @order.Total.ToString("F2")</span>
<small class="text-muted extra-small font-monospace">VAT Inclusive</small>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<div class="tab-pane fade" id="shipping" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<div class="d-flex justify-content-between align-items-baseline mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-0">Saved Addresses</h5>
@if (!showAddForm && editingAddress == null)
{
<button class="btn btn-outline-dark btn-premium-sm font-monospace text-uppercase" @onclick="() => showAddForm = true">
Add New Address
</button>
}
</div>
@if (showAddForm || editingAddress != null)
{
<div class="premium-interactive-form p-4 border rounded-3 mb-4 bg-light bg-opacity-20 animate-fade-in">
<h6 class="fw-bold text-dark mb-3">@(editingAddress != null ? "Modify Curated Address" : "Register Destination Address")</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Address Name Label</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., Home" @bind="newAddressName" />
</div>
<div class="col-md-6">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Postal Routing Code</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., 1685" @bind="newPostalCode" />
</div>
<div class="col-12">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Street Address Lines</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., 12 Main Road" @bind="newStreetAddress" />
</div>
<div class="col-md-12">
<label class="form-label extra-small font-monospace text-uppercase text-muted">City / Region</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., Midrand" @bind="newCity" />
</div>
<div class="col-12 d-flex flex-wrap gap-4 py-2 border-y my-2 bg-white px-3 rounded border">
<div class="form-check d-flex align-items-center gap-2 m-0">
<input type="checkbox" class="form-check-input custom-box-tick m-0" id="isBillingCheck" @bind="isBilling" />
<label class="form-check-label context-clickable small fw-medium text-dark" for="isBillingCheck">Default Billing Endpoint</label>
</div>
<div class="form-check d-flex align-items-center gap-2 m-0">
<input type="checkbox" class="form-check-input custom-box-tick m-0" id="isShippingCheck" @bind="isShipping" />
<label class="form-check-label context-clickable small fw-medium text-dark" for="isShippingCheck">Default Fulfillment Endpoint</label>
</div>
</div>
<div class="col-12 d-flex justify-content-end gap-2 mt-3">
<button class="btn btn-clean-cancel font-monospace text-uppercase small" @onclick="CancelAddressActions">Cancel</button>
<button class="btn btn-dark px-4 py-2 text-uppercase font-monospace small" @onclick="SaveAddress">Save Address Details</button>
</div>
</div>
</div>
}
<div class="row g-4">
@foreach (var addr in savedAddresses)
{
<div class="col-md-6">
<div class="address-curated-card p-4 border rounded-3 position-relative d-flex flex-column h-100 bg-white @(addr.IsPrimary ? "border-dark shadow-sm" : "opacity-90")">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="fw-bold text-dark font-monospace tracking-wide text-uppercase" style="font-size: 0.82rem;">@addr.Name</span>
<div class="form-check d-flex align-items-center gap-1.5 p-0 m-0">
<input type="radio" class="form-check-input custom-box-tick m-0" name="primaryAddr" id="@($"primary-{addr.Id}")" checked="@addr.IsPrimary" @onchange="(e) => SetPrimary(addr, e)" />
<label class="form-check-label extra-small text-muted font-monospace text-uppercase context-clickable ms-1" for="@($"primary-{addr.Id}")">Primary</label>
</div>
</div>
<div class="address-body-text text-muted mb-4 mt-1 flex-grow-1" style="font-size: 0.88rem; line-height: 1.6;">
<span class="d-block text-dark fw-medium">@addr.Street</span>
<span class="d-block">@addr.City</span>
<span class="font-monospace text-secondary extra-small d-block mt-1">ZA-@addr.PostalCode</span>
</div>
<div class="address-metadata-badges d-flex flex-wrap gap-1 mb-3">
@if (addr.IsBilling)
{
<span class="badge bg-light text-secondary font-monospace tracking-wide border text-uppercase extra-small px-2 py-1">Billing</span>
}
@if (addr.IsShipping)
{
<span class="badge bg-light text-dark font-monospace tracking-wide border border-secondary text-uppercase extra-small px-2 py-1">Shipping</span>
}
</div>
<div class="address-actions-row border-top-dashed pt-3 d-flex gap-3 justify-content-end mt-auto">
<button class="btn-action-trigger text-uppercase font-monospace extra-small text-muted border-0 bg-transparent" @onclick="() => EditAddress(addr)">Edit</button>
<button class="btn-action-trigger text-uppercase font-monospace extra-small text-danger border-0 bg-transparent" @onclick="() => DeleteAddress(addr)">Delete</button>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="tab-pane fade" id="profile" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4">Profile Settings</h5>
<div class="profile-hero-banner mb-4 d-flex align-items-center justify-content-between p-4 border rounded-3 bg-light bg-opacity-20 flex-wrap gap-3">
<div class="hero-text-content">
<div class="meta-tag font-monospace text-uppercase text-muted extra-small tracking-wider mb-1">Active Identity</div>
<h5 class="fw-bold text-dark mb-1 h6">@User?.Identity?.Name</h5>
<p class="text-muted small mb-0 font-monospace extra-small opacity-75">Secure Connection Authorized</p>
</div>
<span class="badge rounded-pill bg-success bg-opacity-10 text-success border border-success border-opacity-20 font-monospace px-3 py-1.5 small text-uppercase tracking-wide">
Verified
</span>
</div>
<div class="card p-5 text-center bg-white border rounded-3 shadow-sm my-4">
<div class="mb-4 text-muted opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h5 class="fw-bold text-dark mb-2 h6">Centralized Identity Node Settings</h5>
<p class="text-muted small mx-auto mb-4" style="max-width: 480px; line-height: 1.5;">
For your structural protection, password alterations, account recovery preferences, cross-tenant factors, and core credential manifests are handled through our global Identity Node security layer.
</p>
<a href="https://sts.security.khongisa.co.za/Manage/Index?returnUrl=https://midrandbooks.co.za/account"
target="_blank"
rel="noopener noreferrer"
class="btn btn-dark rounded-pill px-4 py-2.5 btn-sm font-monospace text-uppercase tracking-wider d-inline-flex align-items-center gap-2 mx-auto"
style="font-size: 0.75rem;">
<span>Access Central Security Center</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
</a>
</div>
</div>
</div>
</div>
</Authorized>
</AuthorizeView>
</div>
</div>
</div>
@@ -0,0 +1,172 @@
namespace MidrandBookshop.Components.Pages;
public partial class Account : ComponentBase
{
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
private ClaimsPrincipal? User { get; set; }
private bool showAddForm = false;
private AddressItem? editingAddress = null;
private string newAddressName = "";
private string newStreetAddress = "";
private string newCity = "";
private string newPostalCode = "";
private bool isBilling, isShipping;
private List<OrderItem> orderHistory = new()
{
// 1. Delivered + Paid (Green Mapping Rules)
new OrderItem { OrderId = "#MB-2026-8712", ProductId = "mastering-css-isolation", ProductTitle = "Mastering CSS Isolation in Modern .NET Web Applications Architecture", OrderDate = new DateTime(2026, 4, 14), ShippingAddressName = "Midrand Warehouse", Status = "Delivered", PaymentStatus = "Paid", Total = 890.00 },
// 2. Shipped + Paid (Amber Logistics + Green Payment Rules)
new OrderItem { OrderId = "#MB-2026-9481", ProductId = "introduction-to-blazor", ProductTitle = "Introduction to Blazor WebAssembly Framework Development", OrderDate = new DateTime(2026, 5, 20), ShippingAddressName = "Home Address", Status = "Shipped", PaymentStatus = "Paid", Total = 720.00 },
// 3. Unshipped + Abandoned/Unpaid (Muted Grey / Soft Red Rules — Hides Invoice Button)
new OrderItem { OrderId = "#MB-2026-1034", ProductId = "csharp-functional-paradigms", ProductTitle = "Advanced Functional Architecture & Monadic Paradigms in Modern C#", OrderDate = new DateTime(2026, 6, 11), ShippingAddressName = "Home Address", Status = "Unshipped", PaymentStatus = "Unpaid", Total = 650.00 }
};
private List<AddressItem> savedAddresses = new()
{
new AddressItem { Id = 1, Name = "Home Address", Street = "12 Main Road", City = "Midrand", PostalCode = "1685", IsBilling = true, IsShipping = true, IsPrimary = true },
new AddressItem { Id = 2, Name = "Midrand Warehouse", Street = "Corner of Church & Third Roads", City = "Midrand", PostalCode = "1685", IsBilling = false, IsShipping = false, IsPrimary = false }
};
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState?.User;
}
private void DownloadInvoice(string orderId)
{
Navigation.NavigateTo($"/api/invoices/download/{orderId.Replace("#", "")}", forceLoad: true);
}
private string GetStatusClass(string status) => status?.ToLower() switch
{
"delivered" => "status-delivered", // Green
"shipped" => "status-shipped", // Amber
_ => "status-processing" // Muted Architectural Dark Grey
};
private string GetPaymentStatusClass(string paymentStatus) => paymentStatus?.ToLower() switch
{
"paid" => "pay-paid", // Green
"refunded" => "pay-refunded", // Grey
_ => "pay-pending" // Red Alert Tone
};
private void EditAddress(AddressItem addr)
{
editingAddress = addr;
showAddForm = false;
newAddressName = addr.Name;
newStreetAddress = addr.Street;
newCity = addr.City;
newPostalCode = addr.PostalCode;
isBilling = addr.IsBilling;
isShipping = addr.IsShipping;
}
private void CancelAddressActions()
{
showAddForm = false;
editingAddress = null;
ClearFormFields();
}
private void ClearFormFields()
{
newAddressName = "";
newStreetAddress = "";
newCity = "";
newPostalCode = "";
isBilling = false;
isShipping = false;
}
private void SaveAddress()
{
if (string.IsNullOrWhiteSpace(newAddressName) || string.IsNullOrWhiteSpace(newStreetAddress)) return;
if (editingAddress == null)
{
var nextId = savedAddresses.Any() ? savedAddresses.Max(a => a.Id) + 1 : 1;
var newAddr = new AddressItem
{
Id = nextId,
Name = newAddressName,
Street = newStreetAddress,
City = newCity,
PostalCode = newPostalCode,
IsBilling = isBilling,
IsShipping = isShipping,
IsPrimary = !savedAddresses.Any()
};
savedAddresses.Add(newAddr);
}
else
{
var target = savedAddresses.FirstOrDefault(a => a.Id == editingAddress.Id);
if (target != null)
{
target.Name = newAddressName;
target.Street = newStreetAddress;
target.City = newCity;
target.PostalCode = newPostalCode;
target.IsBilling = isBilling;
target.IsShipping = isShipping;
}
editingAddress = null;
}
showAddForm = false;
ClearFormFields();
}
private void DeleteAddress(AddressItem addr)
{
if (editingAddress?.Id == addr.Id) editingAddress = null;
savedAddresses.Remove(addr);
if (addr.IsPrimary && savedAddresses.Any()) savedAddresses.First().IsPrimary = true;
}
private void SetPrimary(AddressItem target, ChangeEventArgs e)
{
var isChecked = (bool)(e.Value ?? false);
if (isChecked)
{
foreach (var addr in savedAddresses) addr.IsPrimary = (addr.Id == target.Id);
}
else target.IsPrimary = false;
}
private void TriggerLogout()
{
Navigation.NavigateTo("/logout", forceLoad: true);
}
public class AddressItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public bool IsBilling { get; set; }
public bool IsShipping { get; set; }
public bool IsPrimary { get; set; }
}
public class OrderItem
{
public string OrderId { get; set; } = "";
public string ProductId { get; set; } = "";
public string ProductTitle { get; set; } = "";
public DateTime OrderDate { get; set; }
public string ShippingAddressName { get; set; } = "";
public string Status { get; set; } = "";
public string PaymentStatus { get; set; } = "Pending";
public double Total { get; set; }
}
}
@@ -0,0 +1,235 @@
/* ==========================================================================
Curated Architecture Dashboard Style Matrix
========================================================================== */
.account-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.account-main-title {
font-size: 2.25rem;
letter-spacing: -0.03em;
color: #111111;
}
.tracking-wider {
letter-spacing: 0.08em;
}
.extra-small {
font-size: 0.72rem !important;
}
/* --- Left Sidebar Architectural Pillar Controls --- */
.account-nav-stack .nav-link {
color: #555555;
border-radius: 8px;
padding: 0.8rem 1rem;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.2s ease;
border: 1px solid transparent;
background: transparent !important;
}
.account-nav-stack .nav-link.active {
background-color: #111111 !important;
color: #FFFFFF !important;
font-weight: 600;
}
.account-nav-stack .nav-link:hover:not(.active):not(.nav-logout) {
color: #111111;
background-color: rgba(0, 0, 0, 0.04) !important;
transform: translateX(2px);
}
.account-nav-stack .nav-logout:hover {
background-color: #FFF5F5 !important;
color: #DC3545 !important;
}
/* --- Main Tabbed Layout Container Content Structures --- */
.panel-card-wrapper {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 2.2rem;
}
.panel-section-title {
font-size: 0.9rem;
color: #666666;
}
/* --- Order History Structured Panel Cards --- */
.premium-order-card {
transition: transform 0.2s ease, border-color 0.2s ease;
}
.premium-order-card:hover {
border-color: rgba(0, 0, 0, 0.15) !important;
}
.border-b-dashed {
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
/* Minimalist Curated Logistics and Payment Badges Matrix */
.status-badge-base {
font-family: var(--bs-font-monospace);
font-size: 0.65rem !important;
letter-spacing: 0.05em;
padding: 0.3rem 0.6rem !important;
border-radius: 4px !important;
font-weight: 600;
}
/* Fulfillment Matrix Colors */
.status-delivered {
background-color: #E2F0D9 !important; /* Soft Green Match */
color: #385723 !important;
border: 1px solid rgba(56, 87, 35, 0.15);
}
.status-shipped {
background-color: #FFF3CD !important; /* Warm Gold Amber */
color: #856404 !important;
border: 1px solid rgba(133, 100, 4, 0.12);
}
.status-processing {
background-color: #F2F2F2 !important; /* Architectural Neutral Muted Grey */
color: #595959 !important;
border: 1px dashed rgba(0, 0, 0, 0.12);
}
/* Financial Matrix Colors */
.pay-paid {
background-color: #E2F0D9 !important; /* Soft Green Match */
color: #385723 !important;
border: 1px solid rgba(56, 87, 35, 0.15);
}
.pay-pending {
background-color: #F8D7DA !important; /* soft red alert tint for unpaid/abandoned items */
color: #721C24 !important;
border: 1px solid rgba(114, 28, 36, 0.15);
}
.pay-refunded {
background-color: #E2E3E5 !important;
color: #383D41 !important;
border: 1px solid rgba(56, 61, 65, 0.15);
}
/* --- Curated Shipping Identity Panels --- */
.address-curated-card {
transition: all 0.23s cubic-bezier(0.16, 1, 0.3, 1);
}
.address-curated-card:hover {
border-color: #111111 !important;
transform: translateY(-2px);
}
.border-top-dashed {
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.btn-action-trigger {
transition: color 0.15s ease;
}
.btn-action-trigger:hover:not(.text-danger) {
color: #111111 !important;
text-decoration: underline;
}
.btn-action-trigger:hover.text-danger {
color: #A71D2A !important;
text-decoration: underline;
}
/* --- Interactive Form Element Overlays --- */
.premium-plaintext-field {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: 0.65rem 0.85rem;
font-size: 0.9rem;
color: #111111;
transition: all 0.2s ease;
}
.premium-plaintext-field:focus {
border-color: #111111;
box-shadow: 0 0 0 1px #111111;
background-color: #FFFFFF;
}
.custom-box-tick {
border: 1.5px solid rgba(0, 0, 0, 0.25);
width: 1rem;
height: 1rem;
border-radius: 4px;
transition: all 0.15s ease;
cursor: pointer;
}
.custom-box-tick:checked {
background-color: #111111;
border-color: #111111;
}
.btn-premium-sm {
font-size: 0.75rem;
padding: 0.45rem 1rem;
border-radius: 6px;
font-weight: 500;
}
.btn-clean-cancel {
background: transparent;
border: none;
color: #666666;
transition: color 0.2s ease;
}
.btn-clean-cancel:hover {
color: #111111;
}
.border-y {
border-top: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.context-clickable {
cursor: pointer;
user-select: none;
}
/* --- Identity Node Presentation Wrappers --- */
.profile-hero-banner {
border: 1px solid rgba(0, 0, 0, 0.05);
}
.animate-fade-in {
animation: layoutFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes layoutFadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,98 @@
@page "/cart"
@rendermode InteractiveServer
<div class="container py-5" style="max-width: 1000px; font-family: system-ui, -apple-system, sans-serif;">
<div class="d-flex align-items-baseline justify-content-between mb-5 border-bottom pb-3">
<h1 class="ff-serif mb-0" style="font-size: 2.25rem; font-family: 'Playfair Display', Georgia, serif; font-weight: 400; letter-spacing: -0.5px;">Your Cart</h1>
<a href="/" class="text-dark text-decoration-none small fw-semibold tracking-widest text-uppercase" style="font-size: 0.75rem; letter-spacing: 1.5px;">Continue Shopping</a>
</div>
@if (!ShoppingCart.Items.Any())
{
<div class="text-center py-5 my-4">
<div class="mb-4 text-muted-50">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="text-secondary opacity-50"><circle cx="9" cy="21" r="1"></circle><circle cx="20" cy="21" r="1"></circle><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path></svg>
</div>
<h3 class="ff-serif mb-2" style="font-family: 'Playfair Display', Georgia, serif; font-weight: 400;">Your collection is currently vacant.</h3>
<p class="text-muted small mb-4" style="letter-spacing: 0.2px;">Even with careful scrutiny, the requested shelf remains empty.</p>
<a href="/" class="btn btn-dark rounded-pill px-4 py-2 text-uppercase fw-semibold tracking-widest" style="font-size: 0.75rem; letter-spacing: 1px; background-color: #1c1f22;">Browse Catalog</a>
</div>
}
else
{
<div class="row g-5">
<div class="col-12 col-lg-7">
<div class="d-flex flex-column gap-1">
@foreach (var item in ShoppingCart.Items)
{
<div class="row align-items-center py-4 border-bottom position-relative">
<div class="col-12 col-md-7 d-flex align-items-center gap-4 mb-3 mb-md-0">
<div class="bg-light d-flex align-items-center justify-content-center p-2 rounded-1 state-card-shadow"
style="width: 70px; height: 95px; background-color: #f8f9fa; box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);">
@if (!string.IsNullOrWhiteSpace(item.Product!.ImageUrl))
{
<img src="@item.Product!.ImageUrl" class="img-fluid" style="max-height: 80px; object-fit: contain; filter: drop-shadow(2px 4px 6px rgba(0,0,0,0.15));" alt="@item.Product.Name" />
}
else
{
<span class="text-muted fw-bold font-monospace" style="font-size: 0.6rem; letter-spacing: 1px;">[COVER]</span>
}
</div>
<div style="max-width: 75%;">
<h5 class="fw-semibold mb-1 text-dark" style="font-size: 0.95rem; line-height: 1.4; letter-spacing: -0.1px;">@item.Product!.Name</h5>
<p class="text-muted small mb-0" style="font-size: 0.8rem; letter-spacing: 0.1px;">by @($"{item.Author!.Name} {item.Author!.LastName}")</p>
</div>
</div>
<div class="col-6 col-md-3 d-flex justify-content-md-center align-items-center">
<div class="d-flex align-items-center border rounded-pill bg-white px-1 py-1" style="border-color: #e9ecef !important;">
<button class="btn btn-sm border-0 bg-transparent text-muted px-2 py-0" style="font-size: 0.9rem;" @onclick="() => DecreaseQty(item, -1)">—</button>
<span class="px-2 fw-semibold text-dark" style="font-size: 0.85rem; min-width: 20px; text-align: center;">@item.Quantity</span>
<button class="btn btn-sm border-0 bg-transparent text-muted px-2 py-0" style="font-size: 0.9rem;" @onclick="() => IncreaseQty(item)">+</button>
</div>
</div>
<div class="col-6 col-md-2 text-end d-flex align-items-center justify-content-end gap-3">
<span class="fw-semibold text-dark" style="font-size: 0.95rem; letter-spacing: -0.2px;">R @(item.Amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
<button class="btn btn-sm p-1 text-muted opacity-50 hover-opacity-100 border-0 bg-transparent" style="transition: opacity 0.2s;" @onclick="() => RemoveFromCart(item)" title="Remove item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
}
</div>
</div>
<div class="col-12 col-lg-5">
<div class="bg-white p-4 rounded-3 border-0 sticky-top" style="top: 2rem; box-shadow: 0 10px 30px rgba(0,0,0,0.04); border: 1px solid #f1f3f5 !important;">
<h4 class="ff-serif mb-4" style="font-family: 'Playfair Display', Georgia, serif; font-weight: 400; font-size: 1.3rem;">Order Summary</h4>
<div class="d-flex justify-content-between mb-3" style="font-size: 0.9rem;">
<span class="text-muted">Subtotal</span>
<span class="fw-normal text-dark">R @(ShoppingCart.TotalAmount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
</div>
<div class="d-flex justify-content-between mb-4" style="font-size: 0.9rem;">
<span class="text-muted">VAT (15%)</span>
<span class="fw-normal text-dark">R @(ShoppingCart.TotalVat.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
</div>
<hr style="border-color: #f1f3f5;" />
<div class="d-flex justify-content-between align-items-baseline mb-4 mt-2">
<span class="fw-semibold h6 mb-0 text-dark" style="font-size: 1rem;">Total</span>
<span class="fw-bold text-dark" style="font-size: 1.4rem; letter-spacing: -0.5px;">R @((ShoppingCart.TotalAmount + ShoppingCart.TotalVat).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
</div>
<a href="/checkout" class="btn btn-dark w-100 rounded-pill py-3 text-uppercase fw-semibold tracking-widest border-0"
style="font-size: 0.75rem; letter-spacing: 1.5px; background-color: #1c1f22; transition: background-color 0.2s;">
Proceed to Checkout
</a>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,40 @@
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Pages;
public partial class CartReview(CartService cartService)
{
[Inject] public IToastService ToastService { get; set; } = default!;
protected Cart ShoppingCart => cartService?.ShoppingCart!;
protected async void IncreaseQty(CartItem item)
{
if (item is not null)
{
cartService.UpdateQuantity(item!.Price!.Id, 1);
await cartService.SaveCartToStorageAsync();
}
}
protected async void DecreaseQty(CartItem item, int delta)
{
var peekQuantity = item.Quantity + delta;
if (peekQuantity < 1) return;
cartService.UpdateQuantity(item!.Price!.Id, delta);
await cartService.SaveCartToStorageAsync();
}
private async void RemoveFromCart(CartItem item)
{
cartService.RemoveOneItem(item.Price!.Id);
await cartService.SaveCartToStorageAsync();
ToastService.ShowSuccess($"Removed {item.Product!.Name} from cart", "Cart Changed");
}
}
@@ -0,0 +1,2 @@
body {
}
@@ -0,0 +1,261 @@
@page "/checkout"
@inject NavigationManager Navigation
@rendermode InteractiveServer
@attribute [Authorize]
<div class="checkout-page-container py-5">
@if (IsProcessing)
{
<div class="processing-screen-overlay">
<div class="processing-card-box">
<div class="mb-1">
<svg class="payment-vector-loader" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle class="loader-track" cx="12" cy="12" r="10" />
<path class="loader-handshake-ring" d="M12 2a10 10 0 0 1 10 10" />
</svg>
</div>
<div>
<h4 class="fw-bold text-dark font-monospace text-uppercase tracking-wider mb-2" style="font-size: 1.1rem;">
Securing Your Order
</h4>
<p class="text-muted small mb-0 px-2" style="line-height: 1.5; font-size: 0.85rem;">
Please stand by. We are preparing your payment portal and transferring you securely to PayFast.
</p>
</div>
<div class="secure-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<span>Bank-Grade 256-Bit SSL Connection</span>
</div>
</div>
</div>
}
<header class="checkout-header mb-4">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Secure Checkout</span>
<h1 class="checkout-main-title fw-bold">Review Your Order</h1>
</header>
@if (ShoppingCart.Items.Any() && HasStockExceptions)
{
<div class="alert alert-danger border-0 rounded-3 p-3 mb-4 d-flex align-items-center gap-3 animate-fade-in">
<svg class="text-danger flex-shrink-0" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<div>
<h6 class="fw-bold text-danger mb-0.5" style="font-size: 0.92rem;">Action Required: Inventory Shortage</h6>
<p class="text-secondary small mb-0" style="font-size: 0.85rem; line-height: 1.4;">
One or more items in your shopping cart are currently out of stock. Please remove or adjust these selections to proceed to the payment gateway.
</p>
</div>
</div>
}
@if (ShoppingCart.Items.Any() == false)
{
<div class="checkout-section-panel text-center py-5 my-4 d-flex flex-column align-items-center gap-3">
<div class="text-muted opacity-50">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
</div>
<div>
<h4 class="fw-bold text-dark mb-1">Your cart is empty</h4>
<p class="text-muted small mb-0">You cannot proceed to payment without selected titles.</p>
</div>
<a href="/" class="btn btn-premium-action px-4 py-2 mt-2 text-decoration-none" style="font-size: 0.85rem;">
Browse Book Catalogue
</a>
</div>
}
else
{
<div class="row g-5">
<div class="col-lg-7">
<section class="checkout-section-panel mb-4">
<div class="panel-header-row d-flex justify-content-between align-items-center mb-4">
<h5 class="panel-title fw-bold mb-0">Your Selection</h5>
<span class="badge rounded-pill bg-light text-dark font-monospace px-2.5 py-1.5 border">
@ShoppingCart.Items.Count Items
</span>
</div>
<div class="checkout-items-stack">
@foreach (var item in ShoppingCart.Items)
{
var isOutofStock = AvailableStockMap.TryGetValue(item.Price!.Id, out var availableCount) && availableCount <= 0;
<div class="checkout-item-row py-3 d-flex align-items-center justify-content-between @(isOutofStock ? "border-danger bg-light-danger-subtle" : "")">
<div class="item-meta-details pe-4">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<h6 class="item-product-name fw-bold mb-0 text-dark">@item.Product!.Name</h6>
@if (isOutofStock)
{
<span class="badge bg-danger text-white font-monospace text-uppercase" style="font-size: 0.65rem; padding: 0.2rem 0.4rem; letter-spacing: 0.02em;">Out Of Stock</span>
}
</div>
<span class="item-author-label small text-muted font-monospace">
By @($"{item.Author!.Name} {item.Author.LastName}")
</span>
</div>
<div class="item-interactive-actions d-flex align-items-center gap-3 flex-shrink-0">
<div class="premium-quantity-stepper">
<button class="step-btn" @onclick="() => ChangeQuantity(item, -1)" aria-label="Decrease quantity"></button>
<span class="step-value font-monospace">@item.Quantity</span>
<button class="step-btn" @onclick="() => ChangeQuantity(item, 1)" aria-label="Increase quantity">+</button>
</div>
<button class="btn-clean-remove font-monospace text-uppercase" @onclick="() => RemoveFromCart(item)">
Remove
</button>
</div>
</div>
}
</div>
</section>
<section class="checkout-section-panel mb-4">
<h5 class="panel-title fw-bold mb-4">Fulfillment Option</h5>
<div class="premium-radio-group d-flex flex-column gap-3">
<div class="premium-selectable-card @(ShippingCost == 0 ? "active" : "")">
<input class="form-check-input visual-hidden" type="radio" name="shipping" id="pickup"
checked=@(ShippingCost == 0) @onclick="() => ShippingCost = 0">
<div class="card-indicator-circle"></div>
<label class="card-text-block m-0 context-clickable" for="pickup">
<span class="card-label-title fw-bold d-block text-dark">Collect from Midrand Bookshop</span>
<span class="card-label-desc text-muted small">Corner of Church & Third Roads. Ready within 2 hours.</span>
</label>
<span class="card-price-tag font-monospace ms-auto text-success fw-bold">FREE</span>
</div>
<div class="premium-selectable-card @(ShippingCost == 60 ? "active" : "")">
<input class="form-check-input visual-hidden" type="radio" name="shipping" id="delivery"
checked=@(ShippingCost == 60) @onclick="() => ShippingCost = 60">
<div class="card-indicator-circle"></div>
<label class="card-text-block m-0 context-clickable" for="delivery">
<span class="card-label-title fw-bold d-block text-dark">Door-to-Door Home Delivery</span>
<span class="card-label-desc text-muted small">Dispatched via reliable overnight courier straight to your steps.</span>
</label>
<span class="card-price-tag font-monospace ms-auto text-dark fw-bold">R 60.00</span>
</div>
</div>
</section>
<section class="checkout-section-panel mb-4">
<div class="d-flex justify-content-between align-items-baseline mb-2">
<h5 class="panel-title fw-bold mb-0">Delivery Instructions</h5>
<span class="text-muted font-monospace extra-small opacity-75">Optional</span>
</div>
<p class="text-muted small mb-3">
Add any specific details for our dispatch team (e.g., gate access codes, complex navigation, or safe drop-off preferences).
</p>
<div class="premium-textarea-wrapper">
<textarea class="form-control premium-plaintext-field"
rows="3"
placeholder="Type your notes or courier instructions here..."
@bind="OrderNotes"
maxlength="500">
</textarea>
<div class="text-end mt-1.5">
<small class="font-monospace text-muted extra-small opacity-50">
@(OrderNotes?.Length ?? 0) / 500 characters
</small>
</div>
</div>
</section>
<section class="checkout-section-panel">
<h5 class="panel-title fw-bold mb-3">Billing Settings</h5>
<div class="premium-checkbox-wrapper d-flex align-items-center gap-3 p-3 border rounded-3">
<input class="form-check-input custom-box-tick m-0" type="checkbox" id="sameAsBilling" @bind="IsSameAddress">
<label class="checkbox-text-label small text-dark fw-medium m-0 context-clickable w-100 py-1" for="sameAsBilling">
My billing address is the same as my shipping address
</label>
</div>
</section>
</div>
<div class="col-lg-5">
<div class="sticky-summary-card p-4 border">
<h5 class="fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4 small-summary-heading">Summary Breakdown</h5>
<div class="price-summary-ledger d-flex flex-column gap-3">
<div class="ledger-row d-flex justify-content-between">
<span class="text-muted small">Items Subtotal</span>
<span class="font-monospace text-dark fw-medium">R @ShoppingCart.TotalAmount.ToString("F2")</span>
</div>
<div class="ledger-row d-flex justify-content-between align-items-center">
@if (ShoppingCart.TotalVat > 0)
{
<span class="text-muted small">Value Added Tax (VAT 15%)</span>
<span class="font-monospace text-dark fw-medium">R @ShoppingCart.TotalVat.ToString("F2")</span>
}
else
{
<span class="text-muted small">Value Added Tax (VAT)</span>
<small class="text-danger fw-medium tracking-wide font-monospace" style="font-size: 0.78rem;">
(Price is VAT inclusive)
</small>
}
</div>
<div class="ledger-row d-flex justify-content-between">
<span class="text-muted small">Fulfillment Courier Fee</span>
<span class="font-monospace text-dark fw-medium">
@if (ShippingCost == 0)
{
<span class="text-success fw-bold">FREE</span>
}
else
{
<span>R @($"{ShippingCost:F2}")</span>
}
</span>
</div>
<hr class="summary-divider my-2" />
<div class="ledger-row d-flex justify-content-between align-items-baseline mb-4">
<span class="fw-bold text-dark">Total Due Amount</span>
<div class="text-end">
<span class="font-monospace text-dark fw-bold h3 mb-0 d-block tracking-tight">
R @($"{ShoppingCart.TotalAmount + ShoppingCart.TotalVat + ShippingCost:F2}")
</span>
<small class="text-muted opacity-60 font-monospace extra-small">ZAR Currency</small>
</div>
</div>
</div>
<button class="btn btn-premium-action w-100 py-3.5 d-flex align-items-center justify-content-center gap-2"
disabled="@(IsProcessing || HasStockExceptions)"
@onclick="PayNow">
<span>Pay Now</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
</div>
</div>
@if (IsProcessing == true && CheckoutPayload?.Count > 0)
{
<form id="payfastForm" action="@PayfastOptions.Value.CheckoutUrl" method="POST">
@foreach (var field in CheckoutPayload)
{
<input type="hidden" name="@field.Key" value="@field.Value" />
}
</form>
}
</div>
}
</div>
@@ -0,0 +1,216 @@
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Globalization;
using LiteCharms;
using Microsoft.AspNetCore.Components.Authorization;
namespace MidrandBookshop.Components.Pages;
public partial class Checkout()
{
[Inject] public HashService HashService { get; set; } = default!;
[Inject] public PaymentService PaymentService { get; set; } = default!;
[Inject] public OrderService OrderService { get; set; } = default!;
[Inject] public BooksService BooksService { get; set; } = default!;
[Inject] public CartService CartService { get; set; } = default!;
[Inject] public PayfastService PayfastService { get; set; } = default!;
[Inject] public IOptions<PayfastSettings> PayfastOptions { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] public IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
[Inject] public IToastService ToastService { get; set; } = default!;
private Cart ShoppingCart => CartService.ShoppingCart;
private ClaimsPrincipal? User { get; set; }
private bool IsProcessing { get; set; }
private decimal ShippingCost = 0;
private bool IsSameAddress = true;
public string? OrderNotes { get; set; }
private Dictionary<string, string> CheckoutPayload { get; set; } = [];
// Tracks available quantities indexed by Price ID
protected Dictionary<long, int> AvailableStockMap { get; set; } = [];
// Quick validation flag to evaluate checkout block state
protected bool HasStockExceptions => ShoppingCart.Items.Any(item =>
AvailableStockMap.TryGetValue(item.Price!.Id, out var count) && count <= 0);
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState!.User;
Navigation.LocationChanged += OnLocationChanged;
CartService.OnCartChanged += CartService_OnCartChanged;
if (CartService.ShoppingCart.Items.Count == 0)
{
await CartService.LoadCartFromStorageAsync();
}
await RefreshStockValidationAsync();
}
private async void CartService_OnCartChanged()
{
await RefreshStockValidationAsync();
await InvokeAsync(StateHasChanged);
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged();
private async Task RefreshStockValidationAsync()
{
AvailableStockMap.Clear();
foreach (var item in ShoppingCart.Items)
{
if (item.Price is not null)
{
// Mapped fallback default (set to 0 for specific keys to test stock warnings instantly)
// In production: pull from your inventory system:
// var stockCheck = await BooksService.GetStockLevelAsync(item.Price.Id);
int liveStockAvailable = 1;
AvailableStockMap[item.Price.Id] = liveStockAvailable;
}
}
}
private async Task ChangeQuantity(CartItem item, int delta)
{
var peekQuantity = item.Quantity + delta;
if (peekQuantity < 1) return;
// Block internal counters exceeding live available thresholds
if (AvailableStockMap.TryGetValue(item.Price!.Id, out var maxAvailable) && peekQuantity > maxAvailable)
{
ToastService.ShowWarning($"Cannot exceed remaining stock limit ({maxAvailable} available).", "Stock Limit");
return;
}
CartService.UpdateQuantity(item.Price!.Id, delta);
await CartService.SaveCartToStorageAsync();
}
private async Task RemoveFromCart(CartItem item)
{
CartService.RemoveOneItem(item.Price!.Id);
await CartService.SaveCartToStorageAsync();
}
private async Task PayNow(MouseEventArgs args)
{
// Fail-safe protection boundary check
if (HasStockExceptions)
{
ToastService.ShowError("Your order cannot contain items that are out of stock.", "Inventory Issue");
return;
}
if (IsProcessing)
{
ToastService.ShowWarning("Please wait, completing your payment", "Busy...");
return;
}
try
{
IsProcessing = true;
StateHasChanged();
Result<long> orderResult;
var customerId = (long)ShoppingCart.CustomerId!;
if (!ShoppingCart.OrderId.HasValue)
{
CreateOrder request = new(ShoppingCart.TotalAmount, null);
orderResult = await OrderService.CreateOrderAsync(customerId, request, CancellationToken);
ShoppingCart.OrderId = orderResult.Value;
}
List<CreateOrderItem> orderItems = [];
var orderId = (long)ShoppingCart.OrderId;
await OrderService.ClearOrderItemsAsync(orderId, CancellationToken);
foreach (var item in ShoppingCart.Items)
{
var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id, CancellationToken);
if (bookRequest.IsSuccess)
{
var orderItem = new CreateOrderItem(bookRequest.Value.Id, item.Price.Id, item.Quantity);
orderItems.Add(orderItem);
}
}
var orderHash = HashService.HashEncodeLongId(orderId).Value;
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken);
long paymentId = 0;
if (paymentGen.IsSuccess) paymentId = paymentGen.Value;
if (paymentGen.IsFailed)
{
var paymentFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentFetch.IsFailed)
{
ToastService.ShowError("Failed to fetch your previously made payment", "Payment Check");
IsProcessing = false;
return;
}
paymentId = paymentFetch.Value.Id;
}
CreateLedgerEntry ledgerRequest = new()
{
OrderId = orderId,
CustomerId = customerId,
PaymentGatewayId = 1,
PaymentGatewayReference = orderHash,
PaymentId = paymentId,
Status = LiteCharms.Features.LedgerStatuses.Sent,
};
await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken);
var addItemsResult = await OrderService.AddItemsToOrderAsync(orderId, [.. orderItems], CancellationToken);
var hostAddress = Navigation.BaseUri.TrimEnd('/');
CheckoutPayload = new Dictionary<string, string>
{
{ "merchant_id", PayfastOptions.Value.MerchantId! },
{ "merchant_key", PayfastOptions.Value.MerchantKey! },
{ "return_url", $"{hostAddress}/payment-success?reference={orderHash}" },
{ "cancel_url", $"{hostAddress}/payment-failed?reference={orderHash}" },
{ "notify_url", "https://api.uat.midrandbooks.co.za/v1/payments/payfast/confirm" },
{ "email_address", User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value! },
{ "m_payment_id", orderHash },
{ "amount", ShoppingCart.TotalAmount.ToString("F2", CultureInfo.InvariantCulture) },
{ "item_name", "MidrandBooks Sale" },
};
var signature = PayfastService.GenerateSignature(CheckoutPayload!, PayfastOptions.Value.Passphrase).Value;
CheckoutPayload.Add("signature", signature);
StateHasChanged();
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('payfastForm').submit();");
}
catch (Exception ex)
{
ToastService.ShowError($"Failed to perform checkout: {ex.Message}", "Checkout");
IsProcessing = false;
StateHasChanged();
}
}
}
@@ -0,0 +1,347 @@
/* ==========================================================================
Midrand Books — Checkout Layout Polish & Tightening
========================================================================== */
/* --- 🛠️ 1. Global Page Wrapper Boundary Constraints --- */
.checkout-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
/* --- 2. Page Typography & Headers --- */
.checkout-header {
margin-bottom: 2.5rem !important;
}
.checkout-main-title {
font-size: 2.25rem;
letter-spacing: -0.03em;
color: #111111;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- 3. Custom Structural Content Panels --- */
.checkout-section-panel {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 2rem;
}
.panel-title {
font-size: 1.1rem;
letter-spacing: -0.01em;
color: #111111;
}
/* --- 🛠️ 4. Items Manifest Row & Stepper Controls --- */
.checkout-items-stack .checkout-item-row {
display: flex;
align-items: center; /* Locks elements on a clean vertical axis line */
justify-content: space-between;
gap: 2rem;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.checkout-items-stack .checkout-item-row:last-child {
border-bottom: none;
}
.item-meta-details {
max-width: 65%; /* Brounds long item titles elegantly */
}
.item-product-name {
font-size: 0.95rem;
line-height: 1.4;
color: #1A1A1A;
}
.premium-quantity-stepper {
display: inline-flex;
align-items: center;
background-color: #F8F9FA;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 30px;
padding: 3px;
}
.premium-quantity-stepper .step-btn {
background: none;
border: none;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #555555;
border-radius: 50%;
transition: background-color 0.2s ease, color 0.2s ease;
}
.premium-quantity-stepper .step-btn:hover {
background-color: #FFFFFF;
color: #000000;
}
.premium-quantity-stepper .step-value {
min-width: 28px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
color: #111111;
}
.btn-clean-remove {
background: none;
border: none;
color: #DC3545;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.btn-clean-remove:hover {
background-color: rgba(220, 53, 69, 0.06);
}
/* --- 5. Interactive Selectable Radio Selection Cards --- */
.visual-hidden {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.premium-selectable-card {
display: flex;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
padding: 1.25rem;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
background-color: #FFFFFF;
}
.premium-selectable-card:hover {
border-color: #111111;
background-color: #FAFBFB;
}
.premium-selectable-card.active {
border-color: #000000;
box-shadow: inset 0 0 0 1px #000000;
background-color: #FFFFFF;
}
.card-indicator-circle {
width: 16px;
height: 16px;
border: 1px solid rgba(0, 0, 0, 0.25);
border-radius: 50%;
margin-right: 1.25rem;
position: relative;
transition: all 0.2s ease;
flex-shrink: 0;
}
.premium-selectable-card.active .card-indicator-circle {
border-color: #000000;
background-color: #000000;
}
.premium-selectable-card.active .card-indicator-circle::after {
content: '';
position: absolute;
width: 6px;
height: 6px;
background-color: #FFFFFF;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.card-label-title {
font-size: 0.9rem;
line-height: 1.3;
}
.card-label-desc {
font-size: 0.78rem !important;
}
.card-price-tag {
font-size: 0.85rem;
letter-spacing: -0.01em;
}
/* --- 6. Form Checkbox Options --- */
.context-clickable {
cursor: pointer;
transition: background-color 0.2s ease;
}
.context-clickable:hover {
background-color: #FAFBFB;
}
.custom-box-tick {
cursor: pointer;
}
/* --- 🛠️ 7. Sticky Right Sidebar Order Ledger --- */
.sticky-summary-card {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
border-radius: 12px;
position: sticky;
top: 120px; /* Safe breathing room underneath the global nav header */
box-shadow: 0 12px 34px -10px rgba(0, 0, 0, 0.03);
}
.small-summary-heading {
font-size: 0.78rem;
color: #666666;
}
.price-summary-ledger .ledger-row {
padding: 0.25rem 0;
}
.summary-divider {
border-color: rgba(0, 0, 0, 0.06);
opacity: 1;
}
.extra-small {
font-size: 0.68rem;
}
/* --- 8. Core Checkout Action Button --- */
.btn-premium-action {
background-color: #111111;
color: #FFFFFF;
border: none;
border-radius: 50px;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-premium-action:hover:not(:disabled) {
background-color: #222222;
transform: translateY(-1px);
box-shadow: 0 8px 20px -6px rgba(0, 0, 0, 0.15);
}
.btn-premium-action:disabled {
background-color: #CCCCCC;
color: #888888;
cursor: not-allowed;
}
/* ==========================================================================
Full-Screen Handover Processing Overlay
========================================================================== */
.processing-screen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #FFFFFF;
z-index: 3000;
}
.processing-card-box {
max-width: 420px;
padding: 2rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.payment-vector-loader {
width: 48px;
height: 48px;
}
.loader-track {
stroke: rgba(0, 0, 0, 0.06);
stroke-width: 2.5;
fill: none;
}
.loader-handshake-ring {
stroke: #111111;
stroke-width: 2.5;
stroke-linecap: round;
fill: none;
transform-origin: center;
animation: spinHandoverLoop 1s linear infinite;
}
.secure-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #666666;
opacity: 0.6;
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 0.35rem 0.85rem;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 500;
}
@keyframes spinHandoverLoop {
100% {
transform: rotate(360deg);
}
}
/* --- 9. Premium Plaintext Field & Textarea Structure --- */
.premium-textarea-wrapper {
position: relative;
}
.premium-plaintext-field {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
padding: 0.85rem 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: #1A1A1A;
resize: none; /* Disables ugly drag handles to maintain design proportions */
transition: all 0.2s ease;
}
.premium-plaintext-field:focus {
background-color: #FFFFFF;
border-color: #000000;
box-shadow: none; /* Strip standard blue Bootstrap glowing halos */
outline: none;
}
.premium-plaintext-field::placeholder {
color: #A0A0A0;
font-size: 0.88rem;
}
@@ -0,0 +1,97 @@
@page "/contact"
@rendermode InteractiveServer
<div class="contact-page-container py-5">
<header class="contact-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Inquiries & Submissions</span>
<h1 class="contact-main-title fw-bold">Get In Touch</h1>
<p class="text-muted small font-monospace mt-2">
Lite Charms (Pty) Ltd t/a Midrand Books &bull; Reader & Author Support Desk
</p>
</header>
<div class="row g-5">
<div class="col-lg-5">
<div class="contact-metadata-card p-4 border border-light bg-white mb-4">
<h3 class="metadata-title mb-4">The Bookshop Desk</h3>
<div class="meta-item mb-3">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Main Office</span>
<span class="meta-value font-monospace small">Corporate Woods, Midrand, Gauteng, 1685, South Africa</span>
</div>
<div class="meta-item mb-3">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Email Correspondence</span>
<span class="meta-value font-monospace small"><a href="mailto:desk@midrandbooks.co.za" class="contact-link">desk@midrandbooks.co.za</a></span>
</div>
<div class="meta-item mb-4">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Authors & Publishers</span>
<span class="meta-value font-monospace small text-muted">Are you an independent author or an established brand looking to publish with us? Specify your requirements in the form grid.</span>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="contact-form-panel p-4 border rounded bg-white">
<h3 class="metadata-title mb-4">Send us a Note</h3>
@if (HasSubmitted)
{
<div class="submission-success-banner py-4 text-center">
<svg class="success-vector-checkmark mb-3" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<h4 class="font-monospace text-uppercase tracking-wider fs-6 fw-bold text-dark">Message Sent</h4>
<p class="text-muted small mb-0 px-3">
Thank you for reaching out. A bookstore representative or publishing helper will respond to you via email shortly.
</p>
</div>
}
else
{
<form @onSubmit="HandleTransmissionSubmit">
<div class="mb-3">
<label class="form-label font-monospace text-uppercase text-muted extra-small">Your Name</label>
<input type="text" class="premium-plaintext-field w-100" required @bind="FormName" placeholder="e.g., Alexander Stone" />
</div>
<div class="mb-3">
<label class="form-label font-monospace text-uppercase text-muted extra-small">Email Address</label>
<input type="email" class="premium-plaintext-field w-100" required @bind="FormEmail" placeholder="e.g., alex@domain.co.za" />
</div>
<div class="mb-4">
<label class="form-label font-monospace text-uppercase text-muted extra-small">How can we help you?</label>
<textarea class="premium-plaintext-field w-100" rows="5" required @bind="FormMessage" placeholder="Tell us about your book submission, order inquiries, or general publishing ideas..."></textarea>
</div>
<button type="submit" class="btn btn-premium-action w-100 py-3 d-flex align-items-center justify-content-center gap-2">
<span>Send Message</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
}
</div>
</div>
</div>
</div>
@code {
private string FormName { get; set; } = string.Empty;
private string FormEmail { get; set; } = string.Empty;
private string FormMessage { get; set; } = string.Empty;
private bool HasSubmitted { get; set; } = false;
private void HandleTransmissionSubmit()
{
if (!string.IsNullOrWhiteSpace(FormName) && !string.IsNullOrWhiteSpace(FormEmail))
{
HasSubmitted = true;
StateHasChanged();
}
}
}
@@ -0,0 +1,126 @@
/* ==========================================================================
Midrand Books — Contact View Layout Architecture Styles
========================================================================== */
.contact-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.contact-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
.extra-small {
font-size: 0.72rem !important;
}
.metadata-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.2rem;
font-weight: 500;
color: #111111;
}
/* --- Metadata Info Panels --- */
.contact-metadata-card {
border-radius: 8px;
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.meta-label {
letter-spacing: 0.08em;
margin-bottom: 0.25rem;
}
.meta-value {
color: #333333;
}
.contact-link {
color: #111111;
text-decoration: underline;
text-underline-offset: 3px;
transition: opacity 0.2s ease;
}
.contact-link:hover {
opacity: 0.7;
}
/* --- Form Fields --- */
.contact-form-panel {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.premium-plaintext-field {
border: 1px solid rgba(0, 0, 0, 0.15);
background-color: #FAFAFA;
outline: none;
border-radius: 6px;
padding: 0.75rem 0.95rem;
font-size: 0.9rem;
color: #111111;
transition: all 0.2s ease;
}
.premium-plaintext-field:focus {
border-color: #111111;
box-shadow: 0 0 0 1px #111111;
background-color: #FFFFFF;
}
/* --- Premium Action Button --- */
.btn-premium-action {
background-color: #111111;
color: #FFFFFF;
border: 1px solid #111111;
border-radius: 6px;
font-family: var(--bs-font-monospace);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: all 0.2s ease;
}
.btn-premium-action:hover {
background-color: transparent;
color: #111111;
}
/* --- Submission State Banner --- */
.submission-success-banner {
background-color: #FDFBFB;
border: 1px dashed rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.success-vector-checkmark {
color: #111111;
animation: bounceMarker 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounceMarker {
from {
transform: scale(0.4);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
+36 -23
View File
@@ -1,36 +1,49 @@
@page "/Error"
@using System.Diagnostics
@inject NavigationManager Navigation
<PageTitle>Error</PageTitle>
<PageTitle>System Exception | Midrand Books</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<div class="artistic-error-container">
<div class="container h-100">
<div class="row align-items-center justify-content-center min-vh-70 py-5">
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<div class="col-12 col-md-5 d-flex justify-content-center mb-5 mb-md-0">
<svg viewBox="0 0 400 400" width="300" height="300" fill="none" stroke="#1A1A1A" stroke-width="1.5">
<rect x="100" y="100" width="200" height="200" transform="rotate(45 200 200)" opacity="0.2" />
<line x1="150" y1="150" x2="250" y2="250" />
<line x1="250" y1="150" x2="150" y2="250" />
<circle cx="200" cy="200" r="80" stroke-dasharray="10 10" />
</svg>
</div>
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
<div class="col-12 col-md-6 offset-md-1">
<div class="meta-tag font-monospace text-uppercase text-danger mb-3">System Exception // Critical Halt</div>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
<h1 class="display-4 text-dark mb-4 editorial-headline">An unexpected<br />interruption.</h1>
<p class="text-muted mb-5 body-manifesto-text">
We are experiencing a technical disruption in our processing stream. Our team has been notified of this structural anomaly.
</p>
@if (ShowRequestId)
{
<p class="font-monospace text-muted small mb-4">
Ref: <code>@RequestId</code>
</p>
}
<button class="btn btn-outline-dark rounded-pill px-5 py-3" @onclick='() => Navigation.NavigateTo("/")'>Return to Library</button>
</div>
</div>
</div>
</div>
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}
@@ -0,0 +1,37 @@
.artistic-error-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
color: #dc3545; /* Subtle red for system status */
}
.btn-outline-dark {
border: 1px solid #111;
transition: all 0.3s ease;
}
.btn-outline-dark:hover {
background-color: #111;
color: #fff;
}
+199 -3
View File
@@ -1,7 +1,203 @@
@page "/"
@rendermode InteractiveServer
<PageTitle>Midrand Books</PageTitle>
<div id="top-target" @ref="topTargetRef" class="container text-center text-hero-wrapper" />
<h3>midrandbooks.co.za</h3>
<div class="container text-center text-hero-wrapper">
<h1 class="display-3 text-dark mb-3 px-2 master-headline">
Discover thoughtfully curated<br>books for every reader.
</h1>
<p class="text-muted mx-auto mb-0 sub-headline">
Explorations into books, reading culture, and the art of thoughtful curation from Midrand to the world.
</p>
</div>
Welcome to the Midrand Books online bookstore! We are passionate about providing a wide selection of books to readers of all ages and interests. Whether you're looking for the latest bestsellers, classic literature, or educational resources, we have something for everyone.
<div class="container mb-5 px-md-5">
@if (AuthorId.HasValue && !string.IsNullOrEmpty(ActiveAuthorFilterName))
{
<div class="alert alert-light border d-flex align-items-center justify-content-between rounded-pill px-4 py-2 mb-4 shadow-sm mx-auto" style="max-width: 520px;">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-dark rounded-pill fw-normal px-2.5 py-1">Author Collection</span>
<span class="text-dark fw-medium small">Archived items from <strong>@ActiveAuthorFilterName</strong></span>
</div>
<button class="btn btn-close btn-sm p-1 ms-3" @onclick="ClearAuthorFilter" aria-label="Clear Filter"></button>
</div>
}
<div class="row align-items-center justify-content-between pb-3 g-3">
<div class="col-12 col-md-8">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-start">
@if (!AuthorId.HasValue)
{
@foreach (var category in MainCategories)
{
var catName = category;
<button class="btn btn-sm rounded-pill px-3 py-1-5 fw-medium transition-smooth
@(ActiveCategory == catName ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle bg-white")"
@onclick="() => SelectCategory(catName)">
@catName
</button>
}
@if (DynamicExtendedCategories.Count > 0)
{
<button class="btn btn-link text-muted btn-sm text-decoration-none fw-medium transition-smooth d-inline-flex align-items-center gap-1 control-expansion-trigger"
@onclick="ToggleExtraCategories">
<span>@(ShowExpandedCategories ? "Collapse Cloud" : "More Genres")</span>
<svg class="transition-smooth @(ShowExpandedCategories ? "rotate-180" : "")" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6,9 12,15 18,9" /></svg>
</button>
}
}
else
{
<button class="btn btn-sm btn-outline-secondary text-dark bg-white border-light-subtle rounded-pill px-3.5 py-1.5 fw-medium" @onclick="ClearAuthorFilter">
&larr; Return to Catalog Index
</button>
}
</div>
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center justify-content-md-end gap-2 text-md-end row-actions-wrapper">
<button class="btn btn-sm btn-outline-secondary text-dark bg-white border-light-subtle rounded-pill px-3.5 py-1.5 fw-medium d-inline-flex align-items-center gap-2"
@onclick="ToggleFilterMenu">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
<span>Refine Curation</span>
</button>
<div class="btn-group bg-white rounded-pill p-1 border border-light-subtle shadow-sm" role="group">
<button class="btn btn-sm rounded-pill p-1 d-flex align-items-center justify-content-center toggle-layout-action @(CurrentViewMode == ViewMode.Grid ? "btn-dark text-white shadow-sm" : "btn-link text-muted")"
style="width:28px; height:28px;" @onclick="() => SetViewMode(ViewMode.Grid)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg>
</button>
<button class="btn btn-sm rounded-pill p-1 d-flex align-items-center justify-content-center toggle-layout-action @(CurrentViewMode == ViewMode.List ? "btn-dark text-white shadow-sm" : "btn-link text-muted")"
style="width:28px; height:28px;" @onclick="() => SetViewMode(ViewMode.List)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" /></svg>
</button>
</div>
</div>
</div>
</div>
@if (ShowExpandedCategories && DynamicExtendedCategories.Count > 0 && !AuthorId.HasValue)
{
<div class="category-cloud-drawer p-4 mb-4 border border-light-subtle rounded animate-slide-down bg-light">
<h2 class="h6 text-muted mb-3 text-uppercase tracking-wider" style="font-size:0.7rem; font-weight:700;">Curated Subgenre Tag Index</h2>
<div class="d-flex flex-wrap gap-2">
@foreach (var subCategory in DynamicExtendedCategories)
{
var subName = subCategory;
<button class="btn btn-xs rounded-pill px-2.5 py-1 text-dark border-light-subtle transition-smooth
@(ActiveCategory == subName ? "btn-dark text-white" : "bg-white btn-outline-secondary")"
style="font-size:0.75rem;" @onclick="() => SelectCategory(subName)">
#@subName
</button>
}
</div>
</div>
}
@if (ShowFilterMenu)
{
<div class="filter-panel-drawer p-4 mb-4 border border-light-subtle rounded animate-slide-down bg-white shadow-sm">
<div class="row g-4 text-start">
<div class="col-12 col-sm-4">
<label class="form-label small fw-semibold text-dark">Sort Artifacts</label>
<select class="form-select form-select-sm rounded-pill px-3" value="@SelectedSortOption" @onchange="(e) => ChangeSort(e.Value?.ToString()!)">
<option value="default">Release Timeline</option>
<option value="price-low">Value: Low to High</option>
<option value="price-high">Value: High to Low</option>
<option value="title-asc">Alphabetical Order</option>
</select>
</div>
<div class="col-12 col-sm-4">
<label class="form-label small fw-semibold text-dark">Price Thresholds</label>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "all" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("all")'>All Prices</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "under-500" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("under-500")'>Under R500</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "500-1000" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("500-1000")'>R500 - R1,000</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "over-1000" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("over-1000")'>Over R1,000</button>
</div>
</div>
<div class="col-12 col-sm-4 d-flex align-items-center mt-sm-4">
<div class="form-check form-switch p-0 m-0 d-flex align-items-center gap-2">
<input class="form-check-input ms-0 mt-0 styled-switch-toggle" type="checkbox" role="switch" id="newArrivalsToggle" checked="@OnlyShowNew" @onchange="ToggleNewArrivalsOnly">
<label class="form-check-label small fw-medium text-dark user-select-none" for="newArrivalsToggle">New Acquisitions Only</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top border-light-subtle">
<button class="btn btn-link btn-sm text-decoration-none text-muted fw-medium px-3" @onclick="ResetFilters">Purge Filters</button>
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="ToggleFilterMenu">Apply Selection</button>
</div>
</div>
}
@if (!PaginatedBooks.Any())
{
<div class="text-center py-5 my-4 bg-light rounded border border-dashed border-light-subtle animate-fade-in">
<svg class="text-muted mb-3" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<p class="text-secondary mb-1 fw-medium">No archived books match your active criteria.</p>
<span class="text-muted small">Try adjusting your category definitions or search phrase query.</span>
</div>
}
else if (CurrentViewMode == ViewMode.Grid)
{
<div class="row g-4 animate-fade-in">
@foreach (var book in PaginatedBooks)
{
<BookCard Id="@book.Id"
Title="@book.Name"
Author="@(ProductAuthorCache.TryGetValue(book.Id, out var authorName) ? authorName : "Unknown Author")"
Price="@ProductPriceCache[book.Id]"
Category="@ProductPrimaryCategoryCache[book.Id]"
IsNew="@book.Enabled"
BookImageUrl="@book.ImageUrl"
OnCardClick="() => NavigateToProduct(book.Id)" />
}
</div>
}
else if (CurrentViewMode == ViewMode.List)
{
<div class="d-flex flex-column border rounded bg-white overflow-hidden shadow-sm animate-fade-in list-container-wrapper">
@foreach (var book in PaginatedBooks)
{
<div class="d-flex align-items-center justify-content-between py-3 px-2 list-row-item"
style="cursor: pointer;"
@onclick="() => NavigateToProduct(book.Id)">
<div class="d-flex align-items-center gap-4 structural-list-left">
<span class="text-dark fw-medium list-item-title">@book.Name</span>
<span class="text-muted small list-item-author">by @(ProductAuthorCache.TryGetValue(book.Id, out var authorName) ? authorName : "Unknown Author")</span>
<span class="badge bg-light text-secondary border rounded-pill px-2.5 py-1 font-monospace list-item-tag">@ProductPrimaryCategoryCache[book.Id].ToUpper()</span>
</div>
<div class="d-flex align-items-center gap-4">
@if (book.Enabled)
{
<span class="badge rounded-pill bg-danger-subtle text-danger px-2.5 py-1 list-new-badge">NEW</span>
}
<span class="text-dark font-monospace fw-medium list-item-price">R @ProductPriceCache[book.Id].ToString("N0")</span>
<button class="btn btn-link text-dark p-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="7" y1="17" x2="17" y2="7" /><polyline points="7,7 17,7 17,17" /></svg>
</button>
</div>
</div>
}
</div>
}
@if (HasMoreItems)
{
<div class="d-flex flex-column align-items-center justify-content-center mt-5 pt-4">
<button class="btn btn-outline-dark rounded-pill px-5 py-2.5 fw-medium shadow-sm" style="font-size: 0.85rem;" @onclick="LoadNextPage">
<span>Show More Artifacts</span>
</button>
</div>
}
</div>
<a class="back-to-top-btn d-flex align-items-center justify-content-center"
aria-label="Back to top"
href="#top-target">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5,12 12,5 19,12" /></svg>
</a>
@@ -0,0 +1,243 @@
using LiteCharms.Features;
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Categories;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Products.Models;
using LiteCharms.Features.Models;
namespace MidrandBookshop.Components.Pages;
public partial class Home : ComponentBase
{
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private BooksService BooksService { get; set; } = default!;
[Inject] private AuthorService AuthorService { get; set; } = default!;
[Inject] private CategoryService CategoryService { get; set; } = default!;
[Inject] private NavigationManager Navigation { get; set; } = default!;
[Inject] private HydrationService HydrationService { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
[Inject] private CartService CartService { get; set; } = default!;
[SupplyParameterFromQuery(Name = "q")] public string? SharedSearchQuery { get; set; }
[SupplyParameterFromQuery] public long? AuthorId { get; set; }
private ElementReference topTargetRef;
public enum ViewMode { Grid, List }
private ViewMode CurrentViewMode = ViewMode.Grid;
private string ActiveCategory = "All";
private bool ShowExpandedCategories = false;
private bool ShowFilterMenu = false;
private string SelectedSortOption = "default";
private string ActivePriceFilter = "all";
private bool OnlyShowNew = false;
private List<string> MainCategories { get; set; } = ["All"];
private List<string> DynamicExtendedCategories { get; set; } = [];
private int ItemsPerPage = 12;
private int VisibleCount = 12;
private List<Product> ProductsCollection { get; set; } = [];
protected string? ActiveAuthorFilterName { get; private set; }
private Dictionary<long, decimal> ProductPriceCache { get; set; } = [];
private Dictionary<long, string> ProductPrimaryCategoryCache { get; set; } = [];
private Dictionary<long, string> ProductAuthorCache { get; set; } = [];
private IEnumerable<Product> FilteredData
{
get
{
var data = ProductsCollection.AsEnumerable();
if (ActiveCategory != "All" && !AuthorId.HasValue)
{
data = data.Where(p => ProductPrimaryCategoryCache.ContainsKey(p.Id) &&
ProductPrimaryCategoryCache[p.Id] == ActiveCategory);
}
if (!string.IsNullOrWhiteSpace(SharedSearchQuery))
{
var q = SharedSearchQuery.Trim();
data = data.Where(p => (p.Name ?? "").Contains(q, StringComparison.OrdinalIgnoreCase));
}
if (ActivePriceFilter != "all")
{
data = data.Where(p =>
{
var price = ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m;
return ActivePriceFilter switch
{
"under-500" => price < 500m,
"500-1000" => price >= 500m && price <= 1000m,
"over-1000" => price > 1000m,
_ => true
};
});
}
if (OnlyShowNew) data = data.Where(p => p.Enabled);
data = SelectedSortOption switch
{
"price-low" => data.OrderBy(p => ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m),
"price-high" => data.OrderByDescending(p => ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m),
"title-asc" => data.OrderBy(p => p.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase),
"default" or _ => data
};
return data;
}
}
private IEnumerable<Product> PaginatedBooks => FilteredData.Take(VisibleCount);
private bool HasMoreItems => FilteredData.Count() > VisibleCount;
protected override async Task OnInitializedAsync()
{
if (CartService.ShoppingCart.Items.Count == 0)
await CartService.LoadCartFromStorageAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender == false && HydrationService.CartHydrated == false)
{
if(!CartService.ShoppingCart.CustomerId.HasValue)
await HydrationService.EnsureCustomerExistsAsync(CancellationToken);
}
}
protected override async Task OnParametersSetAsync() => await LoadCatalogDataAsync();
private async Task LoadCatalogDataAsync()
{
ProductsCollection.Clear();
ProductPriceCache.Clear();
ProductPrimaryCategoryCache.Clear();
ProductAuthorCache.Clear();
ActiveAuthorFilterName = null;
if (AuthorId.HasValue)
{
var authorResult = await AuthorService.GetAuthorAsync(AuthorId.Value);
if (authorResult.IsSuccess && authorResult.Value != null)
{
var author = authorResult.Value;
ActiveAuthorFilterName = author.PublisherType == PublisherTypes.Company && !string.IsNullOrWhiteSpace(author.Company)
? author.Company
: $"{author.Name} {author.LastName}".Trim();
}
var authorBooksResult = await BooksService.GetBooksByAuthorAsync(AuthorId.Value);
if (authorBooksResult.IsSuccess && authorBooksResult.Value != null)
{
foreach (var authorBook in authorBooksResult.Value)
{
if (authorBook.Product != null)
{
var product = authorBook.Product;
ProductsCollection.Add(product);
ProductPriceCache[product.Id] = product.Price?.Amount ?? 0m;
ProductAuthorCache[product.Id] = ActiveAuthorFilterName ?? "Unknown Author";
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
? (categoryResult.Value[0].Name ?? "General")
: "General";
}
}
}
return;
}
var selectionRange = new DateRange
{
From = new DateOnly(2020, 1, 1),
To = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)),
MaxRecords = 100
};
var allProductsResult = await ProductService.GetProductsAsync(0, selectionRange);
if (allProductsResult.IsSuccess && allProductsResult.Value != null)
{
ProductsCollection = allProductsResult.Value.ToList();
foreach (var product in ProductsCollection)
{
var priceResult = await ProductService.GetProductPriceAsync(product.Id);
ProductPriceCache[product.Id] = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
? (categoryResult.Value[0].Name ?? "General")
: "General";
ProductAuthorCache[product.Id] = !string.IsNullOrWhiteSpace(product.Metadata?.Manufacturer)
? product.Metadata.Manufacturer
: "Unknown Author";
}
}
var categoriesResult = await CategoryService.GetCategoriesAsync();
if (categoriesResult.IsSuccess && categoriesResult.Value != null)
{
var cleanNames = categoriesResult.Value
.Select(c => c.Name)
.Where(n => !string.IsNullOrEmpty(n)).Cast<string>()
.ToList();
MainCategories = ["All", .. cleanNames.Take(3)];
DynamicExtendedCategories = [.. cleanNames.Skip(3)];
}
}
private void ClearAuthorFilter()
{
var newUri = Navigation.GetUriWithQueryParameters("/", new Dictionary<string, object?> { { "authorId", null } });
Navigation.NavigateTo(newUri);
}
private void ResetFilters()
{
SelectedSortOption = "default";
ActivePriceFilter = "all";
OnlyShowNew = false;
VisibleCount = ItemsPerPage;
}
private void NavigateToProduct(long id) => Navigation.NavigateTo($"/product/{id}");
private void SetViewMode(ViewMode targetMode) => CurrentViewMode = targetMode;
private void SelectCategory(string categoryName)
{
ActiveCategory = categoryName;
VisibleCount = ItemsPerPage;
var updatedUri = Navigation.GetUriWithQueryParameters(Navigation.Uri, new Dictionary<string, object?> {{ "q", null }});
Navigation.NavigateTo(updatedUri);
}
private void ToggleExtraCategories() => ShowExpandedCategories = !ShowExpandedCategories;
private void ToggleFilterMenu() => ShowFilterMenu = !ShowFilterMenu;
private void ChangeSort(string sortOption) => SelectedSortOption = sortOption;
private void ChangePriceFilter(string priceBracket) { ActivePriceFilter = priceBracket; VisibleCount = ItemsPerPage; }
private void ToggleNewArrivalsOnly(ChangeEventArgs e) { OnlyShowNew = e.Value is bool b && b; VisibleCount = ItemsPerPage; }
private void LoadNextPage() { if (HasMoreItems) VisibleCount += ItemsPerPage; }
}
@@ -0,0 +1,177 @@
/* Expanding Search Input Controls */
.search-input-container {
max-width: 0;
opacity: 0;
overflow: hidden;
transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
}
.search-input-container.is-active {
max-width: 280px;
opacity: 1;
}
.custom-search-field {
font-size: 0.8rem;
height: 32px;
box-shadow: none !important;
}
/* Structural Layout Typography */
.branding-logo {
font-size: 1.1rem;
letter-spacing: -0.5px;
}
.text-hero-wrapper {
margin-top: 5rem;
margin-bottom: 3.5rem;
}
.master-headline {
letter-spacing: -1.5px;
font-weight: 400;
line-height: 1.15;
}
.sub-headline {
max-width: 520px;
font-size: 0.95rem;
font-weight: 300;
line-height: 1.6;
}
/* Filtering Dropdown Drawer Settings */
.filter-dropdown-panel {
border-radius: 12px;
}
.panel-section-heading {
letter-spacing: 0.5px;
font-size: 0.7rem;
}
.reset-link-btn {
font-size: 0.7rem;
}
/* Custom Minimal Separator Rule Layout */
.custom-milled-line {
height: 35px;
margin-top: 5px;
margin-bottom: 25px;
z-index: 0;
pointer-events: none;
}
.center-bloom-shadow {
bottom: 15px;
width: 55%;
height: 50px;
background: radial-gradient(ellipse at bottom, rgba(20, 20, 20, 0.07) 0%, rgba(40, 40, 40, 0.02) 60%, rgba(255, 255, 255, 0) 100%);
filter: blur(10px);
}
.core-horizontal-rule {
bottom: 15px;
height: 1px;
background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0) 100%);
}
/* View Engine Layout Presentation Variants: Grid Cards */
.book-grid-card {
background-color: #F1F1F1;
border-radius: 12px;
min-height: 380px;
}
.badge-new-arrival {
background-color: #E63946;
font-size: 0.75rem;
}
.book-spine-fallback {
width: 130px;
height: 185px;
font-size: 0.65rem;
letter-spacing: 1px;
border-radius: 2px;
}
.product-card-title {
font-size: 0.95rem;
font-weight: 500;
}
/* View Engine Layout Presentation Variants: List Rows */
.list-row-item {
border-bottom: 1px solid rgba(0,0,0,0.06);
transition: background-color 0.2s ease;
}
.list-row-item:hover {
background-color: rgba(0,0,0,0.01);
}
.structural-list-left {
flex: 2;
}
.list-item-title {
font-size: 0.95rem;
min-width: 260px;
}
.list-item-author {
min-width: 160px;
}
.list-item-tag {
font-size: 0.65rem;
}
.list-new-badge {
font-size: 0.65rem;
}
.list-item-price {
font-size: 0.9rem;
min-width: 80px;
text-align: right;
}
/* Enable native smooth scrolling page-wide */
html {
scroll-behavior: smooth;
}
/* Back to Top Icon Layout Presentation */
.back-to-top-btn {
position: fixed !important;
bottom: 32px;
right: 32px;
width: 42px;
height: 42px;
border-radius: 50% !important;
text-decoration: none !important;
/* Dual-tone contrast visibility profile */
background-color: #ffffff !important;
color: #1a1a1a !important;
border: 2px solid #1a1a1a !important;
box-shadow: 0 0 0 2px #ffffff, 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 2147483647 !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.back-to-top-btn:hover {
transform: translateY(-4px);
background-color: #1a1a1a !important;
color: #ffffff !important;
border-color: #ffffff !important;
box-shadow: 0 0 0 2px #1a1a1a, 0 6px 16px rgba(0, 0, 0, 0.25) !important;
}
/* Direct the smooth scroll action straight to the layout viewport boundary */
#top-target {
scroll-margin-top: 100vh;
}
@@ -1,5 +1,35 @@
@page "/not-found"
@layout MainLayout
@inject NavigationManager Navigation
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
<div class="artistic-404-container">
<div class="container h-100">
<div class="row align-items-center justify-content-center min-vh-70 py-5">
<div class="col-12 col-md-5 d-flex justify-content-center mb-5 mb-md-0">
<div class="artistic-svg-wrapper">
<svg viewBox="0 0 400 400" width="350" height="350" stroke="#1A1A1A" stroke-width="2" fill="none">
<path d="M60 80 Q80 60 100 80" />
<path d="M100 60 Q120 40 140 60" />
<path d="M280 70 Q300 50 320 70" />
<path d="M100 200 L200 180 L300 200 L300 300 L200 320 L100 300 Z" fill="#F8F8F8" />
<path d="M200 180 L200 320" />
<path d="M120 220 L180 220 M120 250 L180 250 M220 220 L280 220 M220 250 L280 250" stroke="#DDD" />
<circle cx="230" cy="230" r="40" />
<line x1="258" y1="258" x2="290" y2="290" />
</svg>
</div>
</div>
<div class="col-12 col-md-6 offset-md-1">
<div class="meta-tag font-monospace text-uppercase text-muted mb-3">System Index // Uncharted Territory</div>
<h1 class="display-4 text-dark mb-4 editorial-headline">Nothing was found<br />in this search.</h1>
<p class="text-muted mb-5 body-manifesto-text">
Even with careful scrutiny, the requested volume remains elusive. It appears the shelf you are exploring is currently vacant.
</p>
<button class="btn btn-dark rounded-pill px-5 py-3" @onclick='() => Navigation.NavigateTo("/")'>Return to Collection</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,76 @@
.artistic-404-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.terrain-wrapper {
display: flex;
justify-content: center;
align-items: center;
/* Static, grounded look */
border-bottom: 2px solid #f0f0f0;
padding-bottom: 2rem;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
}
.btn-dark {
transition: background 0.3s ease;
}
.btn-dark:hover {
background: #444;
}
.artistic-404-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.artistic-svg-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
}
@@ -0,0 +1,40 @@
@page "/payment-failed"
@rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize]
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 text-center">
<div class="mb-4">
<div class="d-inline-block p-4 rounded-circle bg-danger bg-opacity-10 text-danger mb-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h1 class="fw-bold mb-3">Payment Cancelled</h1>
<p class="text-muted fs-5">We couldn't process your transaction. Don't worry, no money was deducted from your account.</p>
<div class="bg-light p-3 rounded mt-4">
<p class="mb-0 text-muted small text-uppercase fw-bold">Common Causes</p>
<p class="mb-0 fs-6 text-dark mt-1">The order was cancelled / insufficient funds, incorrect card details, or a temporary bank gateway timeout.</p>
</div>
</div>
<div class="d-grid gap-3 mt-5">
<div class="row g-3">
<div class="col-6">
<a href="/" class="btn btn-outline-dark w-100 rounded-pill py-3">View Store</a>
</div>
<div class="col-6">
<a href="/support" class="btn btn-outline-dark w-100 rounded-pill py-3">Get Help</a>
</div>
</div>
</div>
<p class="mt-5 text-muted small">If you noticed a charge or have any order questions, please contact our support desk with your account email <strong>shop@litecharms.co.za</strong>.</p>
</div>
</div>
</div>
@@ -0,0 +1,72 @@
using LiteCharms.Features;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Pages;
public partial class PaymentFailed
{
[Inject] public CartService CartService { get; set; } = default!;
[Inject] public OrderService OrderService { get; set; } = default!;
[Inject] private CustomerService CustomerService { get; set; } = default!;
[Inject] public PaymentService PaymentService { get; set; } = default!;
[Inject] public HashService HashService { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
private ClaimsPrincipal? User { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "reference")]
public string? PaymentReference { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState!.User;
if (User?.Identity?.IsAuthenticated == false) Navigation.NavigateTo("/login");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
long orderId = HashService.DecodeLongIdHash(PaymentReference!).Value;
var customerEmail = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
var customerFetch = await CustomerService.GetCustomerAsync(customerEmail, CancellationToken);
if (customerFetch.IsFailed) return;
long customerId = customerFetch.Value.Id;
var orderUpdateResult = await OrderService.UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, CancellationToken);
if (orderUpdateResult.IsFailed) return;
var paymentIdFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentIdFetch.IsFailed) return;
await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
CustomerId = customerId,
OrderId = orderId,
PaymentGatewayId = 1,
PaymentGatewayReference = PaymentReference,
PaymentId = paymentIdFetch.Value.Id,
Status = LedgerStatuses.Cancelled
}, CancellationToken);
CartService.Clear();
CartService.ShoppingCart.OrderId = null;
await CartService.SaveCartToStorageAsync();
CartService.NotifyStateChanged();
}
}
@@ -0,0 +1,39 @@
@page "/payment-success"
@rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize]
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 text-center">
<div class="mb-4">
<div class="d-inline-block p-4 rounded-circle bg-success bg-opacity-10 text-success mb-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</div>
<h1 class="fw-bold mb-3">Order Confirmed</h1>
<p class="text-muted fs-5">Thank you for shopping with Midrand Books. Your order has been received and is being processed.</p>
<div class="bg-light p-3 rounded mt-4">
<p class="mb-0 text-muted small text-uppercase fw-bold">Order Number</p>
<h5 class="fw-bold mb-0">@PaymentReference</h5>
</div>
</div>
<div class="d-grid gap-3 mt-5">
<a href="/" class="btn btn-dark btn-lg rounded-pill py-3">Continue Shopping</a>
<div class="row g-3">
<div class="col-6">
<a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Order History</a>
</div>
<div class="col-6">
<a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Track Order</a>
</div>
</div>
</div>
<p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>@CustomerEmail</strong>.</p>
</div>
</div>
</div>
@@ -0,0 +1,70 @@
using LiteCharms.Features;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace MidrandBookshop.Components.Pages;
public partial class PaymentSuccess
{
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private CustomerService CustomerService { get; set; } = default!;
[Inject] private PaymentService PaymentService { get; set; } = default!;
[Inject] private HashService HashService { get; set; } = default!;
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
[Inject] public IToastService ToastService { get; set; } = default!;
[Parameter]
[SupplyParameterFromQuery(Name = "reference")]
public string? PaymentReference { get; set; }
private ClaimsPrincipal? User { get; set; }
private string? CustomerEmail { get; set; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState!.User;
if (User?.Identity?.IsAuthenticated == false) Navigation.NavigateTo("/login");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
long orderId = HashService.DecodeLongIdHash(PaymentReference!).Value;
string orderHash = HashService.HashEncodeLongId(orderId).Value;
CustomerEmail = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
var customerFetch = await CustomerService.GetCustomerAsync(CustomerEmail, CancellationToken);
if (customerFetch.IsFailed) return;
long customerId = customerFetch.Value.Id;
var paymentIdFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentIdFetch.IsFailed) return;
await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
CustomerId = customerId,
OrderId = orderId,
PaymentGatewayId = 1,
PaymentGatewayReference = orderHash,
PaymentId = paymentIdFetch.Value.Id,
Status = LedgerStatuses.Changed
}, CancellationToken);
CartService.Clear();
CartService.ShoppingCart.OrderId = null;
await CartService.SaveCartToStorageAsync();
CartService.NotifyStateChanged();
}
}
@@ -0,0 +1,83 @@
@page "/privacy"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="legal-page-container py-5">
<header class="legal-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Regulatory Governance</span>
<h1 class="legal-main-title fw-bold">Privacy & Data Protection Policy</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is a trading name for <strong>Lite Charms (Pty) Ltd</strong>, a company duly incorporated and registered in accordance with the Companies Act of South Africa (Registration No: [2020/269438/07]).
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="legal-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("section-processing")' class="index-btn text-start">1. Lawful Processing</button>
<button @onclick='() => ScrollToSection("section-collection")' class="index-btn text-start">2. Information We Collect</button>
<button @onclick='() => ScrollToSection("section-identity")' class="index-btn text-start">3. Identity Nodes</button>
<button @onclick='() => ScrollToSection("section-payment")' class="index-btn text-start">4. PayFast Gateway</button>
<button @onclick='() => ScrollToSection("section-rights")' class="index-btn text-start">5. Data Subject Rights</button>
</div>
</div>
<div class="col-lg-9">
<article class="legal-article-body">
<section id="section-processing" class="legal-section mb-5">
<h3 class="section-title">1. Commitment to POPIA Compliance</h3>
<p>
Lite Charms (Pty) Ltd trading as Midrand Books ("we", "us", "our") is dedicated to protecting the privacy and personal data of our customers in strict alignment with the <strong>Protection of Personal Information Act, No. 4 of 2013 (POPIA)</strong> of South Africa. We act as the "Responsible Party" for all data collected across our web architecture.
</p>
</section>
<section id="section-collection" class="legal-section mb-5">
<h3 class="section-title">2. Collection of Personal Information</h3>
<p>
We collect personal information solely to facilitate the cataloging, transaction, and dispatch of archival material. This information includes, but is not limited to:
</p>
<ul>
<li>Identity Details: Name, email address, and verification markers via federated claim contexts.</li>
<li>Logistics Metrics: Physical shipping addresses and localized postal codes for courier assignment within South African borders.</li>
<li>Digital Footprints: IP addresses, access telemetry, and structural tracking metrics.</li>
</ul>
</section>
<section id="section-identity" class="legal-section mb-5">
<h3 class="section-title">3. Decentralized Claims & Identity Security</h3>
<p>
For enhanced systemic protection, your core security credentials, access authentication methods, and identity validation vectors are managed externally through our secure cross-tenant single-sign-on architecture (<strong>STS Security Cluster</strong>). Lite Charms (Pty) Ltd does not store or process raw account passwords locally on its primary catalog databases.
</p>
</section>
<section id="section-payment" class="legal-section mb-5">
<h3 class="section-title">4. Secure Financial Handover Disclosures</h3>
<p>
All credit card, Instant EFT, and electronic payments are handled through a direct, secure integration handshake with the <strong>PayFast (Pty) Ltd</strong> merchant payment system. Midrand Books never captures, indexes, or retains sensitive primary account numbers (PAN), CVV digits, or banking pins. Payment states are transmitted securely using cryptographically signed verification payloads.
</p>
</section>
<section id="section-rights" class="legal-section mb-5">
<h3 class="section-title">5. Data Subject Rights & Information Officer</h3>
<p>
In accordance with POPIA parameters, you reserve explicit rights to request access to, correction of, or total destruction of your personal information stored inside our archives. For any data protection inquiries, or to contact our designated Information Officer, please direct formal correspondence to <code>privacy@midrandbooks.co.za</code>.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,91 @@
/* ==========================================================================
Midrand Books — Fine Press Legal Styles Document
========================================================================== */
.legal-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.legal-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Stack --- */
.legal-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Content Typography Alignment --- */
.legal-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem; /* Adds padding baseline context when aligned via smooth-scroll */
}
.legal-section p {
margin-bottom: 1.2rem;
}
.legal-section ul {
padding-left: 1.25rem;
margin-bottom: 1.5rem;
}
.legal-section li {
margin-bottom: 0.5rem;
}
code {
background-color: #F5F5F4;
color: #111111;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.88rem;
}
@@ -0,0 +1,148 @@
@page "/product/{BookId:long}"
@rendermode InteractiveServer
<div class="product-container">
@if (IsLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-dark" role="status">
<span class="visually-hidden">Loading product details...</span>
</div>
<p class="text-muted mt-2">Loading curated details...</p>
</div>
}
else if (CurrentProduct == null)
{
<div class="text-center py-5 animate-fade-in">
<svg class="text-muted mb-3" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<h2 class="h5 text-dark fw-medium">Artifact Not Found</h2>
<p class="text-muted small">The requested book could not be found in our current catalog.</p>
<button class="btn btn-dark btn-sm rounded-pill px-4 mt-2" @onclick='() => Navigation.NavigateTo("/")'>Return to Books</button>
</div>
}
else
{
<nav class="breadcrumb">
<span @onclick='() => Navigation.NavigateTo("/")' class="crumb-link">Books</span>
<span class="crumb-separator">/</span>
<span class="crumb-current">@CurrentProduct.Name</span>
</nav>
<div class="product-layout">
<div class="gallery-section">
<div class="main-image-wrapper">
@if (!string.IsNullOrWhiteSpace(ActiveImageUrl))
{
<img src="@ActiveImageUrl" alt="@CurrentProduct.Name" class="main-image" />
}
else
{
<div class="book-spine-fallback-large bg-dark d-flex align-items-center justify-content-center text-center p-4 text-white-50 shadow-sm">
<div>
<span class="fw-semibold tracking-wider d-block">@PrimaryCategory.ToUpper()</span>
<span class="opacity-50 small" style="font-size: 0.65rem;">CURATED EDITION</span>
</div>
</div>
}
<div class="format-badges">
@if (CurrentProduct.Enabled)
{
<span class="badge badge-physical">Physical Book</span>
}
@if (StockCount <= 0)
{
<span class="badge bg-danger text-white border-0">Out of Stock</span>
}
else if (StockCount <= 3)
{
<span class="badge bg-warning text-dark border-0">Only @StockCount Left</span>
}
else
{
<span class="badge badge-ebook">In Stock (@StockCount available)</span>
}
</div>
</div>
@if (Thumbnails.Count > 1)
{
<div class="thumbnail-grid">
@foreach (var img in Thumbnails)
{
var currentImg = img;
<div class="thumbnail-wrapper @(ActiveImageUrl == currentImg ? "active" : "")"
@onclick="() => ActiveImageUrl = currentImg">
<img src="@currentImg" alt="Catalog gallery thumbnail" />
</div>
}
</div>
}
</div>
<div class="details-section">
<div class="meta-header">
<span class="author-name">@AuthorName</span>
<div class="rating-stars">
@for (int i = 1; i <= 5; i++)
{
<span class="star @(i <= 4 ? "filled" : "")">★</span>
}
<span class="rating-text">(4.8)</span>
</div>
</div>
<h1 class="product-title">@CurrentProduct.Name</h1>
<div class="product-price">R @LivePrice.ToString("N2")</div>
<div class="purchase-actions">
<div class="quantity-picker @(StockCount <= 0 ? "opacity-50 pointer-events-none" : "")">
<button @onclick="DecreaseQty" class="qty-btn" disabled="@(StockCount <= 0 || Quantity <= 1)">-</button>
<span class="qty-val">@(StockCount <= 0 ? 0 : Quantity)</span>
<button @onclick="IncreaseQty" class="qty-btn" disabled="@(StockCount <= 0 || Quantity >= StockCount)">+</button>
</div>
@if (StockCount > 0)
{
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
Add to Cart
</button>
}
else
{
<button class="btn-add-to-cart bg-secondary text-white-50 cursor-not-allowed" disabled>
Sold Out
</button>
}
</div>
<hr class="divider" />
@if (!string.IsNullOrWhiteSpace(CurrentProduct.Description))
{
<div class="info-block">
<h3>Description</h3>
<p class="description-text">@CurrentProduct.Description</p>
</div>
}
<div class="info-block author-bio-card">
<h3>About the Author</h3>
<p class="author-bio">
@(string.IsNullOrWhiteSpace(CurrentProduct.Metadata?.Manufacturer)
? "Details regarding this publisher/author are maintained inside our curated archives."
: $"{CurrentProduct.Metadata.Manufacturer} is globally recognized for their direct contribution to this specific genre spectrum.")
</p>
<button class="btn-text-link" @onclick="ViewAllAuthorBooks">
View all books by @AuthorName →
</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,154 @@
using LiteCharms.Features;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Products.Models;
using Microsoft.AspNetCore.Components;
namespace MidrandBookshop.Components.Pages;
public partial class ProductView : ComponentBase
{
[Parameter] public long BookId { get; set; }
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private AuthorService AuthorService { get; set; } = default!;
[Inject] private NavigationManager Navigation { get; set; } = default!;
[Inject] private CartService CartService { get; set; } = default!;
protected Cart ShoppingCart => CartService?.ShoppingCart!;
protected bool IsLoading { get; private set; } = true;
protected Product? CurrentProduct { get; private set; }
protected ProductPrice? CurrentPrice { get; private set; }
protected decimal LivePrice { get; private set; } = 0.00m;
protected string AuthorName { get; private set; } = "Unknown Author";
protected string PrimaryCategory { get; private set; } = "General";
protected string ActiveImageUrl { get; set; } = string.Empty;
protected List<string> Thumbnails { get; private set; } = [];
protected int Quantity { get; private set; } = 1;
// Track real-time stock limits
protected int StockCount { get; private set; } = 5;
protected Author? CurrentAuthor { get; private set; }
protected override async Task OnParametersSetAsync()
{
try
{
IsLoading = true;
CurrentProduct = null;
CurrentAuthor = null;
Thumbnails.Clear();
var productResult = await ProductService.GetProductAsync(BookId);
if (productResult.IsSuccess && productResult.Value != null)
{
CurrentProduct = productResult.Value;
AuthorName = CurrentProduct.Metadata?.Manufacturer ?? "Unknown Author";
// Mapping real stock integers if available on your Product metadata entity model
// StockCount = CurrentProduct.InventoryCount;
var priceResult = await ProductService.GetProductPriceAsync(BookId);
LivePrice = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
CurrentPrice = priceResult.IsSuccess ? priceResult.Value : null;
var categoryResult = await ProductService.GetProductCategoriesAsync(BookId);
if (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
PrimaryCategory = categoryResult.Value[0].Name ?? "General";
var authorResult = await AuthorService.GetAuthorByProductIdAsync(BookId);
if (authorResult.IsSuccess && authorResult.Value != null)
{
CurrentAuthor = authorResult.Value;
if (CurrentAuthor.PublisherType == PublisherTypes.Company && !string.IsNullOrWhiteSpace(CurrentAuthor.Company))
AuthorName = CurrentAuthor.Company;
else
AuthorName = $"{CurrentAuthor.Name} {CurrentAuthor.LastName}".Trim();
}
if (!string.IsNullOrWhiteSpace(CurrentProduct.ImageUrl))
Thumbnails.Add(CurrentProduct.ImageUrl);
if (CurrentProduct.ThumbnailUrls != null && CurrentProduct.ThumbnailUrls?.Length != 0)
foreach (var url in CurrentProduct.ThumbnailUrls!)
if (!string.IsNullOrWhiteSpace(url) && !Thumbnails.Contains(url)) Thumbnails.Add(url);
ActiveImageUrl = Thumbnails.FirstOrDefault() ?? string.Empty;
}
}
catch (Exception)
{
CurrentProduct = null;
CurrentPrice = null;
}
finally
{
IsLoading = false;
}
}
protected async void IncreaseQty()
{
// Enforce maximum stock bounds limits natively during counter picking
if (CurrentPrice is not null && Quantity < StockCount)
{
CartService.UpdateQuantity(CurrentPrice!.Id, 1);
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
await CartService.SaveCartToStorageAsync();
StateHasChanged();
}
}
protected async void DecreaseQty()
{
if (Quantity > 1)
{
CartService.UpdateQuantity(CurrentPrice!.Id, -1);
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
await CartService.SaveCartToStorageAsync();
StateHasChanged();
}
}
protected async void HandleAddToCart()
{
if (CurrentProduct == null || StockCount <= 0) return;
if (CurrentPrice is not null)
{
if (ShoppingCart.Items.Any(p => p.Price!.Id == CurrentPrice.Id))
{
var currentInCart = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
if (currentInCart >= StockCount) return;
CartService.UpdateQuantity(CurrentPrice.Id, 1);
await CartService.SaveCartToStorageAsync();
return;
}
CartService.AddItem(CurrentPrice, CurrentProduct, CurrentAuthor!);
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
await CartService.SaveCartToStorageAsync();
StateHasChanged();
}
}
protected void ViewAllAuthorBooks()
{
if (CurrentAuthor is not null)
Navigation.NavigateTo($"/?authorId={CurrentAuthor.Id}");
}
}
@@ -0,0 +1,379 @@
/* ==========================================================================
Structural Layout Containers
========================================================================== */
.product-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.product-layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 4rem;
}
/* ==========================================================================
Breadcrumb Navigation Links
========================================================================== */
.breadcrumb {
font-size: 0.85rem;
margin-bottom: 2.5rem;
color: #8c8c8c;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.crumb-link {
cursor: pointer;
transition: color 0.2s ease;
}
.crumb-link:hover {
color: #111;
}
.crumb-separator {
margin: 0 0.5rem;
}
.crumb-current {
color: #111;
font-weight: 500;
}
/* ==========================================================================
Left Section: Media Gallery Components
========================================================================== */
.gallery-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.main-image-wrapper {
position: relative;
background-color: #f9f9f9;
border-radius: 8px;
padding: 3rem 3rem 4.5rem 3rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 480px;
width: 100%;
}
.main-image {
max-height: 450px;
max-width: 100%;
object-fit: contain;
mix-blend-mode: multiply;
}
/* Dynamic Overlaid Attribute Badges - Centered flawlessly without stacking */
.format-badges {
position: absolute !important;
bottom: 1.5rem !important;
left: 50% !important; /* Move anchor point to exactly half-width */
right: auto !important;
transform: translateX(-50%) !important; /* Offset width symmetrically to center perfectly */
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important; /* Firmly prevents items from stacking vertically */
justify-content: center !important;
align-items: center !important;
gap: 0.5rem !important;
width: max-content !important; /* Expands safely according to badges text values */
max-width: 90% !important; /* Keeps bounding box clean of parent borders */
}
.badge {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.35rem 0.75rem;
border-radius: 100px;
font-weight: 600;
border: 1px solid transparent;
display: inline-block !important;
white-space: nowrap !important; /* Protects badge inner labels from broken line wrapping */
}
.badge-physical {
background-color: #111;
color: #fff;
}
.badge-ebook {
background-color: #fff;
color: #111;
border-color: #e5e5e5;
}
.badge-online {
background-color: #f0fdf4;
color: #166534;
border-color: #bbf7d0;
}
/* Interactive Gallery Thumbnails Grid */
.thumbnail-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.thumbnail-wrapper {
width: 80px;
height: 80px;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 0.5rem;
cursor: pointer;
background: #f9f9f9;
transition: all 0.2s ease;
}
.thumbnail-wrapper:hover,
.thumbnail-wrapper.active {
border-color: #111;
}
.thumbnail-wrapper img {
width: 100%;
height: 100%;
object-fit: contain;
mix-blend-mode: multiply;
}
/* ==========================================================================
Right Section: Product Details & Typography Controls
========================================================================== */
.details-section {
display: flex;
flex-direction: column;
}
.meta-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.author-name {
font-size: 1.1rem;
color: #666;
}
/* Review Star Components */
.rating-stars {
display: flex;
align-items: center;
gap: 0.2rem;
}
.star {
color: #e5e5e5;
font-size: 1.1rem;
}
.star.filled {
color: #ffc107;
}
.rating-text {
font-size: 0.85rem;
color: #666;
margin-left: 0.4rem;
}
.product-title {
font-size: 2.5rem;
font-family: 'Playfair Display', serif, Georgia;
font-weight: 400;
line-height: 1.2;
margin-bottom: 1rem;
color: #111;
}
.product-price {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 2rem;
color: #111;
}
/* ==========================================================================
Interactive E-Commerce Selection Bars
========================================================================== */
.purchase-actions {
display: flex;
gap: 1rem;
margin-bottom: 2.5rem;
}
.quantity-picker {
display: flex;
align-items: center;
border: 1px solid #e5e5e5;
border-radius: 100px;
padding: 0.25rem 0.5rem;
}
.qty-btn {
background: none;
border: none;
width: 36px;
height: 36px;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.qty-btn:hover {
background-color: #f5f5f5;
}
.qty-val {
min-width: 30px;
text-align: center;
font-weight: 500;
}
.btn-add-to-cart {
flex-grow: 1;
background-color: #111;
color: #fff;
border: none;
border-radius: 100px;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 0.5rem 1.5rem;
}
.btn-add-to-cart:hover {
background-color: #2a2a2a;
}
.divider {
border: 0;
border-top: 1px solid #eee;
margin-bottom: 2rem;
}
/* ==========================================================================
Informational Text Elements & Links
========================================================================== */
.info-block {
margin-bottom: 2rem;
}
.info-block h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8c8c8c;
margin-bottom: 0.75rem;
}
.description-text,
.author-bio {
font-size: 0.95rem;
line-height: 1.6;
color: #444;
}
.author-bio-card {
background-color: #fafafa;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.btn-text-link {
background: none;
border: none;
padding: 0;
margin-top: 1rem;
font-weight: 600;
font-size: 0.9rem;
color: #111;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 4px;
}
.btn-text-link:hover {
color: #444;
}
/* ==========================================================================
Responsive Adaptations & Breakpoints
========================================================================== */
@media (max-width: 992px) {
.product-layout {
grid-template-columns: 1fr;
gap: 2.5rem;
}
.main-image-wrapper {
min-height: 380px;
}
}
@media (max-width: 576px) {
.product-container {
padding: 1rem 1rem;
}
.main-image-wrapper {
min-height: 320px;
padding: 2rem 1rem 4.5rem 1rem; /* Extra padding baseline clearance */
}
.main-image {
max-height: 240px; /* Fluidly limits image height to avoid overlay overlapping */
}
.format-badges {
bottom: 1.25rem !important; /* Fits beautifully aligned at the bottom center */
gap: 0.4rem !important;
}
.badge {
font-size: 0.65rem !important; /* Clean uniform scaling factor to clear margins */
padding: 0.25rem 0.65rem !important;
}
.product-title {
font-size: 1.85rem;
}
.purchase-actions {
flex-direction: column;
gap: 0.75rem;
}
.quantity-picker {
justify-content: space-between;
width: 100%;
padding: 0.5rem;
}
.btn-add-to-cart {
width: 100%;
padding: 0.85rem;
}
}
@@ -0,0 +1,89 @@
@page "/terms"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="legal-page-container py-5">
<header class="legal-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Commercial Agreement</span>
<h1 class="legal-main-title fw-bold">Terms of Sale & Service</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is a trading name for <strong>Lite Charms (Pty) Ltd</strong>, a private company incorporated and governed under the regulations of the Companies Act of the Republic of South Africa.
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="legal-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("section-formation")' class="index-btn text-start">1. Contract Formation</button>
<button @onclick='() => ScrollToSection("section-pricing")' class="index-btn text-start">2. Currency & Pricing</button>
<button @onclick='() => ScrollToSection("section-logistics")' class="index-btn text-start">3. Delivery & Risk</button>
<button @onclick='() => ScrollToSection("section-cooling")' class="index-btn text-start">4. CPA Cooling-Off</button>
<button @onclick='() => ScrollToSection("section-liability")' class="index-btn text-start">5. Liability Shield</button>
<button @onclick='() => ScrollToSection("section-jurisdiction")' class="index-btn text-start">6. Jurisdiction</button>
</div>
</div>
<div class="col-lg-9">
<article class="legal-article-body">
<section id="section-formation" class="legal-section mb-5">
<h3 class="section-title">1. Electronic Contract Formation</h3>
<p>
By placing an order on this digital repository layout, you accept these Terms and Conditions in full. In accordance with Section 11 of the <strong>Electronic Communications and Transactions Act, No. 25 of 2002 (ECTA)</strong>, these terms are legally binding and enforceable from the second you submit an item request. An order request is only confirmed as a final contract of sale once funds are cleared and the physical inventory has been packed for transit by Lite Charms (Pty) Ltd.
</p>
</section>
<section id="section-pricing" class="legal-section mb-5">
<h3 class="section-title">2. Currency, Value Added Tax, & Inventory Realism</h3>
<p>
All book pricing listed on our site is formatted exclusively in <strong>South African Rand (ZAR)</strong>. While we endeavor to ensure immaculate catalog records, typographical pricing errors may occasionally manifest due to currency valuation updates. In such rare anomalies, we explicitly retain the legal prerogative to cancel any affected items prior to dispatch and refund your transaction balance.
</p>
</section>
<section id="section-logistics" class="legal-section mb-5">
<h3 class="section-title">3. Logistics Dispatch & Risk Pass-Through</h3>
<p>
Delivery fulfillment is handled via trusted independent third-party domestic courier providers. Ownership and operational risk concerning physical destruction, item degradation, or total package loss transit pass directly to the consumer at the immediate instant the shipment container leaves our local loading bay docks.
</p>
</section>
<section id="section-cooling" class="legal-section mb-5">
<h3 class="section-title">4. Statutory Returns & CPA Cooling-Off Windows</h3>
<p>
Pursuant to Section 44 of ECTA and the directives of the <strong>Consumer Protection Act, No. 68 of 2008 (CPA)</strong>, you maintain a statutory cooling-off window of <strong>seven (7) days</strong> following delivery receipt to cancel your transaction without justification, provided the items remain perfectly pristine, unread, and factory-sealed. Return logistics freight costs under a simple cooling-off exercise remain entirely the accountability of the purchaser.
</p>
</section>
<section id="section-liability" class="legal-section mb-5 protective-shield-callout">
<h3 class="section-title text-danger-serif">5. Complete Limitation of Liability Shield</h3>
<p class="fw-bold">
CRITICAL LEGAL CLAUSE — PLEASE READ CAREFULLY:
</p>
<p>
To the maximum limit permitted by Section 61 of the Consumer Protection Act, Lite Charms (Pty) Ltd, its underlying software system developers, and structural directors maintain zero liability for any direct, collateral, accidental, or punitive damages resulting from server connectivity breaks, digital database interruptions, payment handoff errors, or systemic down-times. We offer our catalog system entirely on an "As-Is" and "As-Available" functional framework.
</p>
</section>
<section id="section-jurisdiction" class="legal-section mb-5">
<h3 class="section-title">6. Governing Law & Jurisdiction</h3>
<p>
This commercial agreement, catalog usage framework, and transaction architecture are subject strictly to the laws and legal structures of the <strong>Republic of South Africa</strong>. Any disputes arising directly out of these digital terms shall be filed and litigated exclusively under the jurisdiction of the High Court of South Africa.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,86 @@
/* ==========================================================================
Midrand Books — Fine Press Terms Layout Styles
========================================================================== */
.legal-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.legal-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Stack --- */
.legal-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Content Typography Alignment --- */
.legal-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem; /* Clear alignment offset */
}
.text-danger-serif {
color: #A34843 !important;
}
.legal-section p {
margin-bottom: 1.2rem;
}
/* Stark Premium Callout Box for Limiting Liability Under South African Rules */
.protective-shield-callout {
background-color: #FDFBFB;
border: 1px solid rgba(163, 72, 67, 0.15);
padding: 2rem;
border-radius: 8px;
}
@@ -0,0 +1,33 @@
@inject NavigationManager Navigation
<div class="artistic-redirect-container">
<div class="container h-100">
<div class="row align-items-center justify-content-center min-vh-70 py-5">
<div class="col-12 col-md-5 d-flex flex-column align-items-center text-center">
<div class="artistic-svg-wrapper mb-4">
<svg viewBox="0 0 400 400" width="280" height="280" stroke="#1A1A1A" stroke-width="2" fill="none">
<path d="M60 300 L340 300" stroke-dasharray="4 4" />
<path d="M120 300 L120 160 Q120 100 200 100 Q280 100 280 160 L280 300" />
<path d="M170 170 L230 170 L220 230 L200 250 L180 230 Z" fill="#F8F8F8" />
<circle cx="200" cy="195" r="8" fill="#1A1A1A" />
<line x1="200" y1="203" x2="200" y2="218" stroke-width="3" />
<circle class="animated-pulse-ring" cx="200" cy="180" r="60" stroke="#DDD" stroke-width="1" />
<circle class="animated-pulse-ring-delayed" cx="200" cy="180" r="45" stroke="#EEE" stroke-width="1" />
</svg>
</div>
<div class="editorial-spinner mb-4"></div>
<div class="meta-tag font-monospace text-uppercase text-muted mb-2">Secure Connection // Identity Node</div>
<h2 class="fw-bold text-dark mb-3 editorial-subheadline">Verifying Session</h2>
<p class="text-muted body-manifesto-text-center">
Redirecting you safely to the identity gateway to authorize your access permissions...
</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,12 @@
namespace MidrandBookshop.Components;
public partial class RedirectToLogin
{
protected override void OnInitialized()
{
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
var sanitizedRedirectPath = relativePath.StartsWith('/') ? relativePath : $"/{relativePath}";
Navigation.NavigateTo($"/login?redirectUri={Uri.EscapeDataString(sanitizedRedirectPath)}", forceLoad: true);
}
}
@@ -0,0 +1,77 @@
.artistic-redirect-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.artistic-svg-wrapper {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.editorial-subheadline {
font-family: 'Playfair Display', serif;
font-size: 2.2rem;
line-height: 1.2;
letter-spacing: -0.01em;
color: #111111;
}
.body-manifesto-text-center {
font-size: 1rem;
max-width: 360px;
line-height: 1.6;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
}
/* Custom Minimalist Spinner Concept */
.editorial-spinner {
width: 40px;
height: 40px;
border: 2px solid #F0F0F0;
border-top: 2px solid #1A1A1A; /* High contrast matching the SVG path line color */
border-radius: 50%;
animation: spin-editorial 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* Subtle background ring pulse paths */
.animated-pulse-ring {
transform-origin: 200px 180px;
animation: gate-pulse 3s ease-in-out infinite;
}
.animated-pulse-ring-delayed {
transform-origin: 200px 180px;
animation: gate-pulse 3s ease-in-out infinite;
animation-delay: 1.5s;
}
@keyframes spin-editorial {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes gate-pulse {
0%, 100% {
transform: scale(0.95);
opacity: 0.4;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
}
+20 -6
View File
@@ -1,6 +1,20 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@using Microsoft.AspNetCore.Components.Authorization
@using MidrandBookshop.Components.Pages
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<NotFound />
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
@@ -9,3 +9,4 @@
@using MidrandBookshop
@using MidrandBookshop.Components
@using MidrandBookshop.Components.Layout
@using Microsoft.AspNetCore.Authorization;@using Microsoft.AspNetCore.Components.Authorization
+116
View File
@@ -0,0 +1,116 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products;
namespace MidrandBookshop;
public sealed class HydrationService(AuthenticationStateProvider authStateProvider, CustomerService customerService,
ProductService productService, OrderService orderService, BooksService booksService, AuthorService authorService,
CartService cartService)
{
private Cart ShoppingCart => cartService.ShoppingCart;
private AuthenticationState? AuthState { get; set; }
private ClaimsPrincipal? User { get; set; }
public bool CartHydrated { get; set; }
public async Task EnsureCustomerExistsAsync(CancellationToken cancellationToken = default)
{
if (ShoppingCart.CustomerId > 0) return;
AuthState = await authStateProvider.GetAuthenticationStateAsync();
User = AuthState!.User;
if (User?.Identity?.IsAuthenticated == false) return;
var email = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
var existingCustomer = await customerService.GetCustomerAsync(email, cancellationToken);
if (existingCustomer.IsSuccess)
ShoppingCart.CustomerId = existingCustomer.Value.Id;
if (existingCustomer.IsFailed)
{
var name = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty;
var lastname = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value ?? string.Empty;
var mobile = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.MobilePhone)?.Value ?? string.Empty;
var customerCreate = await customerService.CreateCustomerAsync(new CreateCustomer { Email = email }, cancellationToken);
if (customerCreate.IsSuccess && !string.IsNullOrWhiteSpace(name))
{
ShoppingCart.CustomerId = customerCreate.Value;
if (!string.IsNullOrWhiteSpace(name))
await customerService.CreateCustomerContactAsync((long)ShoppingCart.CustomerId, new CreateCustomerContact
{
Email = email,
Name = name,
LastName = lastname,
Phone = mobile,
Type = LiteCharms.Features.ContactTypes.Personal
}, cancellationToken);
}
}
}
public async Task RehydrateCartFromPendingOrderAsync(CancellationToken cancellationToken = default)
{
if (User?.Identity?.IsAuthenticated == false) return;
if (ShoppingCart.OrderId.HasValue && ShoppingCart.CustomerId.HasValue)
{
cartService.CalculateTotalPrice();
CartHydrated = true;
return;
}
var orderResult = await orderService.GetPendingOrderAsync((long)ShoppingCart.CustomerId!, cancellationToken);
if (orderResult.IsFailed) return;
if(orderResult.Value.Id == ShoppingCart.OrderId)
{
cartService.CalculateTotalPrice();
CartHydrated = true;
return;
}
var orderItemsResult = await orderService.GetOrderItemsAsync((long)orderResult.Value.Id!, cancellationToken);
ShoppingCart.OrderId = orderResult.Value.Id;
if(orderItemsResult.IsFailed) return;
foreach (var item in orderItemsResult.Value)
{
var priceFetchResult = await productService.GetProductPriceAsync(item.ProductPriceId, cancellationToken);
if (priceFetchResult.IsFailed) continue;
var bookFetchResult = await booksService.GetBookByProductIdAsync(priceFetchResult.Value.ProductId, cancellationToken);
if (bookFetchResult.IsFailed) continue;
var authorFetchResult = await authorService.GetAuthorAsync(bookFetchResult.Value.AuthorId, cancellationToken);
if (authorFetchResult.IsFailed) continue;
if(!ShoppingCart.Items.Any(i => i.Price!.Id == item.ProductPriceId))
cartService.AddItem(priceFetchResult.Value, bookFetchResult.Value.Product!, authorFetchResult.Value);
}
CartHydrated = true;
await cartService.SaveCartToStorageAsync();
}
}
+14 -1
View File
@@ -18,12 +18,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="LiteCharms.Features" Version="1.43.0" />
<PackageReference Include="LiteCharms.Features" Version="1.137.0" />
</ItemGroup>
<!-- UI -->
<ItemGroup>
<PackageReference Include="ANM.Blazored.Toast" Version="0.1.1" />
<PackageReference Include="LiteCharms.Features.MidrandBooks" Version="1.137.0" />
<!-- Global Usings -->
<Using Include="Blazored.Toast.Services" />
@@ -50,6 +51,18 @@
<!-- Shared Global Usings -->
<ItemGroup>
<Using Include="Blazored.Toast" />
<Using Include="Microsoft.JSInterop" />
<Using Include="System.Globalization" />
<Using Include="System.Security.Claims" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.AspNetCore.HttpOverrides" />
<Using Include="Microsoft.AspNetCore.Components.Authorization" />
<Using Include="Microsoft.AspNetCore.Components.Routing" />
<Using Include="Microsoft.AspNetCore.Components.Web" />
<Using Include="Microsoft.AspNetCore.WebUtilities" />
<Using Include="Microsoft.AspNetCore.Components" />
<Using Include="System.Security.Cryptography.X509Certificates" />
</ItemGroup>
</Project>
+26 -34
View File
@@ -1,59 +1,51 @@
using LiteCharms.Features.Extensions;
using LiteCharms.Features.Mediator;
using LiteCharms.Features.Postgres;
using MidrandBookshop;
using MidrandBookshop.Components;
using static LiteCharms.Features.Extensions.Quartz;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.AddMonitoring();
builder.Services.AddEndpointsApiExplorer();
//builder.Services.AddMediator();
//builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
//builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
//builder.Services.AddSalesServiceBus();
//builder.Services.AddGeneralServiceBus();
builder.Services.AddQuartzSchedulerClient(MidrandShopSchedulerName, builder.Configuration);
//builder.Services.AddEmailServices(builder.Configuration);
//builder.Services.AddEmailServiceBus();
builder.Services.AddMidrandShopDatabase(builder.Configuration);
builder.Services.AddMidrandShopPostgresHealthCheck();
//builder.Services.AddMidrandShopQuartzHealthCheck();
builder.Services.AddHealthChecksSupport(builder.Configuration);
builder.Services.RegisterServices(builder.Configuration);
var app = builder.Build();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(MidrandShopSchedulerName);
if (!scheduler!.IsStarted)
await scheduler.Start();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse
});
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.AddSecurityEndpoints();
using (var security = app.Services.CreateScope())
{
var dataProtectionContext = security.ServiceProvider.GetRequiredService<DataProtectionDbContext>();
await dataProtectionContext.Database.MigrateAsync();
}
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(MidrandShopSchedulerName);
if (!scheduler!.IsStarted) await scheduler.Start();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
@@ -13,8 +13,8 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7021;http://localhost:5053",
"launchBrowser": false,
"applicationUrl": "https://localhost:8443;http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
+62
View File
@@ -0,0 +1,62 @@
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.Extensions;
using LiteCharms.Features.MidrandBooks.Extensions;
using static LiteCharms.Features.Extensions.Quartz;
namespace MidrandBookshop;
public static class Setup
{
public static IServiceCollection RegisterServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddCancellationToken();
services.AddAntiforgery();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services.AddBlazoredToast();
services.AddEndpointsApiExplorer();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
services.AddQuartzSchedulerClient(MidrandShopSchedulerName, configuration);
services.AddMediator();
services.AddEmailServices(configuration);
services.AddEmailServiceBus();
services.AddHttpClient();
services.AddScoped<CartService>();
services.AddScoped<HydrationService>();
services.AddShopServices(includeLocalStorage: true);
services.AddHashServices(configuration);
services.AddPayfastServices(configuration);
services.AddDataProtectionDatabase(configuration);
services.AddMidrandShopDatabase(configuration);
services.AddSecurityApiSdk(configuration);
services.AddLiteCharmsWebSecurity(configuration);
services.AddMidrandShopPostgresHealthCheck();
services.AddMidrandShopQuartzHealthCheck();
services.AddHealthChecksSupport(configuration);
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
options.ForwardLimit = null;
options.RequireHeaderSymmetry = false;
});
return services;
}
}
+27 -8
View File
@@ -1,16 +1,35 @@
{
"Email": {
"Credentials": {
"Username": "shop@litecharms.co.za"
},
"Port": 465,
"Host": "mail.litecharms.co.za",
"UseSsl": true
"PayfastSettings": {
"CheckoutUrl": "https://sandbox.payfast.co.za/eng/process",
"ValidHosts": [
"www.payfast.co.za",
"sandbox.payfast.co.za",
"ips.payfast.co.za",
"api.payfast.co.za",
"payment.payfast.io"
]
},
"LiteCharmsSettings": {
"Authority": "https://sts.security.khongisa.co.za"
},
"LiteCharmsClientSettings": {
"Authority": "https://sts.security.khongisa.co.za",
"GrantType": "client_credentials",
"Scope": "midrandbooks-api"
},
"HasherSettings": {
"MinHashLength": 11
},
"BookshopS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage",
"BucketName": "bookshop",
"CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za"
},
"Monitoring": {
"ApiKey": "",
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "LiteCharms.Shop"
"ServiceName": "MidrandBooks.DEV"
},
"Logging": {
"LogLevel": {
+207 -43
View File
@@ -1,60 +1,224 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@300;400;500;600&display=swap');
:root {
/* Fonts */
--font-heading: 'Instrument Serif', serif;
--font-ui: 'Inter', sans-serif;
/* Color Palette */
--mb-bg: #F9F9F9;
--mb-card-bg: #FFFFFF;
--mb-text-dark: #1A1A1A;
--mb-text-muted: #666666;
--mb-accent-red: #E63946;
--mb-radius: 12px;
/* High-Visibility Machined White Metal Core Surface Definition */
/* Sharp linear reflection channels paired with micro-milled density grids */
--brushed-metal-bg: linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(240, 241, 245, 0.95) 25%, rgba(255, 255, 255, 1) 50%, rgba(238, 240, 244, 0.95) 75%, rgba(255, 255, 255, 1) 100% ), repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.012) 0px, rgba(0, 0, 0, 0.012) 1px, transparent 1px, transparent 2px ), repeating-linear-gradient( 90deg, rgba(255, 255, 255, 0.9) 0px, rgba(255, 255, 255, 0.9) 1px, transparent 1px, transparent 3px ), #EDEFF4; /* Definitive satin platinum breakout base fallback */
}
a, .btn-link {
color: #006bb7;
/* Global Reset & Core Variables Mapping */
body {
background-color: var(--mb-bg);
color: var(--mb-text-dark);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
h1, h2, h3, .display-font {
font-family: var(--font-heading);
font-weight: 400;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
/* Custom Navigation Pill Styling */
.nav-pill-wrapper {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
padding: 6px;
border-radius: 50px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
}
.content {
padding-top: 1.1rem;
.nav-pill-link {
font-size: 0.9rem;
font-weight: 500;
color: var(--mb-text-muted);
padding: 8px 20px;
border-radius: 50px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
.nav-pill-link i {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
/* Navigation Pill States */
.nav-pill-link:hover {
color: var(--mb-text-dark);
background-color: #F1F1F1;
}
.nav-pill-link.active {
color: #FFFFFF !important;
background-color: var(--mb-text-dark);
}
.nav-pill-link.active svg {
stroke: #FFFFFF;
}
/* Utilities & Component Support Layouts */
.cart-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: var(--mb-text-dark);
color: #FFFFFF;
font-size: 0.7rem;
font-weight: 600;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--mb-bg);
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
.transition-smooth {
transition: color 0.2s ease;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
.transition-smooth:hover {
color: var(--mb-text-dark) !important;
}
.py-1-5 {
padding-top: 0.35rem !important;
padding-bottom: 0.35rem !important;
}
/* Global Focus Ring Corrections */
h1:focus, h2:focus, h3:focus, h4:focus, p:focus, div:focus, span:focus {
outline: none !important;
box-shadow: none !important;
}
[tabindex="-1"]:focus {
outline: none !important;
}
/* ==========================================================================
Global Toast Notification Framework Extensions
========================================================================== */
.blazored-toast-container {
position: fixed;
/* 🛠️ Shift anchors from top-right to bottom-left */
bottom: 24px;
left: 24px;
top: auto;
right: auto;
z-index: 2000 !important;
display: flex;
flex-direction: column-reverse; /* 💡 Newest toasts will now stack cleanly on top of old ones */
gap: 12px;
max-width: 400px;
width: 100%;
pointer-events: none;
}
.blazored-toast {
display: flex;
align-items: center;
padding: 16px 20px;
border-radius: var(--mb-radius);
background-color: var(--mb-card-bg);
color: var(--mb-text-dark);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.05);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 500;
animation: toastFadeIn 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.125) forwards;
}
/* Success Toast Core Variants */
.blazored-toast-success {
border-left: 4px solid var(--mb-text-dark);
}
/* Error Toast Core Variants */
.blazored-toast-error {
border-left: 4px solid var(--mb-accent-red);
color: var(--mb-accent-red);
}
.blazored-toast-icon {
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
/* Entry Transition Keyframes */
@keyframes toastFadeIn {
from {
opacity: 0;
transform: translateY(-12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.book-shadow {
filter: drop-shadow(5px 10px 15px rgba(0, 0, 0, 0.15)) drop-shadow(1px 2px 4px rgba(0, 0, 0, 0.1));
}
.sm-icon {
width: 14px;
height: 14px;
vertical-align: middle;
}
/* 🛠️ Micro-interactions for the header icon placement */
.btn-cart-icon:hover {
transform: scale(1.08);
background-color: var(--mb-text-dark) !important;
}
.btn-cart-icon:hover svg {
stroke: #FFFFFF !important;
}
@keyframes toastFadeIn {
from {
opacity: 0;
transform: translateX(-24px) scale(0.95); /* Slide rightward into view */
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.blazored-toast button.blazored-toast-close,
.blazored-toast-close-icon {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
width: 0 !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+57 -49
View File
@@ -1,4 +1,3 @@
---
apiVersion: v1
kind: Namespace
metadata:
@@ -10,21 +9,26 @@ metadata:
name: midrandbooks-config
namespace: midrandbooks-uat
data:
ASPNETCORE_ENVIRONMENT: "Development"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
ASPNETCORE_ENVIRONMENT: "Development"
ASPNETCORE_URLS: "http://0.0.0.0:8443"
Monitoring__Address: "http://aspire-dashboard-service.aspire.svc.cluster.local:18889"
Monitoring__ServiceName: "MidrandBooks.Uat"
---
apiVersion: v1
kind: Secret
metadata:
name: midrandbooks-secrets
namespace: midrandbooks-uat
type: Opaque
data:
connection-string: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPW1pZHJhbmRzaG9wLWRldjtVc2VybmFtZT1taWRyYW5kc2hvcC1kZXYtdXNlcjtQYXNzd29yZD1hUFh5a0tnM3RTOWNtRDtQZXJzaXN0IFNlY3VyaXR5IEluZm89VHJ1ZQ==
connection-string-quartz: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPXNjaGVkdWxlci1kZXY7VXNlcm5hbWU9c2NoZWR1bGVyLWRldi11c2VyO1Bhc3N3b3JkPWtWVm1vV0tKM3h6Z1FYO1BlcnNpc3QgU2VjdXJpdHkgSW5mbz1UcnVl
aspire-apikey: bWMzRzYzSzJqNVpPRXNpMEFqTW9qTFRYbTFLRVpGY3R6SUlqU3dEaVRHdXQ4cUdTa1B1V3d4R1AxUmJzY0pVbw==
HasherSettings__MinHashLength: "11"
BookshopS3Settings__ServiceUrl: "http://garage.garage.svc.cluster.local:3900"
BookshopS3Settings__Region: "garage"
BookshopS3Settings__BucketName: "bookshop"
BookshopS3Settings__CdnBaseUrl: "https://bookshop.cdn.khongisa.co.za"
PayfastSettings__CheckoutUrl: "https://sandbox.payfast.co.za/eng/process"
PayfastSettings__ValidHosts__0: "www.payfast.co.za"
PayfastSettings__ValidHosts__1: "sandbox.payfast.co.za"
PayfastSettings__ValidHosts__2: "ips.payfast.co.za"
PayfastSettings__ValidHosts__3: "api.payfast.co.za"
PayfastSettings__ValidHosts__4: "payment.payfast.io"
LiteCharmsSettings__Authority: "https://sts.security.khongisa.co.za"
LiteCharmsSettings__Audience: "midrandbooks-api"
LiteCharmsClientSettings__Authority: "https://sts.security.khongisa.co.za"
LiteCharmsClientSettings__GrantType: "client_credentials"
LiteCharmsClientSettings__Scope: "midrandbooks-api"
---
apiVersion: v1
kind: PersistentVolumeClaim
@@ -44,7 +48,8 @@ metadata:
name: midrandbooks
namespace: midrandbooks-uat
spec:
replicas: 1
replicas: 2
revisionHistoryLimit: 0
selector:
matchLabels:
app: midrandbooks
@@ -72,56 +77,60 @@ spec:
memory: "256Mi"
cpu: "100m"
ports:
- containerPort: 8080
- containerPort: 8443
envFrom:
- configMapRef:
name: midrandbooks-config
- secretRef:
name: midrandbooks-secrets
env:
- name: ConnectionStrings__PostgresScheduler
- name: DataProtection__Certificate
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: connection-string-quartz
- name: ConnectionStrings__PostgresMidrandShop
name: litecharms-certs
key: litecharms.pfx
- name: DataProtection__Password
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: connection-string
- name: Monitoring__Address
valueFrom:
configMapKeyRef:
name: midrandbooks-config
key: Monitoring__Address
- name: Monitoring__ServiceName
valueFrom:
configMapKeyRef:
name: midrandbooks-config
key: Monitoring__ServiceName
- name: Monitoring__ApiKey
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: aspire-apikey
name: litecharms-certs
key: passphrase
volumeMounts:
- name: cluster-certs-volume
mountPath: /tmp/litecharms-raw-certs
readOnly: true
- name: data
mountPath: /app/wwwroot/content
resources:
mountPath: /app/content
subPath: bookshop-content
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- |
cp /tmp/litecharms-raw-certs/litecharms.crt /usr/local/share/ca-certificates/litecharms.crt
update-ca-certificates
livenessProbe:
httpGet:
path: /health
port: 8080
port: 8443
scheme: HTTP
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
port: 8443
scheme: HTTP
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: midrandbooks-pvc
- name: cluster-certs-volume
secret:
secretName: litecharms-certs
---
apiVersion: v1
kind: Service
@@ -129,14 +138,12 @@ metadata:
name: midrandbooks-service
namespace: midrandbooks-uat
spec:
type: ClusterIP
ports:
- name: https
port: 443
targetPort: 8443
selector:
app: midrandbooks
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
@@ -147,14 +154,15 @@ spec:
entryPoints:
- websecure
routes:
- match: Host(`midrandbooks.co.za`)
- match: Host(`uat.midrandbooks.co.za`)
kind: Rule
services:
- name: midrandbooks-service
port: 80
port: 443
sticky:
cookie:
name: "lp-sticky-session"
httpOnly: true
secure: true
scheme: http
tls: {}