Compare commits

..

34 Commits

Author SHA1 Message Date
khwezi 2aeeb7a240 Merge pull request 'Added data protection key persistance' (#71) from cart into main
Reviewed-on: #71
2026-06-13 23:51:54 +02:00
khwezi 378044d011 Merge pull request 'cart' (#70) from cart into main
Reviewed-on: #70
2026-06-13 23:20:54 +02:00
khwezi 4e42d9f21a Merge pull request 'Using shared service for Cart management' (#56) from cart into main
Reviewed-on: #56
2026-06-12 08:55:26 +02:00
khwezi 0b7476d31c Merge pull request 'Stable checkout page' (#55) from cart into main
Reviewed-on: #55
2026-06-11 14:25:23 +02:00
khwezi 925c1f5988 Merge pull request 'Completed Cart page design' (#54) from cart into main
Reviewed-on: #54
2026-06-11 00:24:41 +02:00
khwezi 9629d9ddf9 Merge pull request 'Wired up CartDrawel and ProductView to cart service and local storage' (#53) from cart into main
Reviewed-on: #53
2026-06-10 23:02:07 +02:00
khwezi 7a11572294 Merge pull request 'cart' (#52) from cart into main
Reviewed-on: #52
2026-06-09 23:41:28 +02:00
khwezi a75bf5951d Merge pull request 'Fixed manifest secret name' (#51) from mock-data into main
Reviewed-on: #51
2026-06-07 16:51:40 +02:00
khwezi bbcf64aa65 Merge pull request 'Stable user session management' (#50) from mock-data into main
Reviewed-on: #50
2026-06-07 16:39:15 +02:00
khwezi a688bc816a Merge pull request 'Updated packages' (#49) from mock-data into main
Reviewed-on: #49
2026-06-05 09:26:44 +02:00
khwezi 4fe801583e Merge pull request 'Build trigger' (#48) from mock-data into main
Reviewed-on: #48
2026-06-05 09:08:18 +02:00
khwezi af3d40531b Merge pull request 'Removd proto handling from login process' (#47) from mock-data into main
Reviewed-on: #47
2026-06-05 09:00:10 +02:00
khwezi bc2b9f81e0 Merge pull request 'Simplified logn and logout process' (#46) from mock-data into main
Reviewed-on: #46
2026-06-05 08:22:30 +02:00
khwezi 49279c0cec Merge pull request 'Added port stripping' (#45) from mock-data into main
Reviewed-on: #45
2026-06-05 07:40:24 +02:00
khwezi edabe266e5 Merge pull request 'Refactored https logni proto handling' (#44) from mock-data into main
Reviewed-on: #44
2026-06-05 06:44:12 +02:00
khwezi 248dd32b1b Merge pull request 'Added support for forwarded headers' (#43) from mock-data into main
Reviewed-on: #43
2026-06-05 06:30:37 +02:00
khwezi 1645b6bbae Merge pull request 'Fixed secrets mappings' (#42) from mock-data into main
Reviewed-on: #42
2026-06-05 06:17:30 +02:00
khwezi 72725a302a Merge pull request 'mock-data' (#41) from mock-data into main
Reviewed-on: #41
2026-06-05 05:58:44 +02:00
khwezi f3d79174be Merge pull request 'Upgraded quartz' (#40) from mock-data into main
Reviewed-on: #40
2026-06-03 11:54:17 +02:00
khwezi c086aa60e4 Merge pull request 'Updated backend' (#39) from mock-data into main
Reviewed-on: #39
2026-06-02 00:31:16 +02:00
khwezi 66b377bf69 Merge pull request 'Fixed event service discovery issue' (#38) from mock-data into main
Reviewed-on: #38
2026-06-01 23:39:37 +02:00
khwezi 82389a9304 Merge pull request 'Updated backend' (#37) from mock-data into main
Reviewed-on: #37
2026-06-01 22:58:55 +02:00
khwezi 4bf1d2e77a Merge pull request 'Ensured appsettings aligns with k8s config' (#36) from mock-data into main
Reviewed-on: #36
2026-06-01 16:37:57 +02:00
khwezi 2b6576de85 Merge pull request 'Refactored app k8s manifest' (#35) from mock-data into main
Reviewed-on: #35
2026-06-01 11:16:54 +02:00
khwezi 7de957ed6f Merge pull request 'Refactored manifest to include s3 bucket and HasherService configs and secrets' (#34) from mock-data into main
Reviewed-on: #34
2026-06-01 09:50:59 +02:00
khwezi 16fdcc8005 Merge pull request 'Authors now showing on the listing' (#31) from mock-data into main
Reviewed-on: #31
2026-05-30 22:23:09 +02:00
khwezi a614d14da5 Merge pull request 'mock-data' (#30) from mock-data into main
Reviewed-on: #30
2026-05-30 21:01:05 +02:00
khwezi b722ea2cd0 Merge pull request 'mock-data' (#29) from mock-data into main
Reviewed-on: #29
2026-05-30 19:06:49 +02:00
khwezi 73145fd360 Merge pull request 'Upgraded backend services' (#28) from ui-design into main
Reviewed-on: #28
2026-05-30 00:32:23 +02:00
khwezi 7f29680993 Merge pull request 'ui-design' (#18) from ui-design into main
Reviewed-on: #18
2026-05-24 11:30:13 +02:00
khwezi 56626d2693 Merge pull request 'ui-design' (#17) from ui-design into main
Reviewed-on: #17
2026-05-24 10:48:55 +02:00
khwezi f9f6788c79 Merge pull request 'Updated UAT url' (#16) from project-setup into main
Reviewed-on: #16
2026-05-23 12:17:28 +02:00
khwezi 249ad319d9 Merge pull request 'Fixed dependencies and config' (#15) from project-setup into main
Reviewed-on: #15
2026-05-23 12:06:13 +02:00
khwezi 4a476febf4 Merge pull request 'Basic project setup' (#14) from project-setup into main
Reviewed-on: #14
2026-05-23 10:56:41 +02:00
37 changed files with 854 additions and 3085 deletions
+1 -5
View File
@@ -23,7 +23,7 @@ trigger:
kind: pipeline
type: docker
name: package
steps:
- name: docker-build
image: plugins/docker
@@ -31,10 +31,6 @@ 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}
+7 -24
View File
@@ -14,29 +14,12 @@
{
<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>
<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 class="d-flex justify-content-center align-items-center flex-grow-1 my-2">
@@ -66,4 +49,4 @@
</div>
</div>
</div>
</div>
+1 -31
View File
@@ -1,9 +1,4 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Products;
namespace MidrandBookshop.Components;
namespace MidrandBookshop.Components;
public partial class BookCard
{
@@ -16,29 +11,4 @@ public partial class BookCard
[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.");
}
}
}
@@ -1,5 +1,4 @@
@using Blazored.Toast
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@inject NavigationManager Navigation
<div class="position-relative vh-100 d-flex flex-column justify-content-between overflow-hidden" style="background-color: #F9F9F9;">
@@ -204,7 +203,7 @@
</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">
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-danger d-flex align-items-center gap-2" href="/logout">
<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>
@@ -216,15 +215,15 @@
</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">
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-dark d-flex align-items-center gap-2" href="/login">
<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">
<a class="dropdown-item p-2.5 font-monospace text-uppercase text-muted 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"><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
Order Status
</a>
</li>
</NotAuthorized>
@@ -296,4 +295,3 @@
</div>
</div>
<BlazoredToasts />
@@ -5,10 +5,15 @@ namespace MidrandBookshop.Components.Layout;
public partial class MainLayout(CartService cartService) : IDisposable
{
[Inject] public IToastService ToastService { get; set; } = default!;
[Inject]
private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
private Cart ShoppingCart => cartService.ShoppingCart;
private AuthenticationState? AuthState { get; set; }
private System.Security.Claims.ClaimsPrincipal? User { get; set; }
private bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
private string SearchInputBuffer { get; set; } = string.Empty;
private string GlobalSearchQuery { get; set; } = string.Empty;
private bool IsSearchActive { get; set; } = false;
@@ -16,15 +21,21 @@ public partial class MainLayout(CartService cartService) : IDisposable
protected override async Task OnInitializedAsync()
{
AuthState = await AuthStateProvider.GetAuthenticationStateAsync();
User = AuthState!.User;
Navigation.LocationChanged += OnLocationChanged;
cartService.OnCartChanged += CartService_OnCartChanged;
if (cartService.ShoppingCart.Items.Count == 0)
await cartService.LoadCartFromStorageAsync();
SyncSearchQueryFromUrl();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await cartService.LoadCartFromStorageAsync();
}
private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged);
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
@@ -93,7 +104,7 @@ public partial class MainLayout(CartService cartService) : IDisposable
private void ToggleCart() => IsCartOpen = !IsCartOpen;
private async Task ChangeQuantity(CartItem item, int delta)
private async void ChangeQuantity(CartItem item, int delta)
{
var peekQuantity = item.Quantity + delta;
@@ -104,60 +115,25 @@ public partial class MainLayout(CartService cartService) : IDisposable
await cartService.SaveCartToStorageAsync();
}
private async Task RemoveFromCart(CartItem item)
private async void 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()
private void RedirectToCart()
{
IsCartOpen = false;
await cartService.SaveCartToStorageAsync();
Navigation.NavigateTo("/cart");
}
private async Task RedirectToCheckout()
private void 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);
Navigation.NavigateTo("/checkout");
}
public void Dispose()
@@ -1,58 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<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 class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></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,218 +1,157 @@
/* ==========================================================================
Midrand Books — Glassine Architectural Veil & Ribbon Strip
========================================================================== */
/* --- Native Dialog Element Layout Overrides --- */
#components-reconnect-modal {
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;
}
/* Remove default browser modal backdrop blockout to allow custom layering below */
#components-reconnect-modal::backdrop {
background: transparent;
}
/* --- 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);
}
}
/* --- Ribbon Layout Matrix Grid --- */
.strip-container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 2.5rem;
}
/* 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;
}
.small-tracking {
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
letter-spacing: 0.12em;
color: #111111;
font-weight: 600;
}
/* Animated Book Helix SVG */
.literary-helix-loader {
width: 18px;
height: 18px;
color: #111111;
}
.flipping-leaf-vector {
transform-origin: 12px 12px;
animation: svgLeafFlip 1.6s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes svgLeafFlip {
0% {
transform: scaleX(1);
opacity: 1;
}
50% {
transform: scaleX(0);
opacity: 0.3;
}
100% {
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-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: inline-block !important;
display: block;
}
#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;
#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;
}
/* Tablet Parameters Response Collapse Matrix */
@media (max-width: 768px) {
.strip-container {
grid-template-columns: 1fr;
gap: 0.65rem;
text-align: center;
}
#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);
}
.sync-status-indicator {
border-right: none;
padding-right: 0;
justify-content: center;
}
.sync-action-node {
margin-top: 0.25rem;
100% {
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;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#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;
}
.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;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
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;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -1,3 +1,4 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
@@ -23,8 +24,14 @@ 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();
@@ -33,6 +40,7 @@ async function retry() {
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
@@ -52,4 +60,4 @@ async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
}
@@ -1,68 +0,0 @@
@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' }});
}}
");
}
}
@@ -1,85 +0,0 @@
/* ==========================================================================
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;
}
+207 -243
View File
@@ -4,261 +4,225 @@
@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="container py-5">
<h2 class="fw-bold mb-5 tracking-tight">My Account</h2>
<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 class="col-md-3">
<div class="nav flex-column nav-pills" role="tablist">
<button class="nav-link active text-start" data-bs-toggle="pill" data-bs-target="#orders" role="tab">Order History</button>
<button class="nav-link text-start" data-bs-toggle="pill" data-bs-target="#shipping" role="tab">Shipping Address</button>
<button class="nav-link text-start" data-bs-toggle="pill" data-bs-target="#profile" role="tab">Profile Settings</button>
<hr />
<button class="nav-link text-danger text-start" @onclick="TriggerLogout">Logout</button>
</div>
</div>
<div class="col-lg-9">
<div class="col-md-9">
<AuthorizeView>
<Authorized>
<div class="tab-content account-panels-deck">
<div class="tab-pane fade show active" id="orders" role="tabpanel">
<div class="tab-panel-body">
<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-bottom border-light 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 @GetOrderStatusClass(order.Status)">
Order: @order.Status
</span>
<span class="badge status-badge-base @GetPaymentStatusClass(order.PaymentStatus)">
Pay: @order.PaymentStatus
</span>
<span class="badge status-badge-base @GetShippingStatusClass(order.ShippingStatus)">
Logistics: @order.ShippingStatus
</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;" disabled="@string.IsNullOrWhiteSpace(order.InvoiceUrl)"
@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-books-manifest manifest-grid-wrap mb-3">
@foreach (var book in order.PurchasedBooks)
{
<div class="manifest-grid-cell">
<div class="manifest-book-item border rounded p-2 d-flex align-items-center justify-content-between bg-light bg-opacity-20 h-100">
<div class="d-flex align-items-center gap-2.5 min-w-0">
<div class="book-thumbnail-container flex-shrink-0 border rounded bg-white shadow-xs">
<img src="@book.CoverImageUrl" alt="@book.Title" class="book-thumbnail-img" />
</div>
<div class="book-text-meta min-w-0">
<h6 class="text-dark fw-bold mb-0.5 small text-truncate" title="@book.Title">@book.Title</h6>
<span class="badge bg-white text-secondary border font-monospace extra-small py-0.5">Qty: @book.Quantity</span>
</div>
</div>
<div class="book-row-pricing text-end font-monospace text-dark small ps-2 flex-shrink-0 fw-medium">
R @((book.PriceUnitPrice * book.Quantity).ToString("F2"))
</div>
</div>
</div>
}
</div>
<div class="order-summary-footer border-top pt-3 d-flex align-items-baseline justify-content-between">
<span class="text-muted small text-uppercase font-monospace">Order Total</span>
<div class="text-end">
<span class="font-monospace text-dark fw-bold h5 mb-0 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 class="profile-hero-banner mb-5 d-flex align-items-center justify-content-between p-4 border border-light bg-white">
<div class="hero-text-content">
<div class="meta-tag font-monospace text-uppercase text-muted mb-1">Customer Profile // Active Session</div>
<h4 class="fw-bold mb-1 brand-greeting">Welcome back, @(context.User.FindFirst("given_name")?.Value ?? context.User.Identity?.Name ?? "Reader")!</h4>
<p class="text-muted small mb-0 font-monospace">@context.User.FindFirst("email")?.Value</p>
</div>
<div class="tab-pane fade" id="shipping" role="tabpanel">
<div class="tab-panel-body">
<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 class="hero-crest-svg d-none d-sm-block">
<svg viewBox="0 0 100 100" width="70" height="70" stroke="#1A1A1A" stroke-width="1.5" fill="none">
<circle cx="50" cy="50" r="40" stroke-dasharray="2 2" stroke="#DDD" />
<circle cx="50" cy="50" r="30" />
<polygon points="50,20 76,65 24,65" stroke="#1A1A1A" />
<line x1="50" y1="20" x2="50" y2="80" stroke-width="1" stroke="#1A1A1A" />
</svg>
</div>
<div class="tab-pane fade" id="profile" role="tabpanel">
<div class="tab-panel-body">
<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 class="tab-content">
<div class="tab-pane fade show active" id="orders" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0 text-uppercase tracking-wider fs-6 text-muted">Order History</h5>
</div>
<div class="d-flex flex-column gap-3">
@if (orderHistory != null)
{
@foreach (var order in orderHistory)
{
<div class="card p-4 shadow-sm order-history-card">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start gap-3">
<div class="flex-grow-1 w-100">
<div class="order-meta-track mb-2">
<div class="meta-item-id">
<span class="fw-bold text-dark">@order.OrderId</span>
</div>
<div class="meta-item-date">
<span class="text-muted small">@order.OrderDate.ToString("MMM dd, yyyy")</span>
</div>
<div class="meta-item-status">
<span class="badge status-badge-base status-@order.Status?.ToLower() text-uppercase">
@order.Status
</span>
</div>
</div>
<h6 class="mb-2">
<a href="/products/@order.ProductId" class="product-link fw-medium text-dark text-decoration-none" title="@order.ProductTitle">
@order.ProductTitle
</a>
</h6>
<div class="d-flex align-items-center text-secondary small">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="currentColor" class="me-1 text-muted flex-shrink-0">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
<span class="text-muted text-uppercase tracking-wider font-monospace" style="font-size: 0.7rem;">Shipping to:</span>&nbsp;<span class="text-dark fw-medium">@order.ShippingAddressName</span>
</div>
</div>
<div class="d-flex flex-row flex-sm-column align-items-center align-items-sm-end justify-content-between w-100 w-sm-auto pt-2 pt-sm-0 border-top border-sm-top-0 border-light">
<div class="text-sm-end mb-sm-2">
<span class="text-muted xx-small d-block text-uppercase font-monospace tracking-wider" style="font-size: 0.6rem;">Total Paid</span>
<span class="fw-bold text-dark fs-5">R @order.Total.ToString("N2")</span>
</div>
<button class="btn btn-link p-0 text-dark action-btn mt-sm-1" title="Download Invoice" @onclick="() => DownloadInvoice(order.OrderId)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor" class="svg-icon">
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2v9.67z" />
</svg>
</button>
</div>
</div>
</div>
}
}
else
{
<div class="card p-4 text-center text-muted">
Loading order history...
</div>
}
</div>
</div>
<div class="tab-pane fade" id="shipping" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0 text-uppercase tracking-wider fs-6 text-muted">Saved Addresses</h5>
@if (!showAddForm && editingAddress == null)
{
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="OpenAddForm">+ Add New</button>
}
</div>
@if (showAddForm)
{
<div class="card p-4 border shadow-sm mb-4 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold m-0">New Address</h6>
<button type="button" class="btn-close" @onclick="() => showAddForm = false"></button>
</div>
<input type="text" class="form-control mb-2" placeholder="Address Name (e.g. Home, Office)" @bind="newAddressName" />
<input type="text" class="form-control mb-2" placeholder="Street Address" @bind="newStreetAddress" />
<div class="d-flex gap-2 mb-3">
<input type="text" class="form-control" placeholder="City" @bind="newCity" />
<input type="text" class="form-control" placeholder="Postal Code" @bind="newPostalCode" />
</div>
<div class="mb-3 d-flex gap-3">
<label class="pointer-label"><input type="checkbox" @bind="isBilling" /> Billing</label>
<label class="pointer-label"><input type="checkbox" @bind="isShipping" /> Shipping</label>
</div>
<div class="d-flex">
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="SaveAddress">Save Address</button>
</div>
</div>
}
@if (editingAddress != null)
{
<div class="card p-4 border shadow-sm mb-4 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold m-0">Edit Address</h6>
<button type="button" class="btn-close" @onclick="CancelEditing"></button>
</div>
<input type="text" class="form-control mb-2" placeholder="Address Name" @bind="editingAddress.Name" />
<input type="text" class="form-control mb-2" placeholder="Street Address" @bind="editingAddress.Street" />
<div class="d-flex gap-2 mb-3">
<input type="text" class="form-control" placeholder="City" @bind="editingAddress.City" />
<input type="text" class="form-control" placeholder="Postal Code" @bind="editingAddress.PostalCode" />
</div>
<div class="mb-3 d-flex gap-3">
<label class="pointer-label"><input type="checkbox" @bind="editingAddress.IsBilling" /> Billing</label>
<label class="pointer-label"><input type="checkbox" @bind="editingAddress.IsShipping" /> Shipping</label>
</div>
<div class="d-flex">
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="UpdateAddress">Update Address</button>
</div>
</div>
}
@foreach (var addr in savedAddresses)
{
<div class="card p-4 shadow-sm mb-3 address-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">@addr.Name</h6>
<p class="mb-2 text-muted">@addr.Street, @addr.City, @addr.PostalCode</p>
<div class="d-flex gap-2 text-uppercase font-monospace text-muted small">
@if (addr.IsBilling)
{
<span class="badge bg-light text-dark border">[Billing]</span>
}
@if (addr.IsShipping)
{
<span class="badge bg-light text-dark border">[Shipping]</span>
}
</div>
</div>
<div class="d-flex align-items-center gap-2 actions-container">
<label class="small text-muted d-flex align-items-center gap-1 m-0 pointer-label me-2">
<input type="checkbox" checked="@addr.IsPrimary" @onchange="(e) => SetPrimary(addr, e)" /> Primary
</label>
<button class="btn btn-link p-0 text-dark action-btn" title="Edit Address" @onclick="() => StartEditing(addr)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
</button>
<button class="btn btn-link p-0 text-danger action-btn" title="Delete Address" @onclick="() => DeleteAddress(addr)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
</div>
</div>
}
</div>
<div class="tab-pane fade" id="profile" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0 text-uppercase tracking-wider fs-6 text-muted">Profile Settings</h5>
</div>
<div class="card p-5 text-center bg-white border">
<div class="mb-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="#1A1A1A" stroke-width="1.5">
<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 mb-2">Centralized Identity Management</h5>
<p class="text-muted small mx-auto mb-4" style="max-width: 480px;">
For your protection, password modifications, recovery settings, authentication methods, and core credentials are managed through our secure Identity Node.
</p>
<a href="https://sts.security.khongisa.co.za/Manage/Index?returnUrl=https://midrandbooks.co.za/account" class="btn btn-dark rounded-pill px-5 py-2.5 btn-sm font-monospace text-uppercase tracking-wider">
Access Security Center
</a>
</div>
</div>
</div>
</div>
</div>
</div>
+30 -229
View File
@@ -1,26 +1,7 @@
using LiteCharms.Features.Hasher;
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.Products;
namespace MidrandBookshop.Components.Pages;
namespace MidrandBookshop.Components.Pages;
public partial class Account : ComponentBase
public partial class Account
{
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
[Inject] private CustomerService CustomerService { get; set; } = default!;
[Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private PaymentService PaymentService { get; set; } = default!;
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private HashService HashService { get; set; } = default!;
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
[Inject] private IToastService ToasterService { get; set; } = default!;
private ClaimsPrincipal? User { get; set; }
private Customer? customer;
private bool showAddForm = false;
private AddressItem? editingAddress = null;
private string newAddressName = "";
@@ -29,147 +10,26 @@ public partial class Account : ComponentBase
private string newPostalCode = "";
private bool isBilling, isShipping;
private List<OrderItem> orderHistory = [];
private List<OrderItem> orderHistory = new()
{
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", Total = 720.00 },
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 Books Warehouse", Status = "Delivered", Total = 890.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 }
new AddressItem { Id = 2, Name = "Corporate Office", Street = "45 Challink Street", City = "Halfway House", PostalCode = "1682", IsBilling = true, IsShipping = false, IsPrimary = false },
new AddressItem { Id = 3, Name = "Midrand Books Warehouse", Street = "Unit 8, Corporate Park North", City = "Randjespark", PostalCode = "1683", IsBilling = false, IsShipping = true, IsPrimary = false }
};
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState?.User;
private void TriggerLogout() => Navigation.NavigateTo("/logout", forceLoad: true);
private void DownloadInvoice(string orderId) { /* Handle download sequence here */ }
private void OpenAddForm() { editingAddress = null; showAddForm = true; }
var customerFetch = await CustomerService.GetCustomerAsync(User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!, CancellationToken);
if (customerFetch.IsSuccess)
customer = customerFetch.Value;
await LoadOrdersAsync();
}
private async Task LoadOrdersAsync()
{
if (customer is null)
{
ToasterService.ShowError("There was a problem loading your details, please contact the administrator");
return;
}
var ordersFetch = await OrderService.GetOrdersByCustomerAsync(customer.Id, CancellationToken);
if (ordersFetch.IsFailed)
{
ToasterService.ShowWarning("No orders were found");
return;
}
orderHistory.Clear();
foreach (var order in ordersFetch.Value)
{
var paymentFetch = await PaymentService.GetOrderPaymentAsync(order.Id, CancellationToken);
var orderEntry = new OrderItem
{
OrderDate = order.CreatedAt,
OrderId = HashService.HashEncodeLongId(order.Id).Value,
Status = order.Status.ToString(),
PaymentStatus = paymentFetch.IsSuccess ? paymentFetch.Value.Status.ToString() : "NotPaid",
ShippingStatus = "Processing",
ShippingAddressName = "TBA",
Total = order.Total,
InvoiceUrl = order.InvoiceUrl!,
};
var orderItemsFetch = await OrderService.GetOrderItemsAsync(order.Id, CancellationToken);
if (orderItemsFetch.IsFailed) continue;
foreach (var item in orderItemsFetch.Value)
{
var productPriceFetch = await ProductService.GetProductPriceAsync(item.ProductPriceId, CancellationToken);
if (productPriceFetch.IsFailed) continue;
var productFetch = await ProductService.GetProductAsync(productPriceFetch.Value.ProductId, CancellationToken);
var itemEntry = new PurchasedBook
{
Quantity = item.Quantity,
PriceUnitPrice = productPriceFetch.Value.Amount,
CoverImageUrl = productFetch.Value.ImageUrl!,
Title = productFetch.Value.Name!,
};
orderEntry.PurchasedBooks.Add(itemEntry);
}
orderHistory.Add(orderEntry);
}
}
private void DownloadInvoice(string orderId)
{
var order = orderHistory.FirstOrDefault(o => o.OrderId == orderId)!;
if (string.IsNullOrWhiteSpace(order.InvoiceUrl))
ToasterService.ShowWarning("Your invoice is currently not availabe for viewing");
else
Navigation.NavigateTo(orderHistory.FirstOrDefault(o => o.OrderId == orderId)!.InvoiceUrl, forceLoad: true);
}
// Badge Style 1: Core Order Lifecycle Status Mapping
private string GetOrderStatusClass(string? status)
{
return status?.ToLower() switch
{
"completed" => "order-completed",
"processing" => "order-processing",
"cancelled" => "order-cancelled",
_ => "order-hold"
};
}
// Badge Style 2: Financial Payment Status Mapping
private string GetPaymentStatusClass(string? status)
{
return status?.ToLower() switch
{
"paid" => "pay-paid",
"refunded" => "pay-refunded",
_ => "pay-pending"
};
}
// Badge Style 3: Logistics Shipment Status Mapping
private string GetShippingStatusClass(string? status)
{
return status?.ToLower() switch
{
"delivered" => "status-delivered",
"shipped" => "status-shipped",
_ => "status-processing"
};
}
// Implemented to resolve UI registration click events
private void SaveAddress()
{
if (string.IsNullOrWhiteSpace(newAddressName) || string.IsNullOrWhiteSpace(newStreetAddress))
{
ToasterService.ShowWarning("Please fill in the required fields.");
return;
}
if (editingAddress != null)
{
editingAddress.Name = newAddressName;
editingAddress.Street = newStreetAddress;
editingAddress.City = newCity;
editingAddress.PostalCode = newPostalCode;
editingAddress.IsBilling = isBilling;
editingAddress.IsShipping = isShipping;
}
else
if (!string.IsNullOrWhiteSpace(newAddressName) && !string.IsNullOrWhiteSpace(newStreetAddress))
{
var nextId = savedAddresses.Any() ? savedAddresses.Max(a => a.Id) + 1 : 1;
savedAddresses.Add(new AddressItem
@@ -183,92 +43,33 @@ public partial class Account : ComponentBase
IsShipping = isShipping,
IsPrimary = !savedAddresses.Any()
});
ResetAddForm();
}
CancelAddressActions();
}
private void CancelAddressActions()
private void ResetAddForm() { newAddressName = ""; newStreetAddress = ""; newCity = ""; newPostalCode = ""; isBilling = isShipping = showAddForm = false; }
private void StartEditing(AddressItem addr) { showAddForm = false; editingAddress = new AddressItem { Id = addr.Id, Name = addr.Name, Street = addr.Street, City = addr.City, PostalCode = addr.PostalCode, IsBilling = addr.IsBilling, IsShipping = addr.IsShipping, IsPrimary = addr.IsPrimary }; }
private void CancelEditing() => editingAddress = null;
private void UpdateAddress()
{
showAddForm = false;
editingAddress = null;
ResetFormFields();
if (editingAddress != null)
{
var target = savedAddresses.FirstOrDefault(a => a.Id == editingAddress.Id);
if (target != null) { target.Name = editingAddress.Name; target.Street = editingAddress.Street; target.City = editingAddress.City; target.PostalCode = editingAddress.PostalCode; target.IsBilling = editingAddress.IsBilling; target.IsShipping = editingAddress.IsShipping; }
editingAddress = null;
}
}
private void ResetFormFields()
{
newAddressName = "";
newStreetAddress = "";
newCity = "";
newPostalCode = "";
isBilling = false;
isShipping = false;
}
private void EditAddress(AddressItem addr)
{
editingAddress = addr;
newAddressName = addr.Name;
newStreetAddress = addr.Street;
newCity = addr.City;
newPostalCode = addr.PostalCode;
isBilling = addr.IsBilling;
isShipping = addr.IsShipping;
showAddForm = false;
}
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 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);
}
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 DateTime OrderDate { get; set; }
public string ShippingAddressName { get; set; } = "";
public string Status { get; set; } = "";
public string PaymentStatus { get; set; } = "";
public string ShippingStatus { get; set; } = "";
public string InvoiceUrl { get; set; } = "";
public decimal Total { get; set; }
public List<PurchasedBook> PurchasedBooks { get; set; } = new();
}
public class PurchasedBook
{
public string Title { get; set; } = "";
public string CoverImageUrl { get; set; } = "";
public int Quantity { get; set; }
public decimal PriceUnitPrice { get; set; }
}
}
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 double Total { get; set; } }
}
+111 -236
View File
@@ -1,272 +1,147 @@
/* ==========================================================================
Curated Architecture Dashboard Style Matrix
========================================================================== */
.account-page-container {
max-width: 1200px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
::deep .container {
max-width: 1100px;
}
.tab-panel-body {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.tab-panel-body {
padding-left: 2rem;
padding-right: 2rem;
}
}
.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;
/* Navigation Layout Overrides - Black & White Architectural Style */
.nav-pills .nav-link {
color: #6c757d;
border-radius: 0;
padding: 0.75rem 1rem;
font-weight: 500;
font-size: 0.9rem;
transition: all 0.2s ease;
font-family: var(--bs-body-font-family);
transition: all 0.2s ease-in-out;
border: 1px solid transparent;
background: transparent !important;
}
.account-nav-stack .nav-link.active {
background-color: #111111 !important;
/* Active State - Solid Black Fill with stark white text */
.nav-pills .nav-link.active {
background-color: #1A1A1A !important;
color: #FFFFFF !important;
border-color: #1A1A1A;
}
.account-nav-stack .nav-link:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.04) !important;
color: #111111;
/* Hover State for Unselected Buttons */
.nav-pills .nav-link:hover:not(.active) {
color: #1A1A1A;
background-color: #F8F8F8 !important;
}
.nav-logout:hover {
background-color: rgba(220, 53, 69, 0.08) !important;
/* Logout Button Link Alignment rules */
.nav-pills .nav-link.text-danger {
color: #DC3545 !important;
}
.nav-pills .nav-link.text-danger:hover {
background-color: #FFF5F5 !important;
color: #A94442 !important;
}
hr {
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin: 1.5rem 0;
}
/* --- Balanced Status Mapping Badges --- */
.status-badge-base {
font-family: var(--bs-font-monospace);
text-transform: uppercase;
/* Profile Banner Design Definitions */
.profile-hero-banner {
border-color: rgba(0, 0, 0, 0.05) !important;
background-color: #FAFAFA !important;
}
.brand-greeting {
font-family: 'Playfair Display', serif;
font-size: 1.8rem;
letter-spacing: -0.01em;
color: #111111;
}
.meta-tag {
font-size: 0.68rem;
font-weight: 600;
padding: 0.45rem 0.65rem;
letter-spacing: 0.02em;
border-radius: 4px;
letter-spacing: 0.15em;
}
/* 1. Core Order Lifecycle Badge Styles */
.order-completed {
background-color: #111111 !important;
color: #FFFFFF !important;
border: 1px solid #111111;
.hero-crest-svg svg {
transition: transform 0.4s ease-in-out;
}
.order-processing {
background-color: rgba(0, 0, 0, 0.03) !important;
color: #111111 !important;
border: 1px solid rgba(0, 0, 0, 0.15);
.profile-hero-banner:hover .hero-crest-svg svg {
transform: rotate(15deg);
}
.order-hold {
background-color: rgba(108, 117, 125, 0.05) !important;
color: #495057 !important;
border: 1px solid rgba(108, 117, 125, 0.2);
/* Cards Layout Rules */
.card {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
}
.order-cancelled {
background-color: rgba(220, 53, 69, 0.04) !important;
color: #842029 !important;
border: 1px solid rgba(220, 53, 69, 0.12);
}
/* 2. Logistics Shipment Status Badge Styles */
.status-delivered {
background-color: rgba(25, 135, 84, 0.06) !important;
color: #198754 !important;
border: 1px solid rgba(25, 135, 84, 0.15);
}
.status-shipped {
background-color: rgba(13, 110, 253, 0.06) !important;
color: #0d6efd !important;
border: 1px solid rgba(13, 110, 253, 0.15);
}
.status-processing {
background-color: rgba(255, 193, 7, 0.08) !important;
color: #b58100 !important;
border: 1px solid rgba(255, 193, 7, 0.25);
}
/* 3. Financial Payment Status Badge Styles */
.pay-paid {
background-color: rgba(25, 135, 84, 0.06) !important;
color: #198754 !important;
border: 1px solid rgba(25, 135, 84, 0.15);
}
.pay-pending {
background-color: rgba(220, 53, 69, 0.06) !important;
color: #dc3545 !important;
border: 1px solid rgba(220, 53, 69, 0.15);
}
.pay-refunded {
background-color: rgba(108, 117, 125, 0.08) !important;
color: #6c757d !important;
border: 1px solid rgba(108, 117, 125, 0.2);
}
/* --- Saved Addresses Section Layout --- */
.address-curated-card {
.address-card {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.address-curated-card:hover {
border-color: #111111 !important;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.03) !important;
.address-card:hover {
border-color: rgba(0, 0, 0, 0.16);
}
.border-top-dashed {
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.btn-action-trigger {
padding: 0;
font-weight: 500;
letter-spacing: 0.04em;
opacity: 0.75;
transition: opacity 0.15s ease;
}
.btn-action-trigger:hover {
opacity: 1;
}
/* --- Interactive Address Form Fields --- */
.premium-plaintext-field {
background-color: #FAFAFA;
border: 1px solid rgba(0, 0, 0, 0.08);
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.15s ease;
}
.btn-clean-cancel:hover {
color: #111111;
}
/* --- Profile Settings Identity Pillar Elements --- */
.profile-hero-banner {
background-color: #FAFAFA;
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* --- CSS Grid Book Item Layout --- */
.manifest-grid-wrap {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1rem;
width: 100%;
}
@media (min-width: 768px) {
.manifest-grid-wrap {
grid-template-columns: repeat(2, 1fr);
}
}
.manifest-grid-cell {
min-width: 0;
}
.manifest-book-item {
transition: background-color 0.15s ease;
}
.manifest-book-item:hover {
background-color: rgba(0, 0, 0, 0.015) !important;
}
.book-thumbnail-container {
width: 42px;
height: 56px;
overflow: hidden;
/* Order Meta Tracks & Status Badges Setup */
.order-meta-track {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.status-badge-base {
font-family: var(--bs-font-monospace);
font-size: 0.68rem !important;
letter-spacing: 0.08em;
padding: 0.35rem 0.65rem !important;
border-radius: 0px !important;
font-weight: 600;
}
.status-delivered {
background-color: #E2F0D9 !important;
color: #385723 !important;
}
.status-shipped {
background-color: #FFF2CC !important;
color: #7F6000 !important;
}
.status-processing, .status-pending {
background-color: #F2F2F2 !important;
color: #595959 !important;
border: 1px dashed #D9D9D9;
}
/* Restored Action Button Interactive Properties */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
border: none;
color: #1A1A1A;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.book-thumbnail-img {
width: 100%;
height: 100%;
object-fit: cover;
.action-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.action-btn:active {
transform: scale(0.92);
}
.pointer-label {
cursor: pointer;
user-select: none;
}
.shadow-xs {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.min-w-0 {
min-width: 0;
}
.order-summary-footer {
border-top: 1px solid rgba(0, 0, 0, 0.06) !important;
.tab-pane #profile .card {
border: 1px dashed rgba(0, 0, 0, 0.15) !important;
}
@@ -5,8 +5,6 @@ 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)
@@ -34,7 +32,5 @@ public partial class CartReview(CartService cartService)
cartService.RemoveOneItem(item.Price!.Id);
await cartService.SaveCartToStorageAsync();
ToastService.ShowSuccess($"Removed {item.Product!.Name} from cart", "Cart Changed");
}
}
+61 -246
View File
@@ -3,259 +3,74 @@
@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 class="container py-5">
<h2 class="fw-bold mb-4">Checkout</h2>
<div class="row g-5">
<div class="col-lg-8">
<div class="card border-0 shadow-sm p-4 mb-4">
<h5 class="fw-bold mb-3">Your Items</h5>
@foreach (var item in ShoppingCart.Items)
{
<div class="d-flex align-items-center justify-content-between pb-3 border-bottom mb-3">
<div><h6 class="mb-0">@item.Product!.Name</h6><small class="text-muted">@($"{item.Author!.Name} {item.Author.LastName}")</small></div>
<div class="d-flex align-items-center gap-3">
<div class="d-flex border rounded-pill">
<button class="btn btn-sm px-2" @onclick="() => ChangeQuantity(item, -1)">-</button>
<span class="px-2 pt-1">@item.Quantity</span>
<button class="btn btn-sm px-2" @onclick="() => ChangeQuantity(item, 1)">+</button>
</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>
<button class="btn btn-sm text-danger" @onclick="() => RemoveFromCart(item)">Remove</button>
</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 class="card border-0 shadow-sm p-4 mb-4">
<h5 class="fw-bold mb-3">Shipping Method</h5>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="shipping" id="pickup"
checked=@(ShippingCost == 0) @onclick="() => ShippingCost = 0">
<label class="form-check-label" for="pickup">Pickup from Bookshop (Free)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="shipping" id="delivery"
checked=@(ShippingCost == 60) @onclick="() => ShippingCost = 60">
<label class="form-check-label" for="delivery">Home Delivery (R60.00)</label>
</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 class="card border-0 shadow-sm p-4">
<h5 class="fw-bold mb-3">Shipping Address</h5>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="sameAsBilling" @bind="IsSameAddress">
<label class="form-check-label" for="sameAsBilling">Billing address same as shipping</label>
</div>
</div>
</div>
}
<div class="col-lg-4">
<div class="card border-0 shadow-sm p-4 sticky-top" style="top: 100px;">
<h5 class="fw-bold mb-3">Order Summary</h5>
<div class="d-flex justify-content-between mb-2"><span>Subtotal</span><span>R @ShoppingCart.TotalAmount.ToString("F2")</span></div>
<div class="d-flex justify-content-between mb-2"><span>VAT (15%)</span><span>R @ShoppingCart.TotalVat.ToString("F2")</span></div>
<div class="d-flex justify-content-between mb-2"><span>Shipping</span><span>R @($"{ShippingCost:F2}")</span></div>
<hr />
<div class="d-flex justify-content-between mb-4">
<span class="fw-bold">Total Due</span>
<h4 class="fw-bold">R @($"{ShoppingCart.TotalAmount + ShoppingCart.TotalVat + ShippingCost:F2}")</h4>
</div>
<button class="btn btn-dark w-100 py-3 rounded-pill" @onclick="PayNow">Complete Purchase</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>
@@ -1,18 +1,13 @@
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Customers.Models;
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;
using LiteCharms.Features.MidrandBooks.Products;
namespace MidrandBookshop.Components.Pages;
@@ -24,130 +19,91 @@ public partial class Checkout()
[Inject] public BooksService BooksService { get; set; } = default!;
[Inject] public CartService CartService { get; set; } = default!;
[Inject] public PayfastService PayfastService { get; set; } = default!;
[Inject] public CustomerService CustomerService { get; set; } = default!;
[Inject] public ProductService ProductService { 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 AuthenticationState? AuthState { get; set; }
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;
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 async void CartService_OnCartChanged() => 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)
private async void 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)
private async void 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;
}
if (IsProcessing) return;
try
{
// 1. Instantly disable the button to prevent duplicate click submissions
IsProcessing = true;
StateHasChanged();
StateHasChanged(); // Force Blazor Server to push the disabled state over SignalR immediately
Result<long> orderResult;
var customerId = (long)ShoppingCart.CustomerId!;
var customerEmail = User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!;
if (!ShoppingCart.OrderId.HasValue)
// 2. Create customer if ShoppingCart.CustomerId is null
if (ShoppingCart.CustomerId == null)
{
CreateOrder request = new(ShoppingCart.TotalAmount, null);
orderResult = await OrderService.CreateOrderAsync(customerId, request, CancellationToken);
ShoppingCart.OrderId = orderResult.Value;
var existingCustomer = await CustomerService.GetCustomerAsync(customerEmail);
if (existingCustomer.IsSuccess)
ShoppingCart.CustomerId = existingCustomer.Value.Id;
if (existingCustomer.IsFailed)
{
var customerCreate = await CustomerService.CreateCustomerAsync(new CreateCustomer { Email = customerEmail });
if (customerCreate.IsSuccess)
ShoppingCart.CustomerId = customerCreate.Value;
}
}
List<CreateOrderItem> orderItems = [];
var orderId = (long)ShoppingCart.OrderId;
// 3. Create order using shopping cart and assign the ShoppingCart.OrderId
await OrderService.ClearOrderItemsAsync(orderId, CancellationToken);
var order = await OrderService.CreateOrderAsync(ShoppingCart.CustomerId!.Value, new CreateOrder(ShoppingCart.TotalAmount, null));
List<CreateOrderItem> orderItems = [];
foreach (var item in ShoppingCart.Items)
{
var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id, CancellationToken);
var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id);
if (bookRequest.IsSuccess)
{
var orderItem = new CreateOrderItem(bookRequest.Value.Id, item.Price.Id, item.Quantity);
@@ -155,47 +111,33 @@ public partial class Checkout()
}
}
var orderHash = HashService.HashEncodeLongId(orderId).Value;
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken);
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, order.Value, HashService.HashEncodeLongId(order.Value).Value);
var merchantPaymentId = HashService.HashEncodeLongId(order.Value).Value;
long paymentId = 0;
if (paymentGen.IsSuccess) paymentId = paymentGen.Value;
if (paymentGen.IsFailed)
await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
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,
OrderId = order.Value,
CustomerId = ShoppingCart.CustomerId.Value,
PaymentGatewayId = 1,
PaymentGatewayReference = orderHash,
PaymentId = paymentId,
PaymentGatewayReference = merchantPaymentId,
PaymentId = paymentGen.Value,
Status = LiteCharms.Features.LedgerStatuses.Sent,
};
await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken);
});
var addItemsResult = await OrderService.AddItemsToOrderAsync(orderId, [.. orderItems], CancellationToken);
var addItemsResult = await OrderService.AddItemsToOrderAsync(order.Value, [.. orderItems]);
// 4. Generate the signed Payfast form payload using your backend service
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}" },
{ "return_url", $"{hostAddress}/payment-success" },
{ "cancel_url", $"{hostAddress}/payment-failed" },
{ "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 },
{ "email_address", customerEmail },
{ "m_payment_id", merchantPaymentId },
{ "amount", ShoppingCart.TotalAmount.ToString("F2", CultureInfo.InvariantCulture) },
{ "item_name", "MidrandBooks Sale" },
};
@@ -204,13 +146,14 @@ public partial class Checkout()
CheckoutPayload.Add("signature", signature);
StateHasChanged();
// 6. Execute programmatic submit directly into the sandbox
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('payfastForm').submit();");
}
catch (Exception ex)
catch
{
ToastService.ShowError($"Failed to perform checkout: {ex.Message}", "Checkout");
IsProcessing = false;
StateHasChanged();
}
}
}
}
@@ -1,347 +0,0 @@
/* ==========================================================================
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;
}
@@ -1,97 +0,0 @@
@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();
}
}
}
@@ -1,126 +0,0 @@
/* ==========================================================================
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;
}
}
@@ -2,7 +2,6 @@
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;
@@ -16,9 +15,6 @@ public partial class Home : ComponentBase
[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; }
@@ -99,21 +95,6 @@ public partial class Home : ComponentBase
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()
@@ -1,6 +1,5 @@
@page "/payment-failed"
@rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize]
<div class="container py-5">
@@ -14,16 +13,18 @@
<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>
<h1 class="fw-bold mb-3">Payment Failed</h1>
<p class="text-muted fs-5">We couldn't process your transaction. Don't worry, no money was deducted from your account, and your cart items are safe.</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>
<p class="mb-0 fs-6 text-dark mt-1">Insufficient funds, incorrect card details, or a temporary bank gateway timeout.</p>
</div>
</div>
<div class="d-grid gap-3 mt-5">
<div class="d-grid gap-3 mt-5">
<a href="/checkout" class="btn btn-dark btn-lg rounded-pill py-3">Try Again</a>
<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>
@@ -34,7 +35,7 @@
</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>
<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>user@email.com</strong>.</p>
</div>
</div>
</div>
@@ -1,72 +0,0 @@
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();
}
}
@@ -1,6 +1,5 @@
@page "/payment-success"
@rendermode InteractiveServer
@inject NavigationManager Navigation
@attribute [Authorize]
<div class="container py-5">
@@ -17,7 +16,7 @@
<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>
<h5 class="fw-bold mb-0">#MB-2026-8834</h5>
</div>
</div>
@@ -28,12 +27,12 @@
<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>
<a href="/track-order" 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>
<p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>user@email.com</strong>.</p>
</div>
</div>
</div>
@@ -1,70 +0,0 @@
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();
}
}
@@ -1,83 +0,0 @@
@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' }});
}}
");
}
}
@@ -1,91 +0,0 @@
/* ==========================================================================
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;
}
@@ -54,19 +54,7 @@
{
<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>
}
<span class="badge badge-ebook">In Stock</span>
</div>
</div>
@@ -101,24 +89,14 @@
<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 class="quantity-picker">
<button @onclick="DecreaseQty" class="qty-btn">-</button>
<span class="qty-val">@Quantity</span>
<button @onclick="IncreaseQty" class="qty-btn">+</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>
}
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
Add to Cart
</button>
</div>
<hr class="divider" />
@@ -5,7 +5,6 @@ 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;
@@ -31,9 +30,6 @@ public partial class ProductView : ComponentBase
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()
@@ -52,9 +48,6 @@ public partial class ProductView : ComponentBase
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;
@@ -97,52 +90,47 @@ public partial class ProductView : ComponentBase
protected async void IncreaseQty()
{
// Enforce maximum stock bounds limits natively during counter picking
if (CurrentPrice is not null && Quantity < StockCount)
if (CurrentPrice is not null)
{
CartService.UpdateQuantity(CurrentPrice!.Id, 1);
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
await CartService.SaveCartToStorageAsync();
StateHasChanged();
}
}
protected async void DecreaseQty()
protected async void DecreaseQty()
{
if (Quantity > 1)
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 (CurrentProduct == null) return;
if (CurrentPrice is not null)
{
if (ShoppingCart.Items.Any(p => p.Price!.Id == CurrentPrice.Id))
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();
}
}
@@ -1,89 +0,0 @@
@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' }});
}}
");
}
}
@@ -1,86 +0,0 @@
/* ==========================================================================
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;
}
-116
View File
@@ -1,116 +0,0 @@
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();
}
}
+3 -5
View File
@@ -18,13 +18,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="LiteCharms.Features" Version="1.137.0" />
<PackageReference Include="LiteCharms.Features" Version="1.127.0" />
</ItemGroup>
<!-- UI -->
<ItemGroup>
<PackageReference Include="ANM.Blazored.Toast" Version="0.1.1" />
<PackageReference Include="LiteCharms.Features.MidrandBooks" Version="1.137.0" />
<PackageReference Include="LiteCharms.Features.MidrandBooks" Version="1.127.0" />
<!-- Global Usings -->
<Using Include="Blazored.Toast.Services" />
@@ -54,15 +54,13 @@
<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.Extensions.Options" />
<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>
+48 -24
View File
@@ -1,51 +1,75 @@
using LiteCharms.Features.Extensions;
using LiteCharms.Features.Postgres;
using MidrandBookshop;
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Extensions;
using LiteCharms.Features.MidrandBooks.Payments;
using MidrandBookshop.Components;
using static LiteCharms.Features.Extensions.Quartz;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.AddMonitoring();
builder.Services.RegisterServices(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddMediator();
builder.Services.AddLiteCharmsWebSecurity(builder.Configuration, builder.Environment);
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzSchedulerClient(MidrandShopSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddEmailServiceBus();
builder.Services.AddHttpClient();
builder.Services.AddScoped<CartService>();
builder.Services.AddShopServices(includeLocalStorage: true);
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddSecurityApiSdk(builder.Configuration);
builder.Services.AddPayfastServices(builder.Configuration);
builder.Services.AddMidrandShopDatabase(builder.Configuration);
builder.Services.AddMidrandShopPostgresHealthCheck();
builder.Services.AddMidrandShopQuartzHealthCheck();
builder.Services.AddHealthChecksSupport(builder.Configuration);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
app.AddSecurityEndpoints();
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.MapStaticAssets();
app.UseCookiePolicy();
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
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.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:8443;http://localhost:8080",
"applicationUrl": "https://localhost:7021;http://localhost:5053",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
-62
View File
@@ -1,62 +0,0 @@
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;
}
}
-110
View File
@@ -112,113 +112,3 @@ h1:focus, h2:focus, h3:focus, h4:focus, p:focus, div:focus, span:focus {
[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;
}
+103 -38
View File
@@ -1,3 +1,4 @@
---
apiVersion: v1
kind: Namespace
metadata:
@@ -9,8 +10,8 @@ metadata:
name: midrandbooks-config
namespace: midrandbooks-uat
data:
ASPNETCORE_ENVIRONMENT: "Development"
ASPNETCORE_URLS: "http://0.0.0.0:8443"
ASPNETCORE_ENVIRONMENT: "Development"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
Monitoring__Address: "http://aspire-dashboard-service.aspire.svc.cluster.local:18889"
Monitoring__ServiceName: "MidrandBooks.Uat"
HasherSettings__MinHashLength: "11"
@@ -26,11 +27,33 @@ data:
PayfastSettings__ValidHosts__4: "payment.payfast.io"
LiteCharmsSettings__Authority: "https://sts.security.khongisa.co.za"
LiteCharmsSettings__Audience: "midrandbooks-api"
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
LiteCharmsClientSettings__Authority: "https://sts.security.khongisa.co.za"
LiteCharmsClientSettings__GrantType: "client_credentials"
LiteCharmsClientSettings__Scope: "midrandbooks-api"
---
apiVersion: v1
kind: Secret
metadata:
name: midrandbooks-secrets
namespace: midrandbooks-uat
type: Opaque
data:
connection-string: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPW1pZHJhbmRzaG9wLWRldjtVc2VybmFtZT1taWRyYW5kc2hvcC1kZXYtdXNlcjtQYXNzd29yZD1hUFh5a0tnM3RTOWNtRDtQZXJzaXN0IFNlY3VyaXR5IEluZm89VHJ1ZQ==
connection-string-quartz: SG9zdD0xOTIuMTY4LjEuMTcwO0RhdGFiYXNlPXNjaGVkdWxlci1kZXY7VXNlcm5hbWU9c2NoZWR1bGVyLWRldi11c2VyO1Bhc3N3b3JkPWtWVm1vV0tKM3h6Z1FYO1BlcnNpc3QgU2VjdXJpdHkgSW5mbz1UcnVl
aspire-apikey: bWMzRzYzSzJqNVpPRXNpMEFqTW9qTFRYbTFLRVpGY3R6SUlqU3dEaVRHdXQ4cUdTa1B1V3d4R1AxUmJzY0pVbw==
hasher-salt: VEdsbmFIUWdRMmhoY20xekxDQk5hV1J5WVc1a1FtOXZhM01nYldGclpTQnNiM1J6SUc5bUlHMXZibVY1SUdGdVpDQmhjbVVnWVNCemRXTmpaWE56Wm5Wc0lIWnBjbUZzSUhOMGIzSjVJR2x1SUZOdmRYUm9JRUZtY21sallRPT0=
bookshop-s3-accesskey: R0s1MTRkMmNlOGRjNjkyMzdhMDVjMDFlZWY=
bookshop-s3-secretkey: ZWFhZmVkYTFhZWQ0MDllY2ZlNjA3MTRlY2RhNTQ5YjgyYmRmNWEzZGFmOWYxOGRkNjFmNjZiNDk3M2E2NDgyZQ==
litecharms-clientid: bWlkcmFuZGJvb2tzLXVhdA==
litecharms-clientsecret: c2VjcmV0Xzc3OGJkODM3NWFjNGE3Mzg2N2QxZDdhNjcwODJlZTJjNGU4NmUwODYwYmI0Y2ZlZWI5NDExOTQ5OTk2ZThhOGU=
payfast-passphrase: OUdBSVIwdFdwaFgwcU8=
payfast-merchantid: MTAwNDkzMDc=
payfast-merchantkey: anU2bmF2bjBqY2JmMA==
litecharms-client-clientid: bWlkcmFuZGJvb2tzLWFwaS1zY2FsZXItdWF0
litecharms-client-clientsecret: c2VjcmV0XzBhOGRjMWY5OTA2MTU5MGE1MmIxMjcyZGIzYTE4NzFkMjc2MWM3OWZiZDA1OGIyYTk2ODkxMTAyOWU0YjIwOGE=
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: midrandbooks-pvc
@@ -49,7 +72,6 @@ metadata:
namespace: midrandbooks-uat
spec:
replicas: 2
revisionHistoryLimit: 0
selector:
matchLabels:
app: midrandbooks
@@ -77,60 +99,102 @@ spec:
memory: "256Mi"
cpu: "100m"
ports:
- containerPort: 8443
- containerPort: 8080
envFrom:
- configMapRef:
name: midrandbooks-config
- secretRef:
name: midrandbooks-secrets
env:
- name: DataProtection__Certificate
- name: LiteCharmsSettings__ClientId
valueFrom:
secretKeyRef:
name: litecharms-certs
key: litecharms.pfx
- name: DataProtection__Password
name: midrandbooks-secrets
key: litecharms-clientid
- name: LiteCharmsSettings__ClientSecret
valueFrom:
secretKeyRef:
name: litecharms-certs
key: passphrase
name: midrandbooks-secrets
key: litecharms-clientsecret
- name: BookshopS3Settings__AccessKey
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: bookshop-s3-accesskey
- name: BookshopS3Settings__SecretKey
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: bookshop-s3-secretkey
- name: HasherSettings__Salt
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: hasher-salt
- name: PayfastSettings__Passphrase
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: payfast-passphrase
- name: PayfastSettings__MerchantId
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: payfast-merchantid
- name: PayfastSettings__MerchantKey
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: payfast-merchantkey
- name: LiteCharmsClientSettings__ClientId
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: litecharms-client-clientid
- name: LiteCharmsClientSettings__ClientSecret
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: litecharms-client-clientsecret
- name: ConnectionStrings__PostgresScheduler
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: connection-string-quartz
- name: ConnectionStrings__PostgresMidrandBooks
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: connection-string
- name: Monitoring__ApiKey
valueFrom:
secretKeyRef:
name: midrandbooks-secrets
key: aspire-apikey
volumeMounts:
- name: cluster-certs-volume
mountPath: /tmp/litecharms-raw-certs
readOnly: true
- name: data
mountPath: /app/content
mountPath: /app/wwwroot/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
- name: shared-keys-volume
mountPath: /app/shared-keys
subPath: dataprotection-keys
livenessProbe:
httpGet:
path: /health
port: 8443
scheme: HTTP
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8443
scheme: HTTP
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: midrandbooks-pvc
- name: cluster-certs-volume
secret:
secretName: litecharms-certs
- name: shared-keys-volume
persistentVolumeClaim:
claimName: midrandbooks-pvc
---
apiVersion: v1
kind: Service
@@ -138,12 +202,14 @@ metadata:
name: midrandbooks-service
namespace: midrandbooks-uat
spec:
ports:
- name: https
port: 443
targetPort: 8443
type: ClusterIP
selector:
app: midrandbooks
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
@@ -158,11 +224,10 @@ spec:
kind: Rule
services:
- name: midrandbooks-service
port: 443
port: 80
sticky:
cookie:
name: "lp-sticky-session"
httpOnly: true
secure: true
scheme: http
tls: {}