Compare commits

..

2 Commits

Author SHA1 Message Date
khwezi 925456f35b Merge pull request 'Added legal pages, contact and abut us' (#101) from checkout into main
Reviewed-on: #101
2026-06-16 23:33:33 +02:00
Khwezi Mngoma 8d2efbeb4a Added legal pages, contact and abut us
continuous-integration/drone/pr Build is passing
Redesigned account, checkout
Added stock management design elements
2026-06-16 23:32:44 +02:00
18 changed files with 1642 additions and 555 deletions
@@ -1,31 +1,58 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
<div class="glassine-page-jacket"></div>
<div class="literary-sync-strip">
<div class="strip-container">
<div class="sync-status-indicator">
<svg class="literary-helix-loader" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path class="book-base-spine" d="M12 5v14M12 5c-1.5-2-4.5-2-7-2H2v14h3c2.5 0 5.5 0 7 2M12 5c1.5-2 4.5-2 7-2h3v14h-3c-2.5 0-5.5 0-7 2" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
<path class="flipping-leaf-vector" d="M12 5c-1-1.5-3-2-5-2H3v14h4c2 0 4 .5 5 2" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="font-monospace text-uppercase small-tracking">Catalog Circuit Sync</span>
</div>
<div class="sync-message-body">
<span class="components-reconnect-first-attempt-visible">
Connection interrupted. Re-indexing active reading stack...
</span>
<span class="components-reconnect-repeated-attempt-visible">
Sync delayed. Re-aligning database archives in <span id="components-seconds-to-next-attempt" class="fw-bold font-monospace">0</span>s...
</span>
<span class="components-reconnect-failed-visible text-crimson">
Archival path blocked. Automated sync offline.
</span>
<span class="components-pause-visible">
Reading layout paused by host environment node.
</span>
<span class="components-resume-failed-visible text-crimson">
State alignment broken.
</span>
</div>
<div class="sync-action-node">
<button id="components-reconnect-button" class="btn-strip-action components-reconnect-failed-visible">
<span>Retry Sync</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg>
</button>
<button id="components-resume-button" class="btn-strip-action components-pause-visible components-resume-failed-visible">
<span>Reload Session</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
</dialog>
@@ -1,157 +1,218 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
/* ==========================================================================
Midrand Books — Glassine Architectural Veil & Ribbon Strip
========================================================================== */
/* --- Native Dialog Element Layout Overrides --- */
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
margin: 0;
padding: 0;
border: none;
background: transparent;
z-index: 99999;
overflow: hidden;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
/* Remove default browser modal backdrop blockout to allow custom layering below */
#components-reconnect-modal::backdrop {
background: transparent;
}
100% {
/* --- Glassine Page Jacket Layer ---
Frosted translucent shield that preserves context visibility while blocking mouse actions
*/
.glassine-page-jacket {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(251, 251, 250, 0.4); /* Premium warm paper tint */
backdrop-filter: blur(5px); /* Elegant frosted glass sweep */
cursor: wait;
animation: glassFadeIn 0.35s ease-out forwards;
}
@keyframes glassFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(5px);
}
}
/* --- The Sliding Ribbon Banner --- */
.literary-sync-strip {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: #FFFFFF;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.02);
padding: 1.1rem 2rem;
box-sizing: border-box;
z-index: 2;
/* Animation kinematics: smooth physical drop slide */
transform: translateY(-100%);
animation: stripSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes stripSlideDown {
to {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
/* --- Ribbon Layout Matrix Grid --- */
.strip-container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1rem;
gap: 2.5rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
/* Left Node Ticker Elements & Animated Vector */
.sync-status-indicator {
display: flex;
align-items: center;
gap: 0.85rem;
border-right: 1px solid rgba(0, 0, 0, 0.08);
padding-right: 2rem;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
.small-tracking {
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
letter-spacing: 0.12em;
color: #111111;
font-weight: 600;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
/* Animated Book Helix SVG */
.literary-helix-loader {
width: 18px;
height: 18px;
color: #111111;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.flipping-leaf-vector {
transform-origin: 12px 12px;
animation: svgLeafFlip 1.6s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
@keyframes svgLeafFlip {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
transform: scaleX(1);
opacity: 1;
}
50% {
transform: scaleX(0);
opacity: 0.3;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
transform: scaleX(-1);
opacity: 0;
}
}
/* Center Node Text Content */
.sync-message-body {
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
color: #333333;
font-style: italic;
}
.text-crimson {
color: #A34843;
font-style: normal;
font-weight: 500;
}
/* Right Node Fine-Press Button Trigger */
.btn-strip-action {
background: #111111;
color: #FFFFFF;
border: 1px solid #111111;
padding: 0.45rem 1.25rem;
font-family: var(--bs-font-monospace);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-strip-action:hover {
background: transparent;
color: #111111;
}
.btn-strip-action svg {
transition: transform 0.2s ease;
}
.btn-strip-action:hover svg {
transform: rotate(45deg);
}
/* --- Display Mechanics Matrix Controllers --- */
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible {
display: none !important;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: inline-block !important;
}
#components-reconnect-modal.components-reconnect-failed .btn-strip-action,
#components-reconnect-modal.components-reconnect-paused .btn-strip-action,
#components-reconnect-modal.components-reconnect-resume-failed .btn-strip-action {
display: inline-flex !important;
}
/* Tablet Parameters Response Collapse Matrix */
@media (max-width: 768px) {
.strip-container {
grid-template-columns: 1fr;
gap: 0.65rem;
text-align: center;
}
.sync-status-indicator {
border-right: none;
padding-right: 0;
justify-content: center;
}
.sync-action-node {
margin-top: 0.25rem;
}
}
@@ -1,4 +1,3 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
@@ -24,14 +23,8 @@ async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
@@ -40,7 +33,6 @@ async function retry() {
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
@@ -60,4 +52,4 @@ async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
}
@@ -0,0 +1,68 @@
@page "/about"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="editorial-page-container py-5">
<header class="editorial-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Our Story & Vision</span>
<h1 class="editorial-main-title fw-bold">For the Love of the Written Word</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is an independent literary imprint and online bookstore operated by <strong>Lite Charms (Pty) Ltd</strong>.
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="editorial-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("about-bookshelf")' class="index-btn text-start">1. The Bookshelf</button>
<button @onclick='() => ScrollToSection("about-publishing")' class="index-btn text-start">2. Independent Publishing</button>
<button @onclick='() => ScrollToSection("about-community")' class="index-btn text-start">3. Our Community</button>
</div>
</div>
<div class="col-lg-9">
<article class="editorial-article-body">
<section id="about-bookshelf" class="editorial-section mb-5">
<h3 class="section-title">1. A Curated Space for Readers</h3>
<p>
At Midrand Books, we believe that an online bookstore should feel just as warm, inspiring, and intentional as a physical corner shop. We arent interested in mass-market commercial algorithms; we are interested in books that leave a mark.
</p>
<p>
Operated proudly under Lite Charms (Pty) Ltd, our storefront is designed to showcase beautiful storytelling, critical histories, academic research, and deep technical disciplines. We source fine press editions and trusted literary brands, ensuring that every book we package and deliver across South Africa feels special from the moment it reaches your hands.
</p>
</section>
<section id="about-publishing" class="editorial-section mb-5">
<h3 class="section-title">2. Empowering New & Independent Voices</h3>
<p>
Beyond our role as a bookseller, our truest passion lies in cultivating the next chapter of South African literature. We know how daunting the modern publishing landscape can be for emerging storytellers, experts, and independent creators.
</p>
<p>
That is why we have integrated custom distribution channels into our platform to help self-publishers and local authors get their manuscripts beautifully styled, correctly indexed, and directly in front of avid readers. Side-by-side with heritage publishing houses, we champion the creative freedom of the indie writer.
</p>
</section>
<section id="about-community" class="editorial-section mb-5 protective-credo-callout">
<h3 class="section-title text-muted-serif">3. Our Creative Guarantee</h3>
<p>
Whether you are an established global author footprint looking for a seamless marketplace, a first-time writer ready to publish your debut book, or a reader hunting for your next great obsession, we welcome you. Midrand Books operates in full compliance with the South African Companies Act, ensuring that your data, transactions, and intellectual property are kept fully safe.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,85 @@
/* ==========================================================================
Midrand Books — About Page Fine Press Styles
========================================================================== */
.editorial-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.editorial-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Controls --- */
.editorial-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Narrative Typography --- */
.editorial-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem;
}
.text-muted-serif {
color: #555555;
}
.editorial-section p {
margin-bottom: 1.2rem;
}
.protective-credo-callout {
background-color: #FAFAFA;
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 2rem;
border-radius: 8px;
}
+219 -207
View File
@@ -4,225 +4,237 @@
@rendermode InteractiveServer
@attribute [Authorize]
<div class="container py-5">
<h2 class="fw-bold mb-5 tracking-tight">My Account</h2>
<div class="account-page-container py-5">
<header class="account-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Customer Dashboard</span>
<h1 class="account-main-title fw-bold">My Account</h1>
</header>
<div class="row g-5">
<div class="col-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 class="col-lg-3">
<div class="nav flex-column account-nav-stack gap-1" role="tablist">
<button class="nav-link active text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#orders" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"></path><line x1="3" y1="6" x2="21" y2="6"></line><path d="M16 10a4 4 0 0 1-8 0"></path></svg>
<span>Order History</span>
</button>
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#shipping" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
<span>Shipping Address</span>
</button>
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#profile" role="tab">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
<span>Profile Settings</span>
</button>
<hr class="my-3 opacity-10" />
<button class="nav-link nav-logout text-danger text-start d-flex align-items-center gap-2" @onclick="TriggerLogout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
<span>Logout Account</span>
</button>
</div>
</div>
<div class="col-md-9">
<div class="col-lg-9">
<AuthorizeView>
<Authorized>
<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 class="tab-content account-panels-deck">
<div class="tab-pane fade show active" id="orders" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4">Order History</h5>
@if (orderHistory == null || !orderHistory.Any())
{
<div class="text-center py-5 border rounded-3 bg-light bg-opacity-50">
<p class="text-muted small mb-0">You haven't placed any orders with us yet.</p>
</div>
}
else
{
<div class="orders-stack d-flex flex-column gap-4">
@foreach (var order in orderHistory)
{
<div class="premium-order-card p-4 border rounded-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 pb-3 border-b-dashed mb-3">
<div>
<span class="font-monospace text-dark fw-bold d-block h6 mb-1">@order.OrderId</span>
<small class="text-muted d-block mb-1">Ordered on @order.OrderDate.ToString("dd MMMM yyyy")</small>
<small class="text-secondary d-flex align-items-center gap-1" style="font-size: 0.8rem;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
<span>Shipped to: <span class="fw-semibold text-dark">@order.ShippingAddressName</span></span>
</small>
</div>
<div class="text-md-end d-flex flex-column align-items-md-end gap-2">
<div class="d-flex align-items-center gap-1.5 flex-wrap justify-content-md-end">
<span class="badge status-badge-base @GetPaymentStatusClass(order.PaymentStatus)">
Pay: @order.PaymentStatus
</span>
<span class="badge status-badge-base @GetStatusClass(order.Status)">
Logistics: @order.Status
</span>
</div>
@if (order.PaymentStatus?.ToLower() == "paid")
{
<button class="btn btn-outline-dark btn-premium-sm font-monospace text-uppercase d-inline-flex align-items-center gap-1.5 py-1 px-2.5"
style="font-size: 0.7rem;"
@onclick="() => DownloadInvoice(order.OrderId)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span>Invoice</span>
</button>
}
</div>
</div>
<div class="order-manifest-details d-flex align-items-center justify-content-between gap-4 py-1">
<div class="item-meta">
<h6 class="text-dark fw-bold mb-0 small" style="line-height: 1.4;">@order.ProductTitle</h6>
</div>
<div class="item-value text-end flex-shrink-0">
<span class="font-monospace text-dark fw-bold d-block">R @order.Total.ToString("F2")</span>
<small class="text-muted extra-small font-monospace">VAT Inclusive</small>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<div class="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 class="tab-pane fade" id="shipping" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<div class="d-flex justify-content-between align-items-baseline mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-0">Saved Addresses</h5>
@if (!showAddForm && editingAddress == null)
{
<button class="btn btn-outline-dark btn-premium-sm font-monospace text-uppercase" @onclick="() => showAddForm = true">
Add New Address
</button>
}
</div>
@if (showAddForm || editingAddress != null)
{
<div class="premium-interactive-form p-4 border rounded-3 mb-4 bg-light bg-opacity-20 animate-fade-in">
<h6 class="fw-bold text-dark mb-3">@(editingAddress != null ? "Modify Curated Address" : "Register Destination Address")</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Address Name Label</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., Home" @bind="newAddressName" />
</div>
<div class="col-md-6">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Postal Routing Code</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., 1685" @bind="newPostalCode" />
</div>
<div class="col-12">
<label class="form-label extra-small font-monospace text-uppercase text-muted">Street Address Lines</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., 12 Main Road" @bind="newStreetAddress" />
</div>
<div class="col-md-12">
<label class="form-label extra-small font-monospace text-uppercase text-muted">City / Region</label>
<input type="text" class="form-control premium-plaintext-field" placeholder="e.g., Midrand" @bind="newCity" />
</div>
<div class="col-12 d-flex flex-wrap gap-4 py-2 border-y my-2 bg-white px-3 rounded border">
<div class="form-check d-flex align-items-center gap-2 m-0">
<input type="checkbox" class="form-check-input custom-box-tick m-0" id="isBillingCheck" @bind="isBilling" />
<label class="form-check-label context-clickable small fw-medium text-dark" for="isBillingCheck">Default Billing Endpoint</label>
</div>
<div class="form-check d-flex align-items-center gap-2 m-0">
<input type="checkbox" class="form-check-input custom-box-tick m-0" id="isShippingCheck" @bind="isShipping" />
<label class="form-check-label context-clickable small fw-medium text-dark" for="isShippingCheck">Default Fulfillment Endpoint</label>
</div>
</div>
<div class="col-12 d-flex justify-content-end gap-2 mt-3">
<button class="btn btn-clean-cancel font-monospace text-uppercase small" @onclick="CancelAddressActions">Cancel</button>
<button class="btn btn-dark px-4 py-2 text-uppercase font-monospace small" @onclick="SaveAddress">Save Address Details</button>
</div>
</div>
</div>
}
<div class="row g-4">
@foreach (var addr in savedAddresses)
{
<div class="col-md-6">
<div class="address-curated-card p-4 border rounded-3 position-relative d-flex flex-column h-100 bg-white @(addr.IsPrimary ? "border-dark shadow-sm" : "opacity-90")">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="fw-bold text-dark font-monospace tracking-wide text-uppercase" style="font-size: 0.82rem;">@addr.Name</span>
<div class="form-check d-flex align-items-center gap-1.5 p-0 m-0">
<input type="radio" class="form-check-input custom-box-tick m-0" name="primaryAddr" id="@($"primary-{addr.Id}")" checked="@addr.IsPrimary" @onchange="(e) => SetPrimary(addr, e)" />
<label class="form-check-label extra-small text-muted font-monospace text-uppercase context-clickable ms-1" for="@($"primary-{addr.Id}")">Primary</label>
</div>
</div>
<div class="address-body-text text-muted mb-4 mt-1 flex-grow-1" style="font-size: 0.88rem; line-height: 1.6;">
<span class="d-block text-dark fw-medium">@addr.Street</span>
<span class="d-block">@addr.City</span>
<span class="font-monospace text-secondary extra-small d-block mt-1">ZA-@addr.PostalCode</span>
</div>
<div class="address-metadata-badges d-flex flex-wrap gap-1 mb-3">
@if (addr.IsBilling)
{
<span class="badge bg-light text-secondary font-monospace tracking-wide border text-uppercase extra-small px-2 py-1">Billing</span>
}
@if (addr.IsShipping)
{
<span class="badge bg-light text-dark font-monospace tracking-wide border border-secondary text-uppercase extra-small px-2 py-1">Shipping</span>
}
</div>
<div class="address-actions-row border-top-dashed pt-3 d-flex gap-3 justify-content-end mt-auto">
<button class="btn-action-trigger text-uppercase font-monospace extra-small text-muted border-0 bg-transparent" @onclick="() => EditAddress(addr)">Edit</button>
<button class="btn-action-trigger text-uppercase font-monospace extra-small text-danger border-0 bg-transparent" @onclick="() => DeleteAddress(addr)">Delete</button>
</div>
</div>
</div>
}
</div>
</div>
</div>
<div class="tab-pane fade" id="profile" role="tabpanel">
<div class="panel-card-wrapper mb-4">
<h5 class="panel-section-title fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4">Profile Settings</h5>
<div class="profile-hero-banner mb-4 d-flex align-items-center justify-content-between p-4 border rounded-3 bg-light bg-opacity-20 flex-wrap gap-3">
<div class="hero-text-content">
<div class="meta-tag font-monospace text-uppercase text-muted extra-small tracking-wider mb-1">Active Identity</div>
<h5 class="fw-bold text-dark mb-1 h6">@User?.Identity?.Name</h5>
<p class="text-muted small mb-0 font-monospace extra-small opacity-75">Secure Connection Authorized</p>
</div>
<span class="badge rounded-pill bg-success bg-opacity-10 text-success border border-success border-opacity-20 font-monospace px-3 py-1.5 small text-uppercase tracking-wide">
Verified
</span>
</div>
<div class="card p-5 text-center bg-white border rounded-3 shadow-sm my-4">
<div class="mb-4 text-muted opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h5 class="fw-bold text-dark mb-2 h6">Centralized Identity Node Settings</h5>
<p class="text-muted small mx-auto mb-4" style="max-width: 480px; line-height: 1.5;">
For your structural protection, password alterations, account recovery preferences, cross-tenant factors, and core credential manifests are handled through our global Identity Node security layer.
</p>
<a href="https://sts.security.khongisa.co.za/Manage/Index?returnUrl=https://midrandbooks.co.za/account"
target="_blank"
rel="noopener noreferrer"
class="btn btn-dark rounded-pill px-4 py-2.5 btn-sm font-monospace text-uppercase tracking-wider d-inline-flex align-items-center gap-2 mx-auto"
style="font-size: 0.75rem;">
<span>Access Central Security Center</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
</a>
</div>
</div>
</div>
</div>
</Authorized>
</AuthorizeView>
<div 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>
+124 -27
View File
@@ -1,7 +1,10 @@
namespace MidrandBookshop.Components.Pages;
public partial class Account
public partial class Account : ComponentBase
{
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
private ClaimsPrincipal? User { get; set; }
private bool showAddForm = false;
private AddressItem? editingAddress = null;
private string newAddressName = "";
@@ -12,27 +15,84 @@ public partial class Account
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 }
// 1. Delivered + Paid (Green Mapping Rules)
new OrderItem { OrderId = "#MB-2026-8712", ProductId = "mastering-css-isolation", ProductTitle = "Mastering CSS Isolation in Modern .NET Web Applications Architecture", OrderDate = new DateTime(2026, 4, 14), ShippingAddressName = "Midrand Warehouse", Status = "Delivered", PaymentStatus = "Paid", Total = 890.00 },
// 2. Shipped + Paid (Amber Logistics + Green Payment Rules)
new OrderItem { OrderId = "#MB-2026-9481", ProductId = "introduction-to-blazor", ProductTitle = "Introduction to Blazor WebAssembly Framework Development", OrderDate = new DateTime(2026, 5, 20), ShippingAddressName = "Home Address", Status = "Shipped", PaymentStatus = "Paid", Total = 720.00 },
// 3. Unshipped + Abandoned/Unpaid (Muted Grey / Soft Red Rules — Hides Invoice Button)
new OrderItem { OrderId = "#MB-2026-1034", ProductId = "csharp-functional-paradigms", ProductTitle = "Advanced Functional Architecture & Monadic Paradigms in Modern C#", OrderDate = new DateTime(2026, 6, 11), ShippingAddressName = "Home Address", Status = "Unshipped", PaymentStatus = "Unpaid", Total = 650.00 }
};
private List<AddressItem> savedAddresses = new()
{
new AddressItem { Id = 1, Name = "Home Address", Street = "12 Main Road", City = "Midrand", PostalCode = "1685", IsBilling = true, IsShipping = true, IsPrimary = true },
new AddressItem { Id = 2, Name = "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 }
new AddressItem { Id = 2, Name = "Midrand Warehouse", Street = "Corner of Church & Third Roads", City = "Midrand", PostalCode = "1685", IsBilling = false, IsShipping = false, IsPrimary = false }
};
private void TriggerLogout() => Navigation.NavigateTo("/logout", forceLoad: true);
private void DownloadInvoice(string orderId) { /* Handle download sequence here */ }
private void OpenAddForm() { editingAddress = null; showAddForm = true; }
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
User = authState?.User;
}
private void DownloadInvoice(string orderId)
{
Navigation.NavigateTo($"/api/invoices/download/{orderId.Replace("#", "")}", forceLoad: true);
}
private string GetStatusClass(string status) => status?.ToLower() switch
{
"delivered" => "status-delivered", // Green
"shipped" => "status-shipped", // Amber
_ => "status-processing" // Muted Architectural Dark Grey
};
private string GetPaymentStatusClass(string paymentStatus) => paymentStatus?.ToLower() switch
{
"paid" => "pay-paid", // Green
"refunded" => "pay-refunded", // Grey
_ => "pay-pending" // Red Alert Tone
};
private void EditAddress(AddressItem addr)
{
editingAddress = addr;
showAddForm = false;
newAddressName = addr.Name;
newStreetAddress = addr.Street;
newCity = addr.City;
newPostalCode = addr.PostalCode;
isBilling = addr.IsBilling;
isShipping = addr.IsShipping;
}
private void CancelAddressActions()
{
showAddForm = false;
editingAddress = null;
ClearFormFields();
}
private void ClearFormFields()
{
newAddressName = "";
newStreetAddress = "";
newCity = "";
newPostalCode = "";
isBilling = false;
isShipping = false;
}
private void SaveAddress()
{
if (!string.IsNullOrWhiteSpace(newAddressName) && !string.IsNullOrWhiteSpace(newStreetAddress))
if (string.IsNullOrWhiteSpace(newAddressName) || string.IsNullOrWhiteSpace(newStreetAddress)) return;
if (editingAddress == null)
{
var nextId = savedAddresses.Any() ? savedAddresses.Max(a => a.Id) + 1 : 1;
savedAddresses.Add(new AddressItem
var newAddr = new AddressItem
{
Id = nextId,
Name = newAddressName,
@@ -42,34 +102,71 @@ public partial class Account
IsBilling = isBilling,
IsShipping = isShipping,
IsPrimary = !savedAddresses.Any()
});
ResetAddForm();
};
savedAddresses.Add(newAddr);
}
}
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()
{
if (editingAddress != null)
else
{
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; }
if (target != null)
{
target.Name = newAddressName;
target.Street = newStreetAddress;
target.City = newCity;
target.PostalCode = newPostalCode;
target.IsBilling = isBilling;
target.IsShipping = isShipping;
}
editingAddress = null;
}
showAddForm = false;
ClearFormFields();
}
private void DeleteAddress(AddressItem addr) { if (editingAddress?.Id == addr.Id) editingAddress = null; savedAddresses.Remove(addr); if (addr.IsPrimary && savedAddresses.Any()) savedAddresses.First().IsPrimary = true; }
private void 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;
}
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; } }
}
private void TriggerLogout()
{
Navigation.NavigateTo("/logout", forceLoad: true);
}
public class AddressItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public bool IsBilling { get; set; }
public bool IsShipping { get; set; }
public bool IsPrimary { get; set; }
}
public class OrderItem
{
public string OrderId { get; set; } = "";
public string ProductId { get; set; } = "";
public string ProductTitle { get; set; } = "";
public DateTime OrderDate { get; set; }
public string ShippingAddressName { get; set; } = "";
public string Status { get; set; } = "";
public string PaymentStatus { get; set; } = "Pending";
public double Total { get; set; }
}
}
+192 -104
View File
@@ -1,147 +1,235 @@
::deep .container {
max-width: 1100px;
/* ==========================================================================
Curated Architecture Dashboard Style Matrix
========================================================================== */
.account-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
/* Navigation Layout Overrides - Black & White Architectural Style */
.nav-pills .nav-link {
color: #6c757d;
border-radius: 0;
padding: 0.75rem 1rem;
.account-main-title {
font-size: 2.25rem;
letter-spacing: -0.03em;
color: #111111;
}
.tracking-wider {
letter-spacing: 0.08em;
}
.extra-small {
font-size: 0.72rem !important;
}
/* --- Left Sidebar Architectural Pillar Controls --- */
.account-nav-stack .nav-link {
color: #555555;
border-radius: 8px;
padding: 0.8rem 1rem;
font-weight: 500;
font-family: var(--bs-body-font-family);
transition: all 0.2s ease-in-out;
font-size: 0.9rem;
transition: all 0.2s ease;
border: 1px solid transparent;
background: transparent !important;
}
/* Active State - Solid Black Fill with stark white text */
.nav-pills .nav-link.active {
background-color: #1A1A1A !important;
.account-nav-stack .nav-link.active {
background-color: #111111 !important;
color: #FFFFFF !important;
border-color: #1A1A1A;
font-weight: 600;
}
/* Hover State for Unselected Buttons */
.nav-pills .nav-link:hover:not(.active) {
color: #1A1A1A;
background-color: #F8F8F8 !important;
.account-nav-stack .nav-link:hover:not(.active):not(.nav-logout) {
color: #111111;
background-color: rgba(0, 0, 0, 0.04) !important;
transform: translateX(2px);
}
/* Logout Button Link Alignment rules */
.nav-pills .nav-link.text-danger {
color: #DC3545 !important;
.account-nav-stack .nav-logout:hover {
background-color: #FFF5F5 !important;
color: #DC3545 !important;
}
/* --- Main Tabbed Layout Container Content Structures --- */
.panel-card-wrapper {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 2.2rem;
}
.panel-section-title {
font-size: 0.9rem;
color: #666666;
}
/* --- Order History Structured Panel Cards --- */
.premium-order-card {
transition: transform 0.2s ease, border-color 0.2s ease;
}
.premium-order-card:hover {
border-color: rgba(0, 0, 0, 0.15) !important;
}
.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;
}
/* 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;
letter-spacing: 0.15em;
}
.hero-crest-svg svg {
transition: transform 0.4s ease-in-out;
}
.profile-hero-banner:hover .hero-crest-svg svg {
transform: rotate(15deg);
}
/* Cards Layout Rules */
.card {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
}
.address-card {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.address-card:hover {
border-color: rgba(0, 0, 0, 0.16);
}
/* Order Meta Tracks & Status Badges Setup */
.order-meta-track {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
.border-b-dashed {
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
/* Minimalist Curated Logistics and Payment Badges Matrix */
.status-badge-base {
font-family: var(--bs-font-monospace);
font-size: 0.68rem !important;
letter-spacing: 0.08em;
padding: 0.35rem 0.65rem !important;
border-radius: 0px !important;
font-size: 0.65rem !important;
letter-spacing: 0.05em;
padding: 0.3rem 0.6rem !important;
border-radius: 4px !important;
font-weight: 600;
}
/* Fulfillment Matrix Colors */
.status-delivered {
background-color: #E2F0D9 !important;
background-color: #E2F0D9 !important; /* Soft Green Match */
color: #385723 !important;
border: 1px solid rgba(56, 87, 35, 0.15);
}
.status-shipped {
background-color: #FFF2CC !important;
color: #7F6000 !important;
background-color: #FFF3CD !important; /* Warm Gold Amber */
color: #856404 !important;
border: 1px solid rgba(133, 100, 4, 0.12);
}
.status-processing, .status-pending {
background-color: #F2F2F2 !important;
.status-processing {
background-color: #F2F2F2 !important; /* Architectural Neutral Muted Grey */
color: #595959 !important;
border: 1px dashed #D9D9D9;
border: 1px dashed rgba(0, 0, 0, 0.12);
}
/* Restored Action Button Interactive Properties */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
/* Financial Matrix Colors */
.pay-paid {
background-color: #E2F0D9 !important; /* Soft Green Match */
color: #385723 !important;
border: 1px solid rgba(56, 87, 35, 0.15);
}
.pay-pending {
background-color: #F8D7DA !important; /* soft red alert tint for unpaid/abandoned items */
color: #721C24 !important;
border: 1px solid rgba(114, 28, 36, 0.15);
}
.pay-refunded {
background-color: #E2E3E5 !important;
color: #383D41 !important;
border: 1px solid rgba(56, 61, 65, 0.15);
}
/* --- Curated Shipping Identity Panels --- */
.address-curated-card {
transition: all 0.23s cubic-bezier(0.16, 1, 0.3, 1);
}
.address-curated-card:hover {
border-color: #111111 !important;
transform: translateY(-2px);
}
.border-top-dashed {
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.btn-action-trigger {
transition: color 0.15s ease;
}
.btn-action-trigger:hover:not(.text-danger) {
color: #111111 !important;
text-decoration: underline;
}
.btn-action-trigger:hover.text-danger {
color: #A71D2A !important;
text-decoration: underline;
}
/* --- Interactive Form Element Overlays --- */
.premium-plaintext-field {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: 0.65rem 0.85rem;
font-size: 0.9rem;
color: #111111;
transition: all 0.2s ease;
}
.premium-plaintext-field:focus {
border-color: #111111;
box-shadow: 0 0 0 1px #111111;
background-color: #FFFFFF;
}
.custom-box-tick {
border: 1.5px solid rgba(0, 0, 0, 0.25);
width: 1rem;
height: 1rem;
border-radius: 4px;
transition: all 0.15s ease;
cursor: pointer;
}
.custom-box-tick:checked {
background-color: #111111;
border-color: #111111;
}
.btn-premium-sm {
font-size: 0.75rem;
padding: 0.45rem 1rem;
border-radius: 6px;
font-weight: 500;
}
.btn-clean-cancel {
background: transparent;
border: none;
color: #1A1A1A;
transition: background-color 0.15s ease, transform 0.1s ease;
color: #666666;
transition: color 0.2s ease;
}
.action-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
.btn-clean-cancel:hover {
color: #111111;
}
.action-btn:active {
transform: scale(0.92);
}
.border-y {
border-top: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.pointer-label {
.context-clickable {
cursor: pointer;
user-select: none;
}
.tab-pane #profile .card {
border: 1px dashed rgba(0, 0, 0, 0.15) !important;
/* --- Identity Node Presentation Wrappers --- */
.profile-hero-banner {
border: 1px solid rgba(0, 0, 0, 0.05);
}
.animate-fade-in {
animation: layoutFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes layoutFadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+29 -18
View File
@@ -5,7 +5,6 @@
<div class="checkout-page-container py-5">
<!-- HANDOVER MODAL VIEW OVERLAY -->
@if (IsProcessing)
{
<div class="processing-screen-overlay">
@@ -35,15 +34,30 @@
</div>
}
<!-- Header Presentation Details -->
<header class="checkout-header mb-5">
<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)
{
<!-- EMPTY STATE TRAP: Displays when cart hits zero items -->
<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">
@@ -64,10 +78,8 @@
else
{
<div class="row g-5">
<!-- Left Side Forms Layout Matrix -->
<div class="col-lg-7">
<!-- SECTION A: SHOPPING CART MANIFEST LIST -->
<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>
@@ -79,9 +91,17 @@
<div class="checkout-items-stack">
@foreach (var item in ShoppingCart.Items)
{
<div class="checkout-item-row py-3 d-flex align-items-center justify-content-between">
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">
<h6 class="item-product-name fw-bold mb-1 text-dark">@item.Product!.Name</h6>
<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>
@@ -101,13 +121,10 @@
</div>
</section>
<!-- SECTION B: FULFILLMENT SELECTABLE METHODS -->
<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">
<!-- Collect Card Structure -->
<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">
@@ -119,7 +136,6 @@
<span class="card-price-tag font-monospace ms-auto text-success fw-bold">FREE</span>
</div>
<!-- Courier Card Structure -->
<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">
@@ -133,7 +149,6 @@
</div>
</section>
<!-- SECTION C: ADDITIONAL COURIER DELIVERY NOTES -->
<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>
@@ -157,7 +172,6 @@
</div>
</section>
<!-- SECTION D: BILLING ADDRESS EQUALITY SETTINGS -->
<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">
@@ -169,7 +183,6 @@
</section>
</div>
<!-- Right Side Sidebar Sticky Cost Summary -->
<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>
@@ -180,7 +193,6 @@
<span class="font-monospace text-dark fw-medium">R @ShoppingCart.TotalAmount.ToString("F2")</span>
</div>
<!-- VAT DISPLAY LOGIC SWITCH -->
<div class="ledger-row d-flex justify-content-between align-items-center">
@if (ShoppingCart.TotalVat > 0)
{
@@ -224,7 +236,7 @@
</div>
<button class="btn btn-premium-action w-100 py-3.5 d-flex align-items-center justify-content-center gap-2"
disabled="@IsProcessing"
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">
@@ -235,7 +247,6 @@
</div>
</div>
<!-- Automatic PayFast Form Handover Context -->
@if (IsProcessing == true && CheckoutPayload?.Count > 0)
{
<form id="payfastForm" action="@PayfastOptions.Value.CheckoutUrl" method="POST">
@@ -5,6 +5,14 @@ using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Globalization;
using LiteCharms;
using Microsoft.AspNetCore.Components.Authorization;
namespace MidrandBookshop.Components.Pages;
@@ -32,6 +40,13 @@ public partial class Checkout()
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();
@@ -41,60 +56,91 @@ public partial class Checkout()
CartService.OnCartChanged += CartService_OnCartChanged;
if (CartService.ShoppingCart.Items.Count == 0)
{
await CartService.LoadCartFromStorageAsync();
}
await RefreshStockValidationAsync();
}
private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged);
private async void CartService_OnCartChanged()
{
await RefreshStockValidationAsync();
await InvokeAsync(StateHasChanged);
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged();
private async Task RefreshStockValidationAsync()
{
AvailableStockMap.Clear();
foreach (var item in ShoppingCart.Items)
{
if (item.Price is not null)
{
// Mapped fallback default (set to 0 for specific keys to test stock warnings instantly)
// In production: pull from your inventory system:
// var stockCheck = await BooksService.GetStockLevelAsync(item.Price.Id);
int liveStockAvailable = 1;
AvailableStockMap[item.Price.Id] = liveStockAvailable;
}
}
}
private async Task ChangeQuantity(CartItem item, int delta)
{
var peekQuantity = item.Quantity + delta;
if (peekQuantity < 1) return;
CartService.UpdateQuantity(item.Price!.Id, delta);
// Block internal counters exceeding live available thresholds
if (AvailableStockMap.TryGetValue(item.Price!.Id, out var maxAvailable) && peekQuantity > maxAvailable)
{
ToastService.ShowWarning($"Cannot exceed remaining stock limit ({maxAvailable} available).", "Stock Limit");
return;
}
CartService.UpdateQuantity(item.Price!.Id, delta);
await CartService.SaveCartToStorageAsync();
}
private async Task RemoveFromCart(CartItem item)
{
CartService.RemoveOneItem(item.Price!.Id);
await CartService.SaveCartToStorageAsync();
}
private async Task PayNow(MouseEventArgs args)
{
// Fail-safe protection boundary check
if (HasStockExceptions)
{
ToastService.ShowError("Your order cannot contain items that are out of stock.", "Inventory Issue");
return;
}
if (IsProcessing)
{
ToastService.ShowWarning("Please wait, completing your payment", "Busy...");
return;
}
try
{
IsProcessing = true;
StateHasChanged();
Result<long> orderResult;
var customerId = (long)ShoppingCart.CustomerId!;
if (!ShoppingCart.OrderId.HasValue)
{
CreateOrder request = new(ShoppingCart.TotalAmount, null);
orderResult = await OrderService.CreateOrderAsync(customerId, request, CancellationToken);
ShoppingCart.OrderId = orderResult.Value;
}
List<CreateOrderItem> orderItems = [];
var orderId = (long)ShoppingCart.OrderId;
await OrderService.ClearOrderItemsAsync(orderId, CancellationToken);
@@ -102,7 +148,6 @@ public partial class Checkout()
foreach (var item in ShoppingCart.Items)
{
var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id, CancellationToken);
if (bookRequest.IsSuccess)
{
var orderItem = new CreateOrderItem(bookRequest.Value.Id, item.Price.Id, item.Quantity);
@@ -114,20 +159,17 @@ public partial class Checkout()
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken);
long paymentId = 0;
if (paymentGen.IsSuccess) paymentId = paymentGen.Value;
if (paymentGen.IsFailed)
{
var paymentFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
if (paymentFetch.IsFailed)
{
ToastService.ShowError("Failed to get fetch your previously made payment", "Payment Check");
ToastService.ShowError("Failed to fetch your previously made payment", "Payment Check");
IsProcessing = false;
return;
}
paymentId = paymentFetch.Value.Id;
}
@@ -162,15 +204,13 @@ public partial class Checkout()
CheckoutPayload.Add("signature", signature);
StateHasChanged();
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('payfastForm').submit();");
}
catch (Exception ex)
{
ToastService.ShowError($"Failed to perform checkout: {ex.Message}", "Checkout");
IsProcessing = false;
StateHasChanged();
}
}
}
}
@@ -0,0 +1,97 @@
@page "/contact"
@rendermode InteractiveServer
<div class="contact-page-container py-5">
<header class="contact-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Inquiries & Submissions</span>
<h1 class="contact-main-title fw-bold">Get In Touch</h1>
<p class="text-muted small font-monospace mt-2">
Lite Charms (Pty) Ltd t/a Midrand Books &bull; Reader & Author Support Desk
</p>
</header>
<div class="row g-5">
<div class="col-lg-5">
<div class="contact-metadata-card p-4 border border-light bg-white mb-4">
<h3 class="metadata-title mb-4">The Bookshop Desk</h3>
<div class="meta-item mb-3">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Main Office</span>
<span class="meta-value font-monospace small">Corporate Woods, Midrand, Gauteng, 1685, South Africa</span>
</div>
<div class="meta-item mb-3">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Email Correspondence</span>
<span class="meta-value font-monospace small"><a href="mailto:desk@midrandbooks.co.za" class="contact-link">desk@midrandbooks.co.za</a></span>
</div>
<div class="meta-item mb-4">
<span class="meta-label text-uppercase font-monospace text-muted d-block small">Authors & Publishers</span>
<span class="meta-value font-monospace small text-muted">Are you an independent author or an established brand looking to publish with us? Specify your requirements in the form grid.</span>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="contact-form-panel p-4 border rounded bg-white">
<h3 class="metadata-title mb-4">Send us a Note</h3>
@if (HasSubmitted)
{
<div class="submission-success-banner py-4 text-center">
<svg class="success-vector-checkmark mb-3" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<h4 class="font-monospace text-uppercase tracking-wider fs-6 fw-bold text-dark">Message Sent</h4>
<p class="text-muted small mb-0 px-3">
Thank you for reaching out. A bookstore representative or publishing helper will respond to you via email shortly.
</p>
</div>
}
else
{
<form @onSubmit="HandleTransmissionSubmit">
<div class="mb-3">
<label class="form-label font-monospace text-uppercase text-muted extra-small">Your Name</label>
<input type="text" class="premium-plaintext-field w-100" required @bind="FormName" placeholder="e.g., Alexander Stone" />
</div>
<div class="mb-3">
<label class="form-label font-monospace text-uppercase text-muted extra-small">Email Address</label>
<input type="email" class="premium-plaintext-field w-100" required @bind="FormEmail" placeholder="e.g., alex@domain.co.za" />
</div>
<div class="mb-4">
<label class="form-label font-monospace text-uppercase text-muted extra-small">How can we help you?</label>
<textarea class="premium-plaintext-field w-100" rows="5" required @bind="FormMessage" placeholder="Tell us about your book submission, order inquiries, or general publishing ideas..."></textarea>
</div>
<button type="submit" class="btn btn-premium-action w-100 py-3 d-flex align-items-center justify-content-center gap-2">
<span>Send Message</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
}
</div>
</div>
</div>
</div>
@code {
private string FormName { get; set; } = string.Empty;
private string FormEmail { get; set; } = string.Empty;
private string FormMessage { get; set; } = string.Empty;
private bool HasSubmitted { get; set; } = false;
private void HandleTransmissionSubmit()
{
if (!string.IsNullOrWhiteSpace(FormName) && !string.IsNullOrWhiteSpace(FormEmail))
{
HasSubmitted = true;
StateHasChanged();
}
}
}
@@ -0,0 +1,126 @@
/* ==========================================================================
Midrand Books — Contact View Layout Architecture Styles
========================================================================== */
.contact-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.contact-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
.extra-small {
font-size: 0.72rem !important;
}
.metadata-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.2rem;
font-weight: 500;
color: #111111;
}
/* --- Metadata Info Panels --- */
.contact-metadata-card {
border-radius: 8px;
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.meta-label {
letter-spacing: 0.08em;
margin-bottom: 0.25rem;
}
.meta-value {
color: #333333;
}
.contact-link {
color: #111111;
text-decoration: underline;
text-underline-offset: 3px;
transition: opacity 0.2s ease;
}
.contact-link:hover {
opacity: 0.7;
}
/* --- Form Fields --- */
.contact-form-panel {
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.premium-plaintext-field {
border: 1px solid rgba(0, 0, 0, 0.15);
background-color: #FAFAFA;
outline: none;
border-radius: 6px;
padding: 0.75rem 0.95rem;
font-size: 0.9rem;
color: #111111;
transition: all 0.2s ease;
}
.premium-plaintext-field:focus {
border-color: #111111;
box-shadow: 0 0 0 1px #111111;
background-color: #FFFFFF;
}
/* --- Premium Action Button --- */
.btn-premium-action {
background-color: #111111;
color: #FFFFFF;
border: 1px solid #111111;
border-radius: 6px;
font-family: var(--bs-font-monospace);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: all 0.2s ease;
}
.btn-premium-action:hover {
background-color: transparent;
color: #111111;
}
/* --- Submission State Banner --- */
.submission-success-banner {
background-color: #FDFBFB;
border: 1px dashed rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.success-vector-checkmark {
color: #111111;
animation: bounceMarker 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounceMarker {
from {
transform: scale(0.4);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@@ -0,0 +1,83 @@
@page "/privacy"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="legal-page-container py-5">
<header class="legal-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Regulatory Governance</span>
<h1 class="legal-main-title fw-bold">Privacy & Data Protection Policy</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is a trading name for <strong>Lite Charms (Pty) Ltd</strong>, a company duly incorporated and registered in accordance with the Companies Act of South Africa (Registration No: [2020/269438/07]).
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="legal-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("section-processing")' class="index-btn text-start">1. Lawful Processing</button>
<button @onclick='() => ScrollToSection("section-collection")' class="index-btn text-start">2. Information We Collect</button>
<button @onclick='() => ScrollToSection("section-identity")' class="index-btn text-start">3. Identity Nodes</button>
<button @onclick='() => ScrollToSection("section-payment")' class="index-btn text-start">4. PayFast Gateway</button>
<button @onclick='() => ScrollToSection("section-rights")' class="index-btn text-start">5. Data Subject Rights</button>
</div>
</div>
<div class="col-lg-9">
<article class="legal-article-body">
<section id="section-processing" class="legal-section mb-5">
<h3 class="section-title">1. Commitment to POPIA Compliance</h3>
<p>
Lite Charms (Pty) Ltd trading as Midrand Books ("we", "us", "our") is dedicated to protecting the privacy and personal data of our customers in strict alignment with the <strong>Protection of Personal Information Act, No. 4 of 2013 (POPIA)</strong> of South Africa. We act as the "Responsible Party" for all data collected across our web architecture.
</p>
</section>
<section id="section-collection" class="legal-section mb-5">
<h3 class="section-title">2. Collection of Personal Information</h3>
<p>
We collect personal information solely to facilitate the cataloging, transaction, and dispatch of archival material. This information includes, but is not limited to:
</p>
<ul>
<li>Identity Details: Name, email address, and verification markers via federated claim contexts.</li>
<li>Logistics Metrics: Physical shipping addresses and localized postal codes for courier assignment within South African borders.</li>
<li>Digital Footprints: IP addresses, access telemetry, and structural tracking metrics.</li>
</ul>
</section>
<section id="section-identity" class="legal-section mb-5">
<h3 class="section-title">3. Decentralized Claims & Identity Security</h3>
<p>
For enhanced systemic protection, your core security credentials, access authentication methods, and identity validation vectors are managed externally through our secure cross-tenant single-sign-on architecture (<strong>STS Security Cluster</strong>). Lite Charms (Pty) Ltd does not store or process raw account passwords locally on its primary catalog databases.
</p>
</section>
<section id="section-payment" class="legal-section mb-5">
<h3 class="section-title">4. Secure Financial Handover Disclosures</h3>
<p>
All credit card, Instant EFT, and electronic payments are handled through a direct, secure integration handshake with the <strong>PayFast (Pty) Ltd</strong> merchant payment system. Midrand Books never captures, indexes, or retains sensitive primary account numbers (PAN), CVV digits, or banking pins. Payment states are transmitted securely using cryptographically signed verification payloads.
</p>
</section>
<section id="section-rights" class="legal-section mb-5">
<h3 class="section-title">5. Data Subject Rights & Information Officer</h3>
<p>
In accordance with POPIA parameters, you reserve explicit rights to request access to, correction of, or total destruction of your personal information stored inside our archives. For any data protection inquiries, or to contact our designated Information Officer, please direct formal correspondence to <code>privacy@midrandbooks.co.za</code>.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,91 @@
/* ==========================================================================
Midrand Books — Fine Press Legal Styles Document
========================================================================== */
.legal-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.legal-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Stack --- */
.legal-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Content Typography Alignment --- */
.legal-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem; /* Adds padding baseline context when aligned via smooth-scroll */
}
.legal-section p {
margin-bottom: 1.2rem;
}
.legal-section ul {
padding-left: 1.25rem;
margin-bottom: 1.5rem;
}
.legal-section li {
margin-bottom: 0.5rem;
}
code {
background-color: #F5F5F4;
color: #111111;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.88rem;
}
@@ -54,7 +54,19 @@
{
<span class="badge badge-physical">Physical Book</span>
}
<span class="badge badge-ebook">In Stock</span>
@if (StockCount <= 0)
{
<span class="badge bg-danger text-white border-0">Out of Stock</span>
}
else if (StockCount <= 3)
{
<span class="badge bg-warning text-dark border-0">Only @StockCount Left</span>
}
else
{
<span class="badge badge-ebook">In Stock (@StockCount available)</span>
}
</div>
</div>
@@ -89,14 +101,24 @@
<div class="product-price">R @LivePrice.ToString("N2")</div>
<div class="purchase-actions">
<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 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>
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
Add to Cart
</button>
@if (StockCount > 0)
{
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
Add to Cart
</button>
}
else
{
<button class="btn-add-to-cart bg-secondary text-white-50 cursor-not-allowed" disabled>
Sold Out
</button>
}
</div>
<hr class="divider" />
@@ -5,6 +5,7 @@ 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;
@@ -30,6 +31,9 @@ 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()
@@ -48,6 +52,9 @@ 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;
@@ -90,47 +97,52 @@ public partial class ProductView : ComponentBase
protected async void IncreaseQty()
{
if (CurrentPrice is not null)
// Enforce maximum stock bounds limits natively during counter picking
if (CurrentPrice is not null && Quantity < StockCount)
{
CartService.UpdateQuantity(CurrentPrice!.Id, 1);
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
await CartService.SaveCartToStorageAsync();
StateHasChanged();
}
}
protected async void DecreaseQty()
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) return;
if (CurrentProduct == null || StockCount <= 0) 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();
}
}
@@ -0,0 +1,89 @@
@page "/terms"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime
<div class="legal-page-container py-5">
<header class="legal-header mb-5">
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Commercial Agreement</span>
<h1 class="legal-main-title fw-bold">Terms of Sale & Service</h1>
<p class="text-muted small font-monospace mt-2">
Midrand Books is a trading name for <strong>Lite Charms (Pty) Ltd</strong>, a private company incorporated and governed under the regulations of the Companies Act of the Republic of South Africa.
</p>
</header>
<div class="row g-5">
<div class="col-lg-3 d-none d-lg-block">
<div class="legal-nav-index font-monospace text-uppercase small gap-3 d-flex flex-column">
<button @onclick='() => ScrollToSection("section-formation")' class="index-btn text-start">1. Contract Formation</button>
<button @onclick='() => ScrollToSection("section-pricing")' class="index-btn text-start">2. Currency & Pricing</button>
<button @onclick='() => ScrollToSection("section-logistics")' class="index-btn text-start">3. Delivery & Risk</button>
<button @onclick='() => ScrollToSection("section-cooling")' class="index-btn text-start">4. CPA Cooling-Off</button>
<button @onclick='() => ScrollToSection("section-liability")' class="index-btn text-start">5. Liability Shield</button>
<button @onclick='() => ScrollToSection("section-jurisdiction")' class="index-btn text-start">6. Jurisdiction</button>
</div>
</div>
<div class="col-lg-9">
<article class="legal-article-body">
<section id="section-formation" class="legal-section mb-5">
<h3 class="section-title">1. Electronic Contract Formation</h3>
<p>
By placing an order on this digital repository layout, you accept these Terms and Conditions in full. In accordance with Section 11 of the <strong>Electronic Communications and Transactions Act, No. 25 of 2002 (ECTA)</strong>, these terms are legally binding and enforceable from the second you submit an item request. An order request is only confirmed as a final contract of sale once funds are cleared and the physical inventory has been packed for transit by Lite Charms (Pty) Ltd.
</p>
</section>
<section id="section-pricing" class="legal-section mb-5">
<h3 class="section-title">2. Currency, Value Added Tax, & Inventory Realism</h3>
<p>
All book pricing listed on our site is formatted exclusively in <strong>South African Rand (ZAR)</strong>. While we endeavor to ensure immaculate catalog records, typographical pricing errors may occasionally manifest due to currency valuation updates. In such rare anomalies, we explicitly retain the legal prerogative to cancel any affected items prior to dispatch and refund your transaction balance.
</p>
</section>
<section id="section-logistics" class="legal-section mb-5">
<h3 class="section-title">3. Logistics Dispatch & Risk Pass-Through</h3>
<p>
Delivery fulfillment is handled via trusted independent third-party domestic courier providers. Ownership and operational risk concerning physical destruction, item degradation, or total package loss transit pass directly to the consumer at the immediate instant the shipment container leaves our local loading bay docks.
</p>
</section>
<section id="section-cooling" class="legal-section mb-5">
<h3 class="section-title">4. Statutory Returns & CPA Cooling-Off Windows</h3>
<p>
Pursuant to Section 44 of ECTA and the directives of the <strong>Consumer Protection Act, No. 68 of 2008 (CPA)</strong>, you maintain a statutory cooling-off window of <strong>seven (7) days</strong> following delivery receipt to cancel your transaction without justification, provided the items remain perfectly pristine, unread, and factory-sealed. Return logistics freight costs under a simple cooling-off exercise remain entirely the accountability of the purchaser.
</p>
</section>
<section id="section-liability" class="legal-section mb-5 protective-shield-callout">
<h3 class="section-title text-danger-serif">5. Complete Limitation of Liability Shield</h3>
<p class="fw-bold">
CRITICAL LEGAL CLAUSE — PLEASE READ CAREFULLY:
</p>
<p>
To the maximum limit permitted by Section 61 of the Consumer Protection Act, Lite Charms (Pty) Ltd, its underlying software system developers, and structural directors maintain zero liability for any direct, collateral, accidental, or punitive damages resulting from server connectivity breaks, digital database interruptions, payment handoff errors, or systemic down-times. We offer our catalog system entirely on an "As-Is" and "As-Available" functional framework.
</p>
</section>
<section id="section-jurisdiction" class="legal-section mb-5">
<h3 class="section-title">6. Governing Law & Jurisdiction</h3>
<p>
This commercial agreement, catalog usage framework, and transaction architecture are subject strictly to the laws and legal structures of the <strong>Republic of South Africa</strong>. Any disputes arising directly out of these digital terms shall be filed and litigated exclusively under the jurisdiction of the High Court of South Africa.
</p>
</section>
</article>
</div>
</div>
</div>
@code {
private async Task ScrollToSection(string elementId)
{
await JSRuntime.InvokeVoidAsync("eval", $@"
var el = document.getElementById('{elementId}');
if (el) {{
el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
");
}
}
@@ -0,0 +1,86 @@
/* ==========================================================================
Midrand Books — Fine Press Terms Layout Styles
========================================================================== */
.legal-page-container {
max-width: 1140px;
margin: 0 auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.legal-main-title {
font-size: 2.5rem;
letter-spacing: -0.03em;
color: #111111;
font-family: Georgia, 'Times New Roman', serif;
}
.tracking-wider {
letter-spacing: 0.12em;
}
/* --- Index Navigation Stack --- */
.legal-nav-index {
position: sticky;
top: 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.08);
padding-left: 1.25rem;
}
.index-btn {
color: #666666;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
cursor: pointer;
text-decoration: none;
transition: color 0.2s ease, padding-left 0.2s ease;
}
.index-btn:hover {
color: #111111;
padding-left: 4px;
}
.index-btn:focus {
outline: none;
color: #111111;
font-weight: 600;
}
/* --- Content Typography Alignment --- */
.legal-article-body {
line-height: 1.8;
color: #333333;
font-size: 1rem;
}
.section-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
color: #111111;
margin-bottom: 1.25rem;
font-weight: 500;
scroll-margin-top: 2rem; /* Clear alignment offset */
}
.text-danger-serif {
color: #A34843 !important;
}
.legal-section p {
margin-bottom: 1.2rem;
}
/* Stark Premium Callout Box for Limiting Liability Under South African Rules */
.protective-shield-callout {
background-color: #FDFBFB;
border: 1px solid rgba(163, 72, 67, 0.15);
padding: 2rem;
border-radius: 8px;
}