Stable run with tailwind
This commit is contained in:
@@ -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 aren’t 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;
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
@page "/account"
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject NavigationManager Navigation
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="account-page-container py-5">
|
||||
<header class="account-header mb-5">
|
||||
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Customer Dashboard</span>
|
||||
<h1 class="account-main-title fw-bold">My Account</h1>
|
||||
</header>
|
||||
|
||||
<div class="row g-5">
|
||||
<div class="col-lg-3">
|
||||
<div class="nav flex-column account-nav-stack gap-1" role="tablist">
|
||||
<button class="nav-link active text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#orders" role="tab">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"></path><line x1="3" y1="6" x2="21" y2="6"></line><path d="M16 10a4 4 0 0 1-8 0"></path></svg>
|
||||
<span>Order History</span>
|
||||
</button>
|
||||
|
||||
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#shipping" role="tab">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
||||
<span>Shipping Address</span>
|
||||
</button>
|
||||
<button class="nav-link text-start d-flex align-items-center gap-2" data-bs-toggle="pill" data-bs-target="#profile" role="tab">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
<span>Profile Settings</span>
|
||||
</button>
|
||||
<hr class="my-3 opacity-10" />
|
||||
<button class="nav-link nav-logout text-danger text-start d-flex align-items-center gap-2" @onclick="TriggerLogout">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>Logout Account</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="tab-content account-panels-deck">
|
||||
|
||||
<div class="tab-pane fade show active" id="orders" role="tabpanel">
|
||||
<div class="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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,274 +0,0 @@
|
||||
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;
|
||||
|
||||
public partial class Account : ComponentBase
|
||||
{
|
||||
[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 = "";
|
||||
private string newStreetAddress = "";
|
||||
private string newCity = "";
|
||||
private string newPostalCode = "";
|
||||
private bool isBilling, isShipping;
|
||||
|
||||
private List<OrderItem> orderHistory = [];
|
||||
|
||||
private List<AddressItem> savedAddresses = new()
|
||||
{
|
||||
new AddressItem { Id = 1, Name = "Home Address", Street = "12 Main Road", City = "Midrand", PostalCode = "1685", IsBilling = true, IsShipping = true, IsPrimary = true },
|
||||
new AddressItem { Id = 2, Name = "Midrand Warehouse", Street = "Corner of Church & Third Roads", City = "Midrand", PostalCode = "1685", IsBilling = false, IsShipping = false, IsPrimary = false }
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
User = authState?.User;
|
||||
|
||||
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
|
||||
{
|
||||
var nextId = savedAddresses.Any() ? savedAddresses.Max(a => a.Id) + 1 : 1;
|
||||
savedAddresses.Add(new AddressItem
|
||||
{
|
||||
Id = nextId,
|
||||
Name = newAddressName,
|
||||
Street = newStreetAddress,
|
||||
City = newCity,
|
||||
PostalCode = newPostalCode,
|
||||
IsBilling = isBilling,
|
||||
IsShipping = isShipping,
|
||||
IsPrimary = !savedAddresses.Any()
|
||||
});
|
||||
}
|
||||
|
||||
CancelAddressActions();
|
||||
}
|
||||
|
||||
private void CancelAddressActions()
|
||||
{
|
||||
showAddForm = false;
|
||||
editingAddress = null;
|
||||
ResetFormFields();
|
||||
}
|
||||
|
||||
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 SetPrimary(AddressItem target, ChangeEventArgs e)
|
||||
{
|
||||
var isChecked = (bool)(e.Value ?? false);
|
||||
if (isChecked)
|
||||
{
|
||||
foreach (var addr in savedAddresses) addr.IsPrimary = (addr.Id == target.Id);
|
||||
}
|
||||
else target.IsPrimary = false;
|
||||
}
|
||||
|
||||
private void TriggerLogout()
|
||||
{
|
||||
Navigation.NavigateTo("/logout", forceLoad: true);
|
||||
}
|
||||
|
||||
public class AddressItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Street { get; set; } = "";
|
||||
public string City { get; set; } = "";
|
||||
public string PostalCode { get; set; } = "";
|
||||
public bool IsBilling { get; set; }
|
||||
public bool IsShipping { get; set; }
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
public class OrderItem
|
||||
{
|
||||
public string OrderId { get; set; } = "";
|
||||
public 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; }
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/* ==========================================================================
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.account-nav-stack .nav-link.active {
|
||||
background-color: #111111 !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.account-nav-stack .nav-link:hover:not(.active) {
|
||||
background-color: rgba(0, 0, 0, 0.04) !important;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.nav-logout:hover {
|
||||
background-color: rgba(220, 53, 69, 0.08) !important;
|
||||
}
|
||||
|
||||
/* --- Balanced Status Mapping Badges --- */
|
||||
.status-badge-base {
|
||||
font-family: var(--bs-font-monospace);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 0.65rem;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 1. Core Order Lifecycle Badge Styles */
|
||||
.order-completed {
|
||||
background-color: #111111 !important;
|
||||
color: #FFFFFF !important;
|
||||
border: 1px solid #111111;
|
||||
}
|
||||
|
||||
.order-processing {
|
||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||
color: #111111 !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.order-hold {
|
||||
background-color: rgba(108, 117, 125, 0.05) !important;
|
||||
color: #495057 !important;
|
||||
border: 1px solid rgba(108, 117, 125, 0.2);
|
||||
}
|
||||
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.book-thumbnail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
@namespace MidrandBooks.Components.Pages
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MidrandBooks.Models
|
||||
|
||||
<div id="razor-author-portal" class="max-w-7xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
<div class="border border-slate-200/60 bg-white rounded-3xl shadow-xl overflow-hidden">
|
||||
|
||||
<!-- Tab Headers -->
|
||||
<div class="flex border-b border-slate-100 bg-slate-50/50">
|
||||
<button
|
||||
@onclick='() => SetActiveTab("publish")'
|
||||
class="px-6 py-4 font-serif text-sm font-bold border-b-2 transition-all cursor-pointer @(ActiveTab == "publish" ? "border-violet-600 text-violet-700 bg-white" : "border-transparent text-slate-500 hover:text-slate-800")">
|
||||
Publish New Manuscript
|
||||
</button>
|
||||
<button
|
||||
@onclick='() => SetActiveTab("catalog")'
|
||||
class="px-6 py-4 font-serif text-sm font-bold border-b-2 transition-all cursor-pointer @(ActiveTab == "catalog" ? "border-violet-600 text-violet-700 bg-white" : "border-transparent text-slate-500 hover:text-slate-800")">
|
||||
My Author Catalog & Moderation Panel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
@if (ActiveTab == "publish")
|
||||
{
|
||||
<!-- Publish Tab Content -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- Left Form Section (8 cols) -->
|
||||
<div class="lg:col-span-7 space-y-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-serif text-lg font-bold text-slate-800">Establish Direct SA Distribution</h3>
|
||||
<p class="text-[11px] text-slate-500 leading-relaxed">
|
||||
Upload your manuscripts directly. Your files are converted in the background into copy-protected, DRM-sandboxed reflowable epub formats ready for legal distribution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Title Input -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Manuscript Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Whispers of the Highveld"
|
||||
@bind="NewTitle"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Selector with Custom Creation Options -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Genre Category</label>
|
||||
<button
|
||||
type="button"
|
||||
@onclick="ToggleCustomCategory"
|
||||
class="text-[10px] text-violet-600 hover:text-violet-500 font-bold font-mono flex items-center gap-1 cursor-pointer">
|
||||
@(ShowCustomCategoryInput ? "Existing" : "New Genre")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (ShowCustomCategoryInput)
|
||||
{
|
||||
<div class="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Afrofuturism"
|
||||
@bind="CustomCategoryName"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@onclick="SaveCustomCategory"
|
||||
class="px-3 rounded-xl bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold cursor-pointer transition-all">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select
|
||||
@bind="SelectedCategory"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all">
|
||||
@foreach (var cat in Categories)
|
||||
{
|
||||
<option value="@cat">@cat</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">South African Pricing (ZAR)</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-3 flex items-center text-slate-500 font-mono text-xs font-bold">R</span>
|
||||
<input
|
||||
type="number"
|
||||
@bind="NewPrice"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 pl-7 pr-3 py-2.5 text-xs text-slate-800 font-mono outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag and Drop Cover Art Image Upload Area -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Custom Cover Artwork Image (Optional)</label>
|
||||
@if (!string.IsNullOrEmpty(CoverImagePreview))
|
||||
{
|
||||
<button
|
||||
type="button"
|
||||
@onclick="ClearCoverImage"
|
||||
class="text-[9px] text-red-500 hover:text-red-400 font-mono cursor-pointer">
|
||||
Clear Image
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border border-dashed rounded-xl p-3 text-center transition-all cursor-pointer flex items-center justify-center gap-3 @(string.IsNullOrEmpty(CoverImagePreview) ? "border-slate-200 bg-slate-50/50 hover:bg-slate-50 text-slate-500" : "border-violet-300 bg-violet-50/10")"
|
||||
@onclick="TriggerCoverUpload">
|
||||
|
||||
@if (!string.IsNullOrEmpty(CoverImagePreview))
|
||||
{
|
||||
<div class="flex items-center gap-3 w-full text-left">
|
||||
<img src="@CoverImagePreview" alt="Cover preview" class="h-10 w-8 rounded object-cover shadow-sm flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block text-[11px] font-bold text-slate-700 truncate font-mono">@CoverImageName</span>
|
||||
<span class="block text-[9px] text-slate-400">Custom image set as primary book artwork</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center gap-2 text-slate-400 py-1">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span class="text-[11px] font-bold text-slate-600">Simulate Drag/Drop or click to upload Cover Art</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brief blurb description -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase">Brief Book Description (Blurb)</label>
|
||||
<textarea
|
||||
placeholder="Summarize your story for prospective buyers..."
|
||||
@bind="NewDescription"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-800 placeholder-slate-400 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@onclick="PublishManuscript"
|
||||
class="w-full py-4 rounded-xl bg-violet-600 hover:bg-violet-500 text-white font-bold text-xs shadow-md cursor-pointer transition-all flex items-center justify-center gap-2">
|
||||
Publish Manuscript for Moderation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right Info / Guidelines Column (5 cols) -->
|
||||
<div class="lg:col-span-5 bg-slate-50 rounded-2xl p-5 border border-slate-100 space-y-4">
|
||||
<h4 class="font-serif text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
⚖ Publishing Laws & Moderation Mandates
|
||||
</h4>
|
||||
|
||||
<div class="space-y-3.5 text-slate-600 text-[11px] leading-relaxed">
|
||||
<p>
|
||||
Midrand Books operates in direct conformity with the **South African National Library Services Act and Copyright protection provisions**. Every submission undergoes rigorous verification:
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">1</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Immediate Background Conversion</strong>
|
||||
Files are dispatched to an asynchronous server node to establish reflowable reading templates, embedded copy-protection, and sandboxed audio DRM loops.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">2</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Editorial Moderation Curation</strong>
|
||||
All books must meet formatting criteria and pass legal compliance checks prior to public listing.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">3</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Real-time Correction Logs</strong>
|
||||
If issues are found, the system records moderation logs and returns feedback so authors can easily submit updated artwork or texts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ActiveTab == "catalog")
|
||||
{
|
||||
<!-- Catalog Tab Content with simulator capabilities -->
|
||||
<div class="space-y-6">
|
||||
@if (MyBooks.Any())
|
||||
{
|
||||
@foreach (var book in MyBooks)
|
||||
{
|
||||
<div class="border border-slate-200 bg-slate-50/50 rounded-2xl p-5 flex flex-col md:flex-row gap-5 transition-all hover:bg-white hover:shadow-md relative overflow-hidden">
|
||||
|
||||
<!-- Jacket Mockup -->
|
||||
<div class="h-36 w-24 rounded-xl bg-slate-900 flex-shrink-0 shadow-md relative overflow-hidden border border-slate-200/80 mx-auto md:mx-0">
|
||||
@if (!string.IsNullOrEmpty(book.CoverUrl))
|
||||
{
|
||||
<img src="@book.CoverUrl" alt="@book.Title" class="h-full w-full object-cover" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="h-full w-full bg-gradient-to-br @book.CoverColor p-2.5 flex flex-col justify-between text-white">
|
||||
<span class="text-[7px] font-mono font-bold tracking-widest uppercase text-white/50">@book.Category</span>
|
||||
<span class="text-[10px] font-serif font-extrabold line-clamp-3 leading-tight">@book.Title</span>
|
||||
<span class="text-[7px] font-sans text-white/85 truncate block">By @book.Author</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details and simulation buttons -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between space-y-4">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="font-serif text-base font-bold text-slate-800 leading-snug">@book.Title</h4>
|
||||
<p class="text-[10px] text-slate-500 font-mono mt-0.5">Category: @book.Category • Price: R @book.Price.ToString("F2")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moderation Box -->
|
||||
<div class="p-3.5 rounded-xl border bg-white space-y-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[10px] font-mono font-bold uppercase text-slate-400">Moderation Status</span>
|
||||
|
||||
@if (book.Status == "published")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Live & Active
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "converting")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 animate-pulse">
|
||||
Converting PDF...
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "pending_review")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-blue-50 text-blue-700 border border-blue-100">
|
||||
Pending Human Review
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "rejected")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-50 text-rose-700 border border-rose-100">
|
||||
Rejected / Revision Required
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-red-100 text-red-800 border border-red-200">
|
||||
Compliance Take Down
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-slate-600 leading-relaxed">
|
||||
<strong>Moderator Feedback:</strong> @(book.ModerationFeedback ?? "No feedback provided yet.")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Live Status Simulations (Allows testing reviewer outcomes instantly) -->
|
||||
<div class="border-t border-slate-100 pt-3 flex flex-wrap items-center justify-between gap-3 bg-violet-50/10 p-3 rounded-xl border border-violet-500/10">
|
||||
<span class="text-[10px] font-bold font-mono text-violet-700">Simulate Curator Actions:</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "published", "Curation review passed successfully. Compliant with legal deposit requirements. Published to public index.")'
|
||||
class="px-2.5 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "rejected", "Low resolution cover art detected on jacket preview. Please re-upload high contrast art.")'
|
||||
class="px-2.5 py-1 rounded bg-amber-600 hover:bg-amber-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "taken_down", "Copyright violation match detected. Digital Millenium Copyright Act claim registered.")'
|
||||
class="px-2.5 py-1 rounded bg-rose-600 hover:bg-rose-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Take Down
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-12 text-center border border-dashed border-slate-200 bg-slate-50/50 rounded-3xl space-y-2">
|
||||
<svg class="mx-auto h-8 w-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<h4 class="font-serif text-sm font-bold text-slate-700">No self-published titles registered</h4>
|
||||
<p class="text-[10px] text-slate-500 font-sans max-w-sm mx-auto leading-relaxed">
|
||||
Create and publish your very first manuscript on the adjacent tab to load tracking, logs, and moderation tools!
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<string> Categories { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnAddCategory { get; set; }
|
||||
[Parameter] public EventCallback<BookModel> OnPublishBook { get; set; }
|
||||
|
||||
private string ActiveTab { get; set; } = "publish";
|
||||
private string NewTitle { get; set; } = string.Empty;
|
||||
private string SelectedCategory { get; set; } = "Fiction";
|
||||
private string CustomCategoryName { get; set; } = string.Empty;
|
||||
private bool ShowCustomCategoryInput { get; set; } = false;
|
||||
private double NewPrice { get; set; } = 120.00;
|
||||
private string NewDescription { get; set; } = string.Empty;
|
||||
|
||||
// Cover art simulation previews
|
||||
private string CoverImagePreview { get; set; } = string.Empty;
|
||||
private string CoverImageName { get; set; } = string.Empty;
|
||||
|
||||
private List<BookModel> MyBooks { get; set; } = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Categories.Any())
|
||||
{
|
||||
SelectedCategory = Categories.First();
|
||||
}
|
||||
|
||||
// Add pre-loaded simulated book for the user on portal initialization
|
||||
MyBooks.Add(new BookModel
|
||||
{
|
||||
Id = "pre-loaded-1",
|
||||
Title = "Highveld Sunset Stories",
|
||||
Author = "Author Khwezi",
|
||||
AuthorId = "khwezi-auth-1",
|
||||
Price = 145.00,
|
||||
Category = "African Literature",
|
||||
CoverColor = "from-rose-950 to-orange-900",
|
||||
Status = "pending_review",
|
||||
ModerationFeedback = "In queue for legal deposit assessment.",
|
||||
ModerationHistory = new List<ModerationHistoryEntry>
|
||||
{
|
||||
new ModerationHistoryEntry { Status = "converting", Date = "12/07/2026 10:14", Feedback = "Background compilation complete." }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SetActiveTab(string tab)
|
||||
{
|
||||
ActiveTab = tab;
|
||||
}
|
||||
|
||||
private void ToggleCustomCategory()
|
||||
{
|
||||
ShowCustomCategoryInput = !ShowCustomCategoryInput;
|
||||
}
|
||||
|
||||
private async Task SaveCustomCategory()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CustomCategoryName))
|
||||
{
|
||||
var cat = CustomCategoryName.Trim();
|
||||
await OnAddCategory.InvokeAsync(cat);
|
||||
SelectedCategory = cat;
|
||||
CustomCategoryName = string.Empty;
|
||||
ShowCustomCategoryInput = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerCoverUpload()
|
||||
{
|
||||
// Simulated cover file selection action
|
||||
CoverImagePreview = "https://images.unsplash.com/photo-1543002588-bfa74002ed7e?q=80&w=600&auto=format&fit=crop";
|
||||
CoverImageName = "sunset-artwork-preview-hires.jpg";
|
||||
}
|
||||
|
||||
private void ClearCoverImage()
|
||||
{
|
||||
CoverImagePreview = string.Empty;
|
||||
CoverImageName = string.Empty;
|
||||
}
|
||||
|
||||
private async Task PublishManuscript()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTitle)) return;
|
||||
|
||||
var book = new BookModel
|
||||
{
|
||||
Id = "self-pub-" + Guid.NewGuid().ToString().Substring(0, 8),
|
||||
Title = NewTitle,
|
||||
Author = "Author Khwezi",
|
||||
Price = NewPrice,
|
||||
Category = SelectedCategory,
|
||||
CoverUrl = CoverImagePreview,
|
||||
CoverColor = "from-indigo-900 to-purple-950",
|
||||
Description = NewDescription,
|
||||
Status = "converting",
|
||||
ModerationFeedback = "Background ingestion process active. Converting raw PDF structure to secure reflowable ePub nodes...",
|
||||
ModerationHistory = new List<ModerationHistoryEntry>
|
||||
{
|
||||
new ModerationHistoryEntry
|
||||
{
|
||||
Status = "converting",
|
||||
Date = DateTime.Now.ToString("dd/MM/yyyy HH:mm"),
|
||||
Feedback = "Manuscript uploaded. Worker assigned."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
MyBooks.Add(book);
|
||||
await OnPublishBook.InvokeAsync(book);
|
||||
|
||||
// Reset inputs
|
||||
NewTitle = string.Empty;
|
||||
NewDescription = string.Empty;
|
||||
CoverImagePreview = string.Empty;
|
||||
CoverImageName = string.Empty;
|
||||
ActiveTab = "catalog";
|
||||
}
|
||||
|
||||
private void SimulateAction(string bookId, string targetStatus, string feedback)
|
||||
{
|
||||
var book = MyBooks.FirstOrDefault(b => b.Id == bookId);
|
||||
if (book != null)
|
||||
{
|
||||
book.Status = targetStatus;
|
||||
book.ModerationFeedback = feedback;
|
||||
book.ModerationHistory.Insert(0, new ModerationHistoryEntry
|
||||
{
|
||||
Status = targetStatus,
|
||||
Date = DateTime.Now.ToString("dd/MM/yyyy HH:mm"),
|
||||
Feedback = feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
@page "/cart"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="container py-5" style="max-width: 1000px; font-family: system-ui, -apple-system, sans-serif;">
|
||||
|
||||
<div class="d-flex align-items-baseline justify-content-between mb-5 border-bottom pb-3">
|
||||
<h1 class="ff-serif mb-0" style="font-size: 2.25rem; font-family: 'Playfair Display', Georgia, serif; font-weight: 400; letter-spacing: -0.5px;">Your Cart</h1>
|
||||
<a href="/" class="text-dark text-decoration-none small fw-semibold tracking-widest text-uppercase" style="font-size: 0.75rem; letter-spacing: 1.5px;">Continue Shopping</a>
|
||||
</div>
|
||||
|
||||
@if (!ShoppingCart.Items.Any())
|
||||
{
|
||||
<div class="text-center py-5 my-4">
|
||||
<div class="mb-4 text-muted-50">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="text-secondary opacity-50"><circle cx="9" cy="21" r="1"></circle><circle cx="20" cy="21" r="1"></circle><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path></svg>
|
||||
</div>
|
||||
<h3 class="ff-serif mb-2" style="font-family: 'Playfair Display', Georgia, serif; font-weight: 400;">Your collection is currently vacant.</h3>
|
||||
<p class="text-muted small mb-4" style="letter-spacing: 0.2px;">Even with careful scrutiny, the requested shelf remains empty.</p>
|
||||
<a href="/" class="btn btn-dark rounded-pill px-4 py-2 text-uppercase fw-semibold tracking-widest" style="font-size: 0.75rem; letter-spacing: 1px; background-color: #1c1f22;">Browse Catalog</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-5">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
@foreach (var item in ShoppingCart.Items)
|
||||
{
|
||||
<div class="row align-items-center py-4 border-bottom position-relative">
|
||||
|
||||
<div class="col-12 col-md-7 d-flex align-items-center gap-4 mb-3 mb-md-0">
|
||||
<div class="bg-light d-flex align-items-center justify-content-center p-2 rounded-1 state-card-shadow"
|
||||
style="width: 70px; height: 95px; background-color: #f8f9fa; box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Product!.ImageUrl))
|
||||
{
|
||||
<img src="@item.Product!.ImageUrl" class="img-fluid" style="max-height: 80px; object-fit: contain; filter: drop-shadow(2px 4px 6px rgba(0,0,0,0.15));" alt="@item.Product.Name" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted fw-bold font-monospace" style="font-size: 0.6rem; letter-spacing: 1px;">[COVER]</span>
|
||||
}
|
||||
</div>
|
||||
<div style="max-width: 75%;">
|
||||
<h5 class="fw-semibold mb-1 text-dark" style="font-size: 0.95rem; line-height: 1.4; letter-spacing: -0.1px;">@item.Product!.Name</h5>
|
||||
<p class="text-muted small mb-0" style="font-size: 0.8rem; letter-spacing: 0.1px;">by @($"{item.Author!.Name} {item.Author!.LastName}")</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3 d-flex justify-content-md-center align-items-center">
|
||||
<div class="d-flex align-items-center border rounded-pill bg-white px-1 py-1" style="border-color: #e9ecef !important;">
|
||||
<button class="btn btn-sm border-0 bg-transparent text-muted px-2 py-0" style="font-size: 0.9rem;" @onclick="() => DecreaseQty(item, -1)">—</button>
|
||||
<span class="px-2 fw-semibold text-dark" style="font-size: 0.85rem; min-width: 20px; text-align: center;">@item.Quantity</span>
|
||||
<button class="btn btn-sm border-0 bg-transparent text-muted px-2 py-0" style="font-size: 0.9rem;" @onclick="() => IncreaseQty(item)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-2 text-end d-flex align-items-center justify-content-end gap-3">
|
||||
<span class="fw-semibold text-dark" style="font-size: 0.95rem; letter-spacing: -0.2px;">R @(item.Amount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
|
||||
<button class="btn btn-sm p-1 text-muted opacity-50 hover-opacity-100 border-0 bg-transparent" style="transition: opacity 0.2s;" @onclick="() => RemoveFromCart(item)" title="Remove item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="bg-white p-4 rounded-3 border-0 sticky-top" style="top: 2rem; box-shadow: 0 10px 30px rgba(0,0,0,0.04); border: 1px solid #f1f3f5 !important;">
|
||||
<h4 class="ff-serif mb-4" style="font-family: 'Playfair Display', Georgia, serif; font-weight: 400; font-size: 1.3rem;">Order Summary</h4>
|
||||
|
||||
<div class="d-flex justify-content-between mb-3" style="font-size: 0.9rem;">
|
||||
<span class="text-muted">Subtotal</span>
|
||||
<span class="fw-normal text-dark">R @(ShoppingCart.TotalAmount.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mb-4" style="font-size: 0.9rem;">
|
||||
<span class="text-muted">VAT (15%)</span>
|
||||
<span class="fw-normal text-dark">R @(ShoppingCart.TotalVat.ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
|
||||
</div>
|
||||
|
||||
<hr style="border-color: #f1f3f5;" />
|
||||
|
||||
<div class="d-flex justify-content-between align-items-baseline mb-4 mt-2">
|
||||
<span class="fw-semibold h6 mb-0 text-dark" style="font-size: 1rem;">Total</span>
|
||||
<span class="fw-bold text-dark" style="font-size: 1.4rem; letter-spacing: -0.5px;">R @((ShoppingCart.TotalAmount + ShoppingCart.TotalVat).ToString("N2", System.Globalization.CultureInfo.GetCultureInfo("js")))</span>
|
||||
</div>
|
||||
|
||||
<a href="/checkout" class="btn btn-dark w-100 rounded-pill py-3 text-uppercase fw-semibold tracking-widest border-0"
|
||||
style="font-size: 0.75rem; letter-spacing: 1.5px; background-color: #1c1f22; transition: background-color 0.2s;">
|
||||
Proceed to Checkout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
using LiteCharms.Features.MidrandBooks.Payments;
|
||||
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||
|
||||
namespace MidrandBookshop.Components.Pages;
|
||||
|
||||
public partial class CartReview(CartService cartService)
|
||||
{
|
||||
[Inject] public IToastService ToastService { get; set; } = default!;
|
||||
|
||||
protected Cart ShoppingCart => cartService?.ShoppingCart!;
|
||||
|
||||
protected async void IncreaseQty(CartItem item)
|
||||
{
|
||||
if (item is not null)
|
||||
{
|
||||
cartService.UpdateQuantity(item!.Price!.Id, 1);
|
||||
|
||||
await cartService.SaveCartToStorageAsync();
|
||||
}
|
||||
}
|
||||
protected async void DecreaseQty(CartItem item, int delta)
|
||||
{
|
||||
var peekQuantity = item.Quantity + delta;
|
||||
|
||||
if (peekQuantity < 1) return;
|
||||
|
||||
cartService.UpdateQuantity(item!.Price!.Id, delta);
|
||||
|
||||
await cartService.SaveCartToStorageAsync();
|
||||
}
|
||||
|
||||
private async void RemoveFromCart(CartItem item)
|
||||
{
|
||||
cartService.RemoveOneItem(item.Price!.Id);
|
||||
|
||||
await cartService.SaveCartToStorageAsync();
|
||||
|
||||
ToastService.ShowSuccess($"Removed {item.Product!.Name} from cart", "Cart Changed");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
body {
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
@page "/checkout"
|
||||
@inject NavigationManager Navigation
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="checkout-page-container py-5">
|
||||
|
||||
@if (IsProcessing)
|
||||
{
|
||||
<div class="processing-screen-overlay">
|
||||
<div class="processing-card-box">
|
||||
<div class="mb-1">
|
||||
<svg class="payment-vector-loader" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="loader-track" cx="12" cy="12" r="10" />
|
||||
<path class="loader-handshake-ring" d="M12 2a10 10 0 0 1 10 10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="fw-bold text-dark font-monospace text-uppercase tracking-wider mb-2" style="font-size: 1.1rem;">
|
||||
Securing Your Order
|
||||
</h4>
|
||||
<p class="text-muted small mb-0 px-2" style="line-height: 1.5; font-size: 0.85rem;">
|
||||
Please stand by. We are preparing your payment portal and transferring you securely to PayFast.
|
||||
</p>
|
||||
</div>
|
||||
<div class="secure-badge">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<span>Bank-Grade 256-Bit SSL Connection</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<header class="checkout-header mb-4">
|
||||
<span class="text-uppercase font-monospace text-muted tracking-wider small d-block mb-1">Secure Checkout</span>
|
||||
<h1 class="checkout-main-title fw-bold">Review Your Order</h1>
|
||||
</header>
|
||||
|
||||
@if (ShoppingCart.Items.Any() && HasStockExceptions)
|
||||
{
|
||||
<div class="alert alert-danger border-0 rounded-3 p-3 mb-4 d-flex align-items-center gap-3 animate-fade-in">
|
||||
<svg class="text-danger flex-shrink-0" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<div>
|
||||
<h6 class="fw-bold text-danger mb-0.5" style="font-size: 0.92rem;">Action Required: Inventory Shortage</h6>
|
||||
<p class="text-secondary small mb-0" style="font-size: 0.85rem; line-height: 1.4;">
|
||||
One or more items in your shopping cart are currently out of stock. Please remove or adjust these selections to proceed to the payment gateway.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShoppingCart.Items.Any() == false)
|
||||
{
|
||||
<div class="checkout-section-panel text-center py-5 my-4 d-flex flex-column align-items-center gap-3">
|
||||
<div class="text-muted opacity-50">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="21" r="1"></circle>
|
||||
<circle cx="20" cy="21" r="1"></circle>
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="fw-bold text-dark mb-1">Your cart is empty</h4>
|
||||
<p class="text-muted small mb-0">You cannot proceed to payment without selected titles.</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-premium-action px-4 py-2 mt-2 text-decoration-none" style="font-size: 0.85rem;">
|
||||
Browse Book Catalogue
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-5">
|
||||
<div class="col-lg-7">
|
||||
|
||||
<section class="checkout-section-panel mb-4">
|
||||
<div class="panel-header-row d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="panel-title fw-bold mb-0">Your Selection</h5>
|
||||
<span class="badge rounded-pill bg-light text-dark font-monospace px-2.5 py-1.5 border">
|
||||
@ShoppingCart.Items.Count Items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="checkout-items-stack">
|
||||
@foreach (var item in ShoppingCart.Items)
|
||||
{
|
||||
var isOutofStock = AvailableStockMap.TryGetValue(item.Price!.Id, out var availableCount) && availableCount <= 0;
|
||||
|
||||
<div class="checkout-item-row py-3 d-flex align-items-center justify-content-between @(isOutofStock ? "border-danger bg-light-danger-subtle" : "")">
|
||||
<div class="item-meta-details pe-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
||||
<h6 class="item-product-name fw-bold mb-0 text-dark">@item.Product!.Name</h6>
|
||||
@if (isOutofStock)
|
||||
{
|
||||
<span class="badge bg-danger text-white font-monospace text-uppercase" style="font-size: 0.65rem; padding: 0.2rem 0.4rem; letter-spacing: 0.02em;">Out Of Stock</span>
|
||||
}
|
||||
</div>
|
||||
<span class="item-author-label small text-muted font-monospace">
|
||||
By @($"{item.Author!.Name} {item.Author.LastName}")
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-interactive-actions d-flex align-items-center gap-3 flex-shrink-0">
|
||||
<div class="premium-quantity-stepper">
|
||||
<button class="step-btn" @onclick="() => ChangeQuantity(item, -1)" aria-label="Decrease quantity">−</button>
|
||||
<span class="step-value font-monospace">@item.Quantity</span>
|
||||
<button class="step-btn" @onclick="() => ChangeQuantity(item, 1)" aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
<button class="btn-clean-remove font-monospace text-uppercase" @onclick="() => RemoveFromCart(item)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="checkout-section-panel mb-4">
|
||||
<h5 class="panel-title fw-bold mb-4">Fulfillment Option</h5>
|
||||
|
||||
<div class="premium-radio-group d-flex flex-column gap-3">
|
||||
<div class="premium-selectable-card @(ShippingCost == 0 ? "active" : "")">
|
||||
<input class="form-check-input visual-hidden" type="radio" name="shipping" id="pickup"
|
||||
checked=@(ShippingCost == 0) @onclick="() => ShippingCost = 0">
|
||||
<div class="card-indicator-circle"></div>
|
||||
<label class="card-text-block m-0 context-clickable" for="pickup">
|
||||
<span class="card-label-title fw-bold d-block text-dark">Collect from Midrand Bookshop</span>
|
||||
<span class="card-label-desc text-muted small">Corner of Church & Third Roads. Ready within 2 hours.</span>
|
||||
</label>
|
||||
<span class="card-price-tag font-monospace ms-auto text-success fw-bold">FREE</span>
|
||||
</div>
|
||||
|
||||
<div class="premium-selectable-card @(ShippingCost == 60 ? "active" : "")">
|
||||
<input class="form-check-input visual-hidden" type="radio" name="shipping" id="delivery"
|
||||
checked=@(ShippingCost == 60) @onclick="() => ShippingCost = 60">
|
||||
<div class="card-indicator-circle"></div>
|
||||
<label class="card-text-block m-0 context-clickable" for="delivery">
|
||||
<span class="card-label-title fw-bold d-block text-dark">Door-to-Door Home Delivery</span>
|
||||
<span class="card-label-desc text-muted small">Dispatched via reliable overnight courier straight to your steps.</span>
|
||||
</label>
|
||||
<span class="card-price-tag font-monospace ms-auto text-dark fw-bold">R 60.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="checkout-section-panel mb-4">
|
||||
<div class="d-flex justify-content-between align-items-baseline mb-2">
|
||||
<h5 class="panel-title fw-bold mb-0">Delivery Instructions</h5>
|
||||
<span class="text-muted font-monospace extra-small opacity-75">Optional</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
Add any specific details for our dispatch team (e.g., gate access codes, complex navigation, or safe drop-off preferences).
|
||||
</p>
|
||||
<div class="premium-textarea-wrapper">
|
||||
<textarea class="form-control premium-plaintext-field"
|
||||
rows="3"
|
||||
placeholder="Type your notes or courier instructions here..."
|
||||
@bind="OrderNotes"
|
||||
maxlength="500">
|
||||
</textarea>
|
||||
<div class="text-end mt-1.5">
|
||||
<small class="font-monospace text-muted extra-small opacity-50">
|
||||
@(OrderNotes?.Length ?? 0) / 500 characters
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="checkout-section-panel">
|
||||
<h5 class="panel-title fw-bold mb-3">Billing Settings</h5>
|
||||
<div class="premium-checkbox-wrapper d-flex align-items-center gap-3 p-3 border rounded-3">
|
||||
<input class="form-check-input custom-box-tick m-0" type="checkbox" id="sameAsBilling" @bind="IsSameAddress">
|
||||
<label class="checkbox-text-label small text-dark fw-medium m-0 context-clickable w-100 py-1" for="sameAsBilling">
|
||||
My billing address is the same as my shipping address
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="sticky-summary-card p-4 border">
|
||||
<h5 class="fw-bold text-dark font-monospace text-uppercase tracking-wider mb-4 small-summary-heading">Summary Breakdown</h5>
|
||||
|
||||
<div class="price-summary-ledger d-flex flex-column gap-3">
|
||||
<div class="ledger-row d-flex justify-content-between">
|
||||
<span class="text-muted small">Items Subtotal</span>
|
||||
<span class="font-monospace text-dark fw-medium">R @ShoppingCart.TotalAmount.ToString("F2")</span>
|
||||
</div>
|
||||
|
||||
<div class="ledger-row d-flex justify-content-between align-items-center">
|
||||
@if (ShoppingCart.TotalVat > 0)
|
||||
{
|
||||
<span class="text-muted small">Value Added Tax (VAT 15%)</span>
|
||||
<span class="font-monospace text-dark fw-medium">R @ShoppingCart.TotalVat.ToString("F2")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">Value Added Tax (VAT)</span>
|
||||
<small class="text-danger fw-medium tracking-wide font-monospace" style="font-size: 0.78rem;">
|
||||
(Price is VAT inclusive)
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ledger-row d-flex justify-content-between">
|
||||
<span class="text-muted small">Fulfillment Courier Fee</span>
|
||||
<span class="font-monospace text-dark fw-medium">
|
||||
@if (ShippingCost == 0)
|
||||
{
|
||||
<span class="text-success fw-bold">FREE</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>R @($"{ShippingCost:F2}")</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="summary-divider my-2" />
|
||||
|
||||
<div class="ledger-row d-flex justify-content-between align-items-baseline mb-4">
|
||||
<span class="fw-bold text-dark">Total Due Amount</span>
|
||||
<div class="text-end">
|
||||
<span class="font-monospace text-dark fw-bold h3 mb-0 d-block tracking-tight">
|
||||
R @($"{ShoppingCart.TotalAmount + ShoppingCart.TotalVat + ShippingCost:F2}")
|
||||
</span>
|
||||
<small class="text-muted opacity-60 font-monospace extra-small">ZAR Currency</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-premium-action w-100 py-3.5 d-flex align-items-center justify-content-center gap-2"
|
||||
disabled="@(IsProcessing || HasStockExceptions)"
|
||||
@onclick="PayNow">
|
||||
<span>Pay Now</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsProcessing == true && CheckoutPayload?.Count > 0)
|
||||
{
|
||||
<form id="payfastForm" action="@PayfastOptions.Value.CheckoutUrl" method="POST">
|
||||
@foreach (var field in CheckoutPayload)
|
||||
{
|
||||
<input type="hidden" name="@field.Key" value="@field.Value" />
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,216 +0,0 @@
|
||||
using LiteCharms.Features.Api.Configuration;
|
||||
using LiteCharms.Features.Hasher;
|
||||
using LiteCharms.Features.MidrandBooks.AuthorBooks;
|
||||
using LiteCharms.Features.MidrandBooks.Orders;
|
||||
using LiteCharms.Features.MidrandBooks.Orders.Models;
|
||||
using LiteCharms.Features.MidrandBooks.Payments;
|
||||
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Globalization;
|
||||
using LiteCharms;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace MidrandBookshop.Components.Pages;
|
||||
|
||||
public partial class Checkout()
|
||||
{
|
||||
[Inject] public HashService HashService { get; set; } = default!;
|
||||
[Inject] public PaymentService PaymentService { get; set; } = default!;
|
||||
[Inject] public OrderService OrderService { get; set; } = default!;
|
||||
[Inject] public BooksService BooksService { get; set; } = default!;
|
||||
[Inject] public CartService CartService { get; set; } = default!;
|
||||
[Inject] public PayfastService PayfastService { get; set; } = default!;
|
||||
[Inject] public IOptions<PayfastSettings> PayfastOptions { get; set; } = default!;
|
||||
[Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!;
|
||||
[Inject] public IJSRuntime JSRuntime { get; set; } = default!;
|
||||
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
|
||||
[Inject] public IToastService ToastService { get; set; } = default!;
|
||||
|
||||
private Cart ShoppingCart => CartService.ShoppingCart;
|
||||
private ClaimsPrincipal? User { get; set; }
|
||||
private bool IsProcessing { get; set; }
|
||||
|
||||
private decimal ShippingCost = 0;
|
||||
private bool IsSameAddress = true;
|
||||
|
||||
public string? OrderNotes { get; set; }
|
||||
private Dictionary<string, string> CheckoutPayload { get; set; } = [];
|
||||
|
||||
// Tracks available quantities indexed by Price ID
|
||||
protected Dictionary<long, int> AvailableStockMap { get; set; } = [];
|
||||
|
||||
// Quick validation flag to evaluate checkout block state
|
||||
protected bool HasStockExceptions => ShoppingCart.Items.Any(item =>
|
||||
AvailableStockMap.TryGetValue(item.Price!.Id, out var count) && count <= 0);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
User = authState!.User;
|
||||
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
CartService.OnCartChanged += CartService_OnCartChanged;
|
||||
|
||||
if (CartService.ShoppingCart.Items.Count == 0)
|
||||
{
|
||||
await CartService.LoadCartFromStorageAsync();
|
||||
}
|
||||
|
||||
await RefreshStockValidationAsync();
|
||||
}
|
||||
|
||||
private async void CartService_OnCartChanged()
|
||||
{
|
||||
await RefreshStockValidationAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged();
|
||||
|
||||
private async Task RefreshStockValidationAsync()
|
||||
{
|
||||
AvailableStockMap.Clear();
|
||||
foreach (var item in ShoppingCart.Items)
|
||||
{
|
||||
if (item.Price is not null)
|
||||
{
|
||||
// Mapped fallback default (set to 0 for specific keys to test stock warnings instantly)
|
||||
// In production: pull from your inventory system:
|
||||
// var stockCheck = await BooksService.GetStockLevelAsync(item.Price.Id);
|
||||
int liveStockAvailable = 1;
|
||||
|
||||
AvailableStockMap[item.Price.Id] = liveStockAvailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChangeQuantity(CartItem item, int delta)
|
||||
{
|
||||
var peekQuantity = item.Quantity + delta;
|
||||
if (peekQuantity < 1) return;
|
||||
|
||||
// Block internal counters exceeding live available thresholds
|
||||
if (AvailableStockMap.TryGetValue(item.Price!.Id, out var maxAvailable) && peekQuantity > maxAvailable)
|
||||
{
|
||||
ToastService.ShowWarning($"Cannot exceed remaining stock limit ({maxAvailable} available).", "Stock Limit");
|
||||
return;
|
||||
}
|
||||
|
||||
CartService.UpdateQuantity(item.Price!.Id, delta);
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
}
|
||||
|
||||
private async Task RemoveFromCart(CartItem item)
|
||||
{
|
||||
CartService.RemoveOneItem(item.Price!.Id);
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
}
|
||||
|
||||
private async Task PayNow(MouseEventArgs args)
|
||||
{
|
||||
// Fail-safe protection boundary check
|
||||
if (HasStockExceptions)
|
||||
{
|
||||
ToastService.ShowError("Your order cannot contain items that are out of stock.", "Inventory Issue");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsProcessing)
|
||||
{
|
||||
ToastService.ShowWarning("Please wait, completing your payment", "Busy...");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsProcessing = true;
|
||||
StateHasChanged();
|
||||
|
||||
Result<long> orderResult;
|
||||
var customerId = (long)ShoppingCart.CustomerId!;
|
||||
|
||||
if (!ShoppingCart.OrderId.HasValue)
|
||||
{
|
||||
CreateOrder request = new(ShoppingCart.TotalAmount, null);
|
||||
orderResult = await OrderService.CreateOrderAsync(customerId, request, CancellationToken);
|
||||
ShoppingCart.OrderId = orderResult.Value;
|
||||
}
|
||||
|
||||
List<CreateOrderItem> orderItems = [];
|
||||
var orderId = (long)ShoppingCart.OrderId;
|
||||
|
||||
await OrderService.ClearOrderItemsAsync(orderId, CancellationToken);
|
||||
|
||||
foreach (var item in ShoppingCart.Items)
|
||||
{
|
||||
var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id, CancellationToken);
|
||||
if (bookRequest.IsSuccess)
|
||||
{
|
||||
var orderItem = new CreateOrderItem(bookRequest.Value.Id, item.Price.Id, item.Quantity);
|
||||
orderItems.Add(orderItem);
|
||||
}
|
||||
}
|
||||
|
||||
var orderHash = HashService.HashEncodeLongId(orderId).Value;
|
||||
var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken);
|
||||
|
||||
long paymentId = 0;
|
||||
if (paymentGen.IsSuccess) paymentId = paymentGen.Value;
|
||||
|
||||
if (paymentGen.IsFailed)
|
||||
{
|
||||
var paymentFetch = await PaymentService.GetOrderPaymentAsync(orderId, CancellationToken);
|
||||
if (paymentFetch.IsFailed)
|
||||
{
|
||||
ToastService.ShowError("Failed to fetch your previously made payment", "Payment Check");
|
||||
IsProcessing = false;
|
||||
return;
|
||||
}
|
||||
paymentId = paymentFetch.Value.Id;
|
||||
}
|
||||
|
||||
CreateLedgerEntry ledgerRequest = new()
|
||||
{
|
||||
OrderId = orderId,
|
||||
CustomerId = customerId,
|
||||
PaymentGatewayId = 1,
|
||||
PaymentGatewayReference = orderHash,
|
||||
PaymentId = paymentId,
|
||||
Status = LiteCharms.Features.LedgerStatuses.Sent,
|
||||
};
|
||||
await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken);
|
||||
|
||||
var addItemsResult = await OrderService.AddItemsToOrderAsync(orderId, [.. orderItems], CancellationToken);
|
||||
var hostAddress = Navigation.BaseUri.TrimEnd('/');
|
||||
|
||||
CheckoutPayload = new Dictionary<string, string>
|
||||
{
|
||||
{ "merchant_id", PayfastOptions.Value.MerchantId! },
|
||||
{ "merchant_key", PayfastOptions.Value.MerchantKey! },
|
||||
{ "return_url", $"{hostAddress}/payment-success?reference={orderHash}" },
|
||||
{ "cancel_url", $"{hostAddress}/payment-failed?reference={orderHash}" },
|
||||
{ "notify_url", "https://api.uat.midrandbooks.co.za/v1/payments/payfast/confirm" },
|
||||
{ "email_address", User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value! },
|
||||
{ "m_payment_id", orderHash },
|
||||
{ "amount", ShoppingCart.TotalAmount.ToString("F2", CultureInfo.InvariantCulture) },
|
||||
{ "item_name", "MidrandBooks Sale" },
|
||||
};
|
||||
|
||||
var signature = PayfastService.GenerateSignature(CheckoutPayload!, PayfastOptions.Value.Passphrase).Value;
|
||||
CheckoutPayload.Add("signature", signature);
|
||||
|
||||
StateHasChanged();
|
||||
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('payfastForm').submit();");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToastService.ShowError($"Failed to perform checkout: {ex.Message}", "Checkout");
|
||||
IsProcessing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 • 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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,53 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
@inject NavigationManager Navigation
|
||||
@namespace MidrandBooks.Components.Pages
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<PageTitle>System Exception | Midrand Books</PageTitle>
|
||||
<div id="error-razor" class="w-full max-w-xl mx-auto px-4 py-16 text-center space-y-6">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-500/10 text-red-600 border border-red-500/20">
|
||||
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="artistic-error-container">
|
||||
<div class="container h-100">
|
||||
<div class="row align-items-center justify-content-center min-vh-70 py-5">
|
||||
<div class="space-y-2">
|
||||
<h2 class="font-serif text-2xl md:text-3xl font-bold text-slate-800">Something went wrong</h2>
|
||||
<p class="text-xs text-slate-500 max-w-sm mx-auto leading-relaxed">
|
||||
@Message
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 d-flex justify-content-center mb-5 mb-md-0">
|
||||
<svg viewBox="0 0 400 400" width="300" height="300" fill="none" stroke="#1A1A1A" stroke-width="1.5">
|
||||
<rect x="100" y="100" width="200" height="200" transform="rotate(45 200 200)" opacity="0.2" />
|
||||
<line x1="150" y1="150" x2="250" y2="250" />
|
||||
<line x1="250" y1="150" x2="150" y2="250" />
|
||||
<circle cx="200" cy="200" r="80" stroke-dasharray="10 10" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 offset-md-1">
|
||||
<div class="meta-tag font-monospace text-uppercase text-danger mb-3">System Exception // Critical Halt</div>
|
||||
|
||||
<h1 class="display-4 text-dark mb-4 editorial-headline">An unexpected<br />interruption.</h1>
|
||||
|
||||
<p class="text-muted mb-5 body-manifesto-text">
|
||||
We are experiencing a technical disruption in our processing stream. Our team has been notified of this structural anomaly.
|
||||
</p>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p class="font-monospace text-muted small mb-4">
|
||||
Ref: <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<button class="btn btn-outline-dark rounded-pill px-5 py-3" @onclick='() => Navigation.NavigateTo("/")'>Return to Library</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(ErrorCode))
|
||||
{
|
||||
<div class="p-3 bg-slate-50 rounded-xl border border-slate-100 inline-block font-mono text-[10px] text-slate-400">
|
||||
Error Code: <span class="font-bold text-slate-600">@ErrorCode</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
@if (OnRetry.HasDelegate)
|
||||
{
|
||||
<button @onclick="HandleRetry" class="px-5 py-2 rounded-xl bg-violet-600 hover:bg-violet-500 text-white font-bold text-xs transition-all shadow-md shadow-violet-500/15 cursor-pointer">
|
||||
Try Again
|
||||
</button>
|
||||
}
|
||||
<button @onclick="HandleBackToHome" class="px-5 py-2 rounded-xl border border-slate-200 hover:bg-slate-50 text-slate-600 font-bold text-xs transition-all cursor-pointer">
|
||||
Back to Catalog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
[Parameter] public string Message { get; set; } = "An unexpected error occurred while processing your request. Please try again later.";
|
||||
[Parameter] public string? ErrorCode { get; set; } = "ERR_CONNECTION_FAILED";
|
||||
[Parameter] public EventCallback OnRetry { get; set; }
|
||||
[Parameter] public EventCallback OnBackToHome { get; set; }
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
private async Task HandleRetry()
|
||||
{
|
||||
await OnRetry.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task HandleBackToHome()
|
||||
{
|
||||
await OnBackToHome.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.artistic-error-container {
|
||||
background-color: #ffffff;
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.editorial-headline {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.body-manifesto-text {
|
||||
font-size: 1.1rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: #dc3545; /* Subtle red for system status */
|
||||
}
|
||||
|
||||
.btn-outline-dark {
|
||||
border: 1px solid #111;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-outline-dark:hover {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
using LiteCharms.Features;
|
||||
using LiteCharms.Features.MidrandBooks.AuthorBooks;
|
||||
using LiteCharms.Features.MidrandBooks.Authors;
|
||||
using LiteCharms.Features.MidrandBooks.Categories;
|
||||
using LiteCharms.Features.MidrandBooks.Payments;
|
||||
using LiteCharms.Features.MidrandBooks.Products;
|
||||
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||
using LiteCharms.Features.Models;
|
||||
|
||||
namespace MidrandBookshop.Components.Pages;
|
||||
|
||||
public partial class Home : ComponentBase
|
||||
{
|
||||
[Inject] private ProductService ProductService { get; set; } = default!;
|
||||
[Inject] private BooksService BooksService { get; set; } = default!;
|
||||
[Inject] private AuthorService AuthorService { get; set; } = default!;
|
||||
[Inject] private CategoryService CategoryService { get; set; } = default!;
|
||||
[Inject] private NavigationManager Navigation { get; set; } = default!;
|
||||
[Inject] private HydrationService HydrationService { get; set; } = default!;
|
||||
[Inject] private CancellationToken CancellationToken { get; set; } = default!;
|
||||
[Inject] private CartService CartService { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "q")] public string? SharedSearchQuery { get; set; }
|
||||
[SupplyParameterFromQuery] public long? AuthorId { get; set; }
|
||||
|
||||
private ElementReference topTargetRef;
|
||||
public enum ViewMode { Grid, List }
|
||||
private ViewMode CurrentViewMode = ViewMode.Grid;
|
||||
private string ActiveCategory = "All";
|
||||
private bool ShowExpandedCategories = false;
|
||||
private bool ShowFilterMenu = false;
|
||||
private string SelectedSortOption = "default";
|
||||
private string ActivePriceFilter = "all";
|
||||
private bool OnlyShowNew = false;
|
||||
|
||||
private List<string> MainCategories { get; set; } = ["All"];
|
||||
private List<string> DynamicExtendedCategories { get; set; } = [];
|
||||
|
||||
private int ItemsPerPage = 12;
|
||||
private int VisibleCount = 12;
|
||||
|
||||
private List<Product> ProductsCollection { get; set; } = [];
|
||||
|
||||
protected string? ActiveAuthorFilterName { get; private set; }
|
||||
|
||||
private Dictionary<long, decimal> ProductPriceCache { get; set; } = [];
|
||||
private Dictionary<long, string> ProductPrimaryCategoryCache { get; set; } = [];
|
||||
private Dictionary<long, string> ProductAuthorCache { get; set; } = [];
|
||||
|
||||
private IEnumerable<Product> FilteredData
|
||||
{
|
||||
get
|
||||
{
|
||||
var data = ProductsCollection.AsEnumerable();
|
||||
|
||||
if (ActiveCategory != "All" && !AuthorId.HasValue)
|
||||
{
|
||||
data = data.Where(p => ProductPrimaryCategoryCache.ContainsKey(p.Id) &&
|
||||
ProductPrimaryCategoryCache[p.Id] == ActiveCategory);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SharedSearchQuery))
|
||||
{
|
||||
var q = SharedSearchQuery.Trim();
|
||||
data = data.Where(p => (p.Name ?? "").Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (ActivePriceFilter != "all")
|
||||
{
|
||||
data = data.Where(p =>
|
||||
{
|
||||
var price = ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m;
|
||||
|
||||
return ActivePriceFilter switch
|
||||
{
|
||||
"under-500" => price < 500m,
|
||||
"500-1000" => price >= 500m && price <= 1000m,
|
||||
"over-1000" => price > 1000m,
|
||||
_ => true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (OnlyShowNew) data = data.Where(p => p.Enabled);
|
||||
|
||||
data = SelectedSortOption switch
|
||||
{
|
||||
"price-low" => data.OrderBy(p => ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m),
|
||||
"price-high" => data.OrderByDescending(p => ProductPriceCache.TryGetValue(p.Id, out var amt) ? amt : 0m),
|
||||
"title-asc" => data.OrderBy(p => p.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase),
|
||||
"default" or _ => data
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Product> PaginatedBooks => FilteredData.Take(VisibleCount);
|
||||
|
||||
private bool HasMoreItems => FilteredData.Count() > VisibleCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
//if (CartService.ShoppingCart.Items.Count == 0)
|
||||
// await CartService.LoadCartFromStorageAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
//if (firstRender == false && HydrationService.CartHydrated == false)
|
||||
//{
|
||||
// if(!CartService.ShoppingCart.CustomerId.HasValue)
|
||||
// await HydrationService.EnsureCustomerExistsAsync(CancellationToken);
|
||||
//}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
//await LoadCatalogDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadCatalogDataAsync()
|
||||
{
|
||||
ProductsCollection.Clear();
|
||||
ProductPriceCache.Clear();
|
||||
ProductPrimaryCategoryCache.Clear();
|
||||
ProductAuthorCache.Clear();
|
||||
ActiveAuthorFilterName = null;
|
||||
|
||||
if (AuthorId.HasValue)
|
||||
{
|
||||
var authorResult = await AuthorService.GetAuthorAsync(AuthorId.Value);
|
||||
|
||||
if (authorResult.IsSuccess && authorResult.Value != null)
|
||||
{
|
||||
var author = authorResult.Value;
|
||||
|
||||
ActiveAuthorFilterName = author.PublisherType == PublisherTypes.Company && !string.IsNullOrWhiteSpace(author.Company)
|
||||
? author.Company
|
||||
: $"{author.Name} {author.LastName}".Trim();
|
||||
}
|
||||
|
||||
var authorBooksResult = await BooksService.GetBooksByAuthorAsync(AuthorId.Value);
|
||||
|
||||
if (authorBooksResult.IsSuccess && authorBooksResult.Value != null)
|
||||
{
|
||||
foreach (var authorBook in authorBooksResult.Value)
|
||||
{
|
||||
if (authorBook.Product != null)
|
||||
{
|
||||
var product = authorBook.Product;
|
||||
|
||||
ProductsCollection.Add(product);
|
||||
ProductPriceCache[product.Id] = product.Price?.Amount ?? 0m;
|
||||
|
||||
ProductAuthorCache[product.Id] = ActiveAuthorFilterName ?? "Unknown Author";
|
||||
|
||||
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
|
||||
|
||||
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
|
||||
? (categoryResult.Value[0].Name ?? "General")
|
||||
: "General";
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var selectionRange = new DateRange
|
||||
{
|
||||
From = new DateOnly(2020, 1, 1),
|
||||
To = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)),
|
||||
MaxRecords = 100
|
||||
};
|
||||
|
||||
var allProductsResult = await ProductService.GetProductsAsync(0, selectionRange);
|
||||
|
||||
if (allProductsResult.IsSuccess && allProductsResult.Value != null)
|
||||
{
|
||||
ProductsCollection = allProductsResult.Value.ToList();
|
||||
|
||||
foreach (var product in ProductsCollection)
|
||||
{
|
||||
var priceResult = await ProductService.GetProductPriceAsync(product.Id);
|
||||
ProductPriceCache[product.Id] = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
|
||||
|
||||
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
|
||||
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
|
||||
? (categoryResult.Value[0].Name ?? "General")
|
||||
: "General";
|
||||
|
||||
ProductAuthorCache[product.Id] = !string.IsNullOrWhiteSpace(product.Metadata?.Manufacturer)
|
||||
? product.Metadata.Manufacturer
|
||||
: "Unknown Author";
|
||||
}
|
||||
}
|
||||
|
||||
var categoriesResult = await CategoryService.GetCategoriesAsync();
|
||||
|
||||
if (categoriesResult.IsSuccess && categoriesResult.Value != null)
|
||||
{
|
||||
var cleanNames = categoriesResult.Value
|
||||
.Select(c => c.Name)
|
||||
.Where(n => !string.IsNullOrEmpty(n)).Cast<string>()
|
||||
.ToList();
|
||||
|
||||
MainCategories = ["All", .. cleanNames.Take(3)];
|
||||
DynamicExtendedCategories = [.. cleanNames.Skip(3)];
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAuthorFilter()
|
||||
{
|
||||
var newUri = Navigation.GetUriWithQueryParameters("/", new Dictionary<string, object?> { { "authorId", null } });
|
||||
|
||||
Navigation.NavigateTo(newUri);
|
||||
}
|
||||
|
||||
private void ResetFilters()
|
||||
{
|
||||
SelectedSortOption = "default";
|
||||
ActivePriceFilter = "all";
|
||||
OnlyShowNew = false;
|
||||
VisibleCount = ItemsPerPage;
|
||||
}
|
||||
|
||||
private void NavigateToProduct(long id) => Navigation.NavigateTo($"/product/{id}");
|
||||
private void SetViewMode(ViewMode targetMode) => CurrentViewMode = targetMode;
|
||||
|
||||
private void SelectCategory(string categoryName)
|
||||
{
|
||||
ActiveCategory = categoryName;
|
||||
VisibleCount = ItemsPerPage;
|
||||
|
||||
var updatedUri = Navigation.GetUriWithQueryParameters(Navigation.Uri, new Dictionary<string, object?> {{ "q", null }});
|
||||
|
||||
Navigation.NavigateTo(updatedUri);
|
||||
}
|
||||
|
||||
private void ToggleExtraCategories() => ShowExpandedCategories = !ShowExpandedCategories;
|
||||
private void ToggleFilterMenu() => ShowFilterMenu = !ShowFilterMenu;
|
||||
private void ChangeSort(string sortOption) => SelectedSortOption = sortOption;
|
||||
private void ChangePriceFilter(string priceBracket) { ActivePriceFilter = priceBracket; VisibleCount = ItemsPerPage; }
|
||||
private void ToggleNewArrivalsOnly(ChangeEventArgs e) { OnlyShowNew = e.Value is bool b && b; VisibleCount = ItemsPerPage; }
|
||||
private void LoadNextPage() { if (HasMoreItems) VisibleCount += ItemsPerPage; }
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/* --- Centering Container --- */
|
||||
.home-portal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Invisible Wrapper --- */
|
||||
.artistic-portal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
/* Make the whole block interactive */
|
||||
cursor: pointer;
|
||||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.artistic-portal:hover {
|
||||
transform: translateY(-5px) scale(1.02);
|
||||
}
|
||||
|
||||
/* --- The Illustration --- */
|
||||
.portal-illustration {
|
||||
width: 100%;
|
||||
max-width: 450px; /* Scales nicely on larger screens */
|
||||
height: auto;
|
||||
/* Adds a beautiful glass-like shadow behind the vector lines */
|
||||
filter: drop-shadow(0 15px 25px rgba(0, 0, 0, 0.3));
|
||||
transition: filter 0.4s ease;
|
||||
}
|
||||
|
||||
.artistic-portal:hover .portal-illustration {
|
||||
filter: drop-shadow(0 25px 35px rgba(0, 0, 0, 0.4)) drop-shadow(0 0 15px rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
/* --- Ambient Animations inside the SVG --- */
|
||||
.float-slow {
|
||||
animation: floatVertical 6s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.float-medium {
|
||||
animation: floatVertical 4s ease-in-out infinite reverse;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes floatVertical {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- The Text --- */
|
||||
.read-text {
|
||||
color: #f8f9fa;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.35em;
|
||||
margin-left: 0.35em; /* Compensates for the extra tracking on the right side */
|
||||
/* Soft glow */
|
||||
text-shadow: 0 4px 15px rgba(248, 249, 250, 0.2);
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.artistic-portal:hover .read-text {
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.4em;
|
||||
text-shadow: 0 4px 20px rgba(248, 249, 250, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
@namespace MidrandBooks.Components
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MidrandBooks.Models
|
||||
|
||||
<div id="library-view-razor" class="w-full max-w-7xl mx-auto px-4 sm:px-6 my-10 space-y-8 min-h-[500px]">
|
||||
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 class="font-serif text-2xl md:text-3xl font-bold text-slate-800 flex items-center gap-2">
|
||||
<svg class="h-7 w-7 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
My Secure Library
|
||||
</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
Access your licensed digital purchases. Sync progress instantly across all browsers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Format Filter Tabs -->
|
||||
<div class="flex bg-slate-100 border border-slate-200/60 p-1 rounded-xl self-start sm:self-auto">
|
||||
@foreach (var format in Formats)
|
||||
{
|
||||
<button @onclick="() => FilterByFormat(format)"
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wider transition-all cursor-pointer @(ActiveFormat == format ? "bg-white text-slate-900 shadow-sm border border-slate-200/50" : "text-slate-500 hover:text-slate-800")">
|
||||
@format
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Layout -->
|
||||
@if (FilteredLibraryItems().Any())
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@foreach (var item in FilteredLibraryItems())
|
||||
{
|
||||
var book = item.Book;
|
||||
var isEbook = item.Format == "ebook";
|
||||
var isCached = OfflineBooks.Contains(book.Id);
|
||||
var isDownloading = DownloadingBookId == book.Id;
|
||||
|
||||
<div class="group relative flex gap-5 rounded-2xl p-4 border border-slate-100 bg-white/70 backdrop-blur-md shadow-sm hover:shadow-md transition-all">
|
||||
|
||||
<!-- Book Cover -->
|
||||
<div @onclick="() => HandleSelectBook(book)" class="relative h-36 w-24 rounded-xl overflow-hidden shadow-sm bg-slate-100 flex-shrink-0 cursor-pointer">
|
||||
@if (!string.IsNullOrEmpty(book.CoverUrl))
|
||||
{
|
||||
<img src="@book.CoverUrl" alt="@book.Title" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="h-full w-full bg-gradient-to-br @book.CoverColor p-3 flex flex-col justify-between text-white transition-transform duration-500 group-hover:scale-105">
|
||||
<span class="text-[6px] font-mono font-bold tracking-widest block uppercase text-white/40">@book.Category</span>
|
||||
<span class="text-xs font-serif font-extrabold line-clamp-3 leading-tight text-white">@book.Title</span>
|
||||
<span class="text-[8px] font-sans text-white/70 truncate block">By @book.Author</span>
|
||||
</div>
|
||||
}
|
||||
<div class="absolute top-0 bottom-0 left-0 w-2 bg-gradient-to-r from-black/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Details Node -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="inline-block text-[8px] font-mono font-extrabold uppercase rounded px-1.5 py-0.5 @(isEbook ? "bg-indigo-50 text-indigo-700" : "bg-violet-50 text-violet-700")">
|
||||
@item.Format
|
||||
</span>
|
||||
|
||||
<!-- Offline Cache Trigger Button -->
|
||||
@if (isEbook)
|
||||
{
|
||||
@if (isCached)
|
||||
{
|
||||
<span class="inline-flex items-center gap-0.5 text-[9px] font-semibold text-emerald-600 font-mono" title="Synced Offline Cached copy secure">
|
||||
✓ Cached
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button @onclick="() => TriggerCacheBook(book.Id)" disabled="@isDownloading" class="text-[9px] font-bold text-violet-600 hover:text-violet-800 flex items-center gap-0.5 cursor-pointer disabled:opacity-50">
|
||||
@if (isDownloading)
|
||||
{
|
||||
<span>Caching...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>↓ Sync Offline</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 @onclick="() => HandleSelectBook(book)" class="font-serif text-sm font-bold text-slate-800 leading-tight mt-1.5 hover:text-violet-600 cursor-pointer truncate">
|
||||
@book.Title
|
||||
</h3>
|
||||
<span class="text-[10px] text-slate-400 block mt-0.5">By @book.Author</span>
|
||||
|
||||
<!-- Progress Track Slider bar -->
|
||||
<div class="mt-3.5 space-y-1">
|
||||
<div class="flex justify-between text-[8px] font-mono font-bold text-slate-400">
|
||||
<span>READING PROGRESS</span>
|
||||
<span>@(isEbook ? $"Page {item.CurrentPage + 1}" : "In Progress")</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-violet-600 h-full rounded-full transition-all" style="width: @(isEbook ? $"{item.Progress}%" : "15%")" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Action Buttons -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
@if (isEbook)
|
||||
{
|
||||
<button @onclick="() => HandleReadBook(book)" class="flex-1 py-1.5 text-center text-[10px] font-bold rounded-xl bg-slate-900 hover:bg-slate-800 text-white shadow-sm transition-colors cursor-pointer flex items-center justify-center gap-1">
|
||||
Open Ebook
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button @onclick="() => HandlePlayBook(book)" class="flex-1 py-1.5 text-center text-[10px] font-bold rounded-xl bg-amber-400 hover:bg-amber-300 text-slate-950 shadow-sm transition-colors cursor-pointer flex items-center justify-center gap-1">
|
||||
Listen Audio
|
||||
</button>
|
||||
}
|
||||
<button @onclick="() => HandleSelectBook(book)" class="p-1.5 rounded-xl border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-slate-700 cursor-pointer" title="View details">
|
||||
<svg class="h-4.5 w-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-12 text-center border border-dashed border-slate-200 bg-slate-50/50 rounded-3xl space-y-2">
|
||||
<svg class="mx-auto h-8 w-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<h4 class="font-serif text-sm font-bold text-slate-700">Your secure library is empty</h4>
|
||||
<p class="text-[10px] text-slate-500 font-sans max-w-sm mx-auto leading-relaxed">
|
||||
Unlock local publishing titles, bestsellers, and studio audiobooks in our browse store. Purchases are instantly activated here.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<PurchaseRecord> Purchases { get; set; } = new();
|
||||
[Parameter] public List<BookModel> Books { get; set; } = new();
|
||||
[Parameter] public List<string> OfflineBooks { get; set; } = new();
|
||||
[Parameter] public bool IsOfflineMode { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
|
||||
[Parameter] public EventCallback<BookModel> OnReadBook { get; set; }
|
||||
[Parameter] public EventCallback<BookModel> OnPlayBook { get; set; }
|
||||
[Parameter] public EventCallback<string> OnCacheBookOffline { get; set; }
|
||||
|
||||
private string ActiveFormat { get; set; } = "all";
|
||||
private string[] Formats = new string[] { "all", "ebook", "audiobook" };
|
||||
private string? DownloadingBookId { get; set; }
|
||||
|
||||
private void FilterByFormat(string format)
|
||||
{
|
||||
ActiveFormat = format;
|
||||
}
|
||||
|
||||
private IEnumerable<LibraryItem> FilteredLibraryItems()
|
||||
{
|
||||
var items = new List<LibraryItem>();
|
||||
foreach (var purchase in Purchases)
|
||||
{
|
||||
var matchedBook = Books.FirstOrDefault(b => b.Id == purchase.BookId);
|
||||
if (matchedBook != null)
|
||||
{
|
||||
items.Add(new LibraryItem
|
||||
{
|
||||
Book = matchedBook,
|
||||
Format = purchase.Format,
|
||||
CurrentPage = purchase.CurrentPage,
|
||||
Progress = purchase.Progress
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ActiveFormat == "all") return items;
|
||||
return items.Where(i => i.Format.Equals(ActiveFormat, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task TriggerCacheBook(string bookId)
|
||||
{
|
||||
if (DownloadingBookId != null) return;
|
||||
DownloadingBookId = bookId;
|
||||
|
||||
await Task.Delay(1500); // Simulate network latency
|
||||
await OnCacheBookOffline.InvokeAsync(bookId);
|
||||
|
||||
DownloadingBookId = null;
|
||||
}
|
||||
|
||||
private void HandleSelectBook(BookModel book)
|
||||
{
|
||||
OnSelectBook.InvokeAsync(book);
|
||||
}
|
||||
|
||||
private void HandleReadBook(BookModel book)
|
||||
{
|
||||
OnReadBook.InvokeAsync(book);
|
||||
}
|
||||
|
||||
private void HandlePlayBook(BookModel book)
|
||||
{
|
||||
OnPlayBook.InvokeAsync(book);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,33 @@
|
||||
@page "/not-found"
|
||||
@inject NavigationManager Navigation
|
||||
@namespace MidrandBooks.Components.Pages
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<div class="artistic-404-container">
|
||||
<div class="container h-100">
|
||||
<div class="row align-items-center justify-content-center min-vh-70 py-5">
|
||||
|
||||
<div class="col-12 col-md-5 d-flex justify-content-center mb-5 mb-md-0">
|
||||
<div class="artistic-svg-wrapper">
|
||||
<svg viewBox="0 0 400 400" width="350" height="350" stroke="#1A1A1A" stroke-width="2" fill="none">
|
||||
<path d="M60 80 Q80 60 100 80" />
|
||||
<path d="M100 60 Q120 40 140 60" />
|
||||
<path d="M280 70 Q300 50 320 70" />
|
||||
|
||||
<path d="M100 200 L200 180 L300 200 L300 300 L200 320 L100 300 Z" fill="#F8F8F8" />
|
||||
<path d="M200 180 L200 320" />
|
||||
<path d="M120 220 L180 220 M120 250 L180 250 M220 220 L280 220 M220 250 L280 250" stroke="#DDD" />
|
||||
|
||||
<circle cx="230" cy="230" r="40" />
|
||||
<line x1="258" y1="258" x2="290" y2="290" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 offset-md-1">
|
||||
<div class="meta-tag font-monospace text-uppercase text-muted mb-3">System Index // Uncharted Territory</div>
|
||||
<h1 class="display-4 text-dark mb-4 editorial-headline">Nothing was found<br />in this search.</h1>
|
||||
<p class="text-muted mb-5 body-manifesto-text">
|
||||
Even with careful scrutiny, the requested volume remains elusive. It appears the shelf you are exploring is currently vacant.
|
||||
</p>
|
||||
<button class="btn btn-dark rounded-pill px-5 py-3" @onclick='() => Navigation.NavigateTo("/")'>Return to Collection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notfound-razor" class="w-full max-w-xl mx-auto px-4 py-16 text-center space-y-6">
|
||||
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-400 border border-slate-200/60">
|
||||
<svg class="h-10 w-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h2 class="font-serif text-3xl font-extrabold text-slate-800 tracking-tight">404</h2>
|
||||
<h3 class="font-serif text-lg font-bold text-slate-700">Page Not Found</h3>
|
||||
<p class="text-xs text-slate-500 max-w-sm mx-auto leading-relaxed">
|
||||
The literary masterpiece or digital section you are trying to find doesn't exist or has been relocated to another shelf.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<button @onclick="HandleBackToHome" class="px-5 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-bold text-xs transition-all shadow-md cursor-pointer">
|
||||
Return to Storefront
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback OnBackToHome { get; set; }
|
||||
|
||||
private async Task HandleBackToHome()
|
||||
{
|
||||
await OnBackToHome.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
.artistic-404-container {
|
||||
background-color: #ffffff;
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.terrain-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* Static, grounded look */
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.editorial-headline {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.body-manifesto-text {
|
||||
font-size: 1.1rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.artistic-404-container {
|
||||
background-color: #ffffff;
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.artistic-svg-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editorial-headline {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.body-manifesto-text {
|
||||
font-size: 1.1rem;
|
||||
max-width: 400px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
@page "/payment-failed"
|
||||
@rendermode InteractiveServer
|
||||
@inject NavigationManager Navigation
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<div class="d-inline-block p-4 rounded-circle bg-danger bg-opacity-10 text-danger mb-3">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="fw-bold mb-3">Payment Cancelled</h1>
|
||||
<p class="text-muted fs-5">We couldn't process your transaction. Don't worry, no money was deducted from your account.</p>
|
||||
|
||||
<div class="bg-light p-3 rounded mt-4">
|
||||
<p class="mb-0 text-muted small text-uppercase fw-bold">Common Causes</p>
|
||||
<p class="mb-0 fs-6 text-dark mt-1">The order was cancelled / insufficient funds, incorrect card details, or a temporary bank gateway timeout.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-3 mt-5">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<a href="/" class="btn btn-outline-dark w-100 rounded-pill py-3">View Store</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a href="/support" class="btn btn-outline-dark w-100 rounded-pill py-3">Get Help</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-muted small">If you noticed a charge or have any order questions, please contact our support desk with your account email <strong>shop@litecharms.co.za</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,39 +0,0 @@
|
||||
@page "/payment-success"
|
||||
@rendermode InteractiveServer
|
||||
@inject NavigationManager Navigation
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="mb-4">
|
||||
<div class="d-inline-block p-4 rounded-circle bg-success bg-opacity-10 text-success mb-3">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="fw-bold mb-3">Order Confirmed</h1>
|
||||
<p class="text-muted fs-5">Thank you for shopping with Midrand Books. Your order has been received and is being processed.</p>
|
||||
<div class="bg-light p-3 rounded mt-4">
|
||||
<p class="mb-0 text-muted small text-uppercase fw-bold">Order Number</p>
|
||||
<h5 class="fw-bold mb-0">@PaymentReference</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-3 mt-5">
|
||||
<a href="/" class="btn btn-dark btn-lg rounded-pill py-3">Continue Shopping</a>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Order History</a>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<a href="/account" class="btn btn-outline-dark w-100 rounded-pill py-3">Track Order</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>@CustomerEmail</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,73 +1,118 @@
|
||||
@page "/privacy"
|
||||
@rendermode InteractiveServer
|
||||
@page "/privacy"
|
||||
@namespace MidrandBooks.Components.Pages
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@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>
|
||||
<div id="privacy-razor" class="w-full max-w-7xl mx-auto px-4 sm:px-6 my-10 space-y-8 min-h-[600px] animate-in fade-in duration-300">
|
||||
|
||||
<!-- Header Hero Banner -->
|
||||
<header class="relative overflow-hidden rounded-3xl border border-violet-500/30 bg-slate-900 p-8 md:p-12 shadow-xl flex flex-col justify-between gap-6">
|
||||
<div class="absolute top-0 right-0 h-64 w-64 bg-violet-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div class="absolute bottom-0 left-0 h-40 w-40 bg-indigo-600/10 rounded-full blur-2xl pointer-events-none" />
|
||||
|
||||
<div class="space-y-4 max-w-3xl relative z-10">
|
||||
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-violet-500/20 border border-violet-500/30 text-xs font-mono font-bold text-violet-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Regulatory Governance
|
||||
</div>
|
||||
<h1 class="font-serif text-3xl md:text-4xl font-black text-white tracking-tight">
|
||||
Privacy & Data Protection Policy
|
||||
</h1>
|
||||
<p class="text-xs md:text-sm text-slate-300 leading-relaxed font-medium font-sans">
|
||||
Midrand Books is designed, maintained, owned, and hosted by <strong class="text-violet-300">LiteCharms (PTY) Ltd</strong> (<a href="https://www.litecharms.co.za" target="_blank" rel="noopener noreferrer" class="text-violet-400 hover:text-violet-300 underline">www.litecharms.co.za</a>), a company duly incorporated and registered in accordance with the Companies Act of South Africa (Registration No: <span class="font-mono text-xs">2020/269438/07</span>).
|
||||
</p>
|
||||
</div>
|
||||
</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 class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
|
||||
<!-- Sidebar Navigation Index -->
|
||||
<div class="lg:col-span-3 lg:sticky lg:top-24 hidden lg:block">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md p-5 space-y-4 shadow-sm">
|
||||
<span class="text-[10px] font-mono font-bold tracking-wider text-slate-400 uppercase block pb-2 border-b border-white/5">
|
||||
Policy Sections
|
||||
</span>
|
||||
<nav class="flex flex-col gap-2 font-mono text-[11px] font-bold text-slate-300 uppercase">
|
||||
<button @onclick='() => ScrollToSection("section-processing")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
1. Lawful Processing
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-collection")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
2. Information We Collect
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-identity")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
3. Identity Nodes
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-payment")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
4. PayFast Gateway
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-rights")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
5. Data Subject Rights
|
||||
</button>
|
||||
</nav>
|
||||
</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.
|
||||
<!-- Scrollable Legal Article Body -->
|
||||
<div class="lg:col-span-9 space-y-6">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/40 backdrop-blur-md p-6 sm:p-8 shadow-sm space-y-8 text-slate-200 text-xs sm:text-sm leading-relaxed">
|
||||
|
||||
<section id="section-processing" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
1. Commitment to POPIA Compliance
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
LiteCharms (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>
|
||||
<section id="section-collection" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
2. Collection of Personal Information
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
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 class="list-disc pl-5 space-y-2 text-slate-300 font-medium">
|
||||
<li><strong class="text-white">Identity Details:</strong> Name, email address, and verification markers via federated claim contexts.</li>
|
||||
<li><strong class="text-white">Logistics Metrics:</strong> Physical shipping addresses and localized postal codes for courier assignment within South African borders.</li>
|
||||
<li><strong class="text-white">Digital Footprints:</strong> 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.
|
||||
<section id="section-identity" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
3. Decentralized Claims & Identity Security
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
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 class="text-white">STS Security Cluster</strong>). LiteCharms (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.
|
||||
<section id="section-payment" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
4. Secure Financial Handover Disclosures
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
All credit card, Instant EFT, and electronic payments are handled through a direct, secure integration handshake with the <strong class="text-white">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>.
|
||||
<section id="section-rights" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
5. Data Subject Rights & Information Officer
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
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 class="bg-slate-950/60 px-2 py-1 rounded text-violet-300 font-bold border border-white/5">privacy@midrandbooks.co.za</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -80,4 +125,4 @@
|
||||
}}
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
@page "/product/{BookId:long}"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<div class="product-container">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-dark" role="status">
|
||||
<span class="visually-hidden">Loading product details...</span>
|
||||
</div>
|
||||
<p class="text-muted mt-2">Loading curated details...</p>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentProduct == null)
|
||||
{
|
||||
<div class="text-center py-5 animate-fade-in">
|
||||
<svg class="text-muted mb-3" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<h2 class="h5 text-dark fw-medium">Artifact Not Found</h2>
|
||||
<p class="text-muted small">The requested book could not be found in our current catalog.</p>
|
||||
<button class="btn btn-dark btn-sm rounded-pill px-4 mt-2" @onclick='() => Navigation.NavigateTo("/")'>Return to Books</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<nav class="breadcrumb">
|
||||
<span @onclick='() => Navigation.NavigateTo("/")' class="crumb-link">Books</span>
|
||||
<span class="crumb-separator">/</span>
|
||||
<span class="crumb-current">@CurrentProduct.Name</span>
|
||||
</nav>
|
||||
|
||||
<div class="product-layout">
|
||||
<div class="gallery-section">
|
||||
<div class="main-image-wrapper">
|
||||
@if (!string.IsNullOrWhiteSpace(ActiveImageUrl))
|
||||
{
|
||||
<img src="@ActiveImageUrl" alt="@CurrentProduct.Name" class="main-image" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="book-spine-fallback-large bg-dark d-flex align-items-center justify-content-center text-center p-4 text-white-50 shadow-sm">
|
||||
<div>
|
||||
<span class="fw-semibold tracking-wider d-block">@PrimaryCategory.ToUpper()</span>
|
||||
<span class="opacity-50 small" style="font-size: 0.65rem;">CURATED EDITION</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="format-badges">
|
||||
@if (CurrentProduct.Enabled)
|
||||
{
|
||||
<span class="badge badge-physical">Physical Book</span>
|
||||
}
|
||||
|
||||
@if (StockCount <= 0)
|
||||
{
|
||||
<span class="badge bg-danger text-white border-0">Out of Stock</span>
|
||||
}
|
||||
else if (StockCount <= 3)
|
||||
{
|
||||
<span class="badge bg-warning text-dark border-0">Only @StockCount Left</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-ebook">In Stock (@StockCount available)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Thumbnails.Count > 1)
|
||||
{
|
||||
<div class="thumbnail-grid">
|
||||
@foreach (var img in Thumbnails)
|
||||
{
|
||||
var currentImg = img;
|
||||
<div class="thumbnail-wrapper @(ActiveImageUrl == currentImg ? "active" : "")"
|
||||
@onclick="() => ActiveImageUrl = currentImg">
|
||||
<img src="@currentImg" alt="Catalog gallery thumbnail" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="details-section">
|
||||
<div class="meta-header">
|
||||
<span class="author-name">@AuthorName</span>
|
||||
<div class="rating-stars">
|
||||
@for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
<span class="star @(i <= 4 ? "filled" : "")">★</span>
|
||||
}
|
||||
<span class="rating-text">(4.8)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="product-title">@CurrentProduct.Name</h1>
|
||||
<div class="product-price">R @LivePrice.ToString("N2")</div>
|
||||
|
||||
<div class="purchase-actions">
|
||||
<div class="quantity-picker @(StockCount <= 0 ? "opacity-50 pointer-events-none" : "")">
|
||||
<button @onclick="DecreaseQty" class="qty-btn" disabled="@(StockCount <= 0 || Quantity <= 1)">-</button>
|
||||
<span class="qty-val">@(StockCount <= 0 ? 0 : Quantity)</span>
|
||||
<button @onclick="IncreaseQty" class="qty-btn" disabled="@(StockCount <= 0 || Quantity >= StockCount)">+</button>
|
||||
</div>
|
||||
|
||||
@if (StockCount > 0)
|
||||
{
|
||||
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
|
||||
Add to Cart
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn-add-to-cart bg-secondary text-white-50 cursor-not-allowed" disabled>
|
||||
Sold Out
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(CurrentProduct.Description))
|
||||
{
|
||||
<div class="info-block">
|
||||
<h3>Description</h3>
|
||||
<p class="description-text">@CurrentProduct.Description</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="info-block author-bio-card">
|
||||
<h3>About the Author</h3>
|
||||
<p class="author-bio">
|
||||
@(string.IsNullOrWhiteSpace(CurrentProduct.Metadata?.Manufacturer)
|
||||
? "Details regarding this publisher/author are maintained inside our curated archives."
|
||||
: $"{CurrentProduct.Metadata.Manufacturer} is globally recognized for their direct contribution to this specific genre spectrum.")
|
||||
</p>
|
||||
<button class="btn-text-link" @onclick="ViewAllAuthorBooks">
|
||||
View all books by @AuthorName →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,154 +0,0 @@
|
||||
using LiteCharms.Features;
|
||||
using LiteCharms.Features.MidrandBooks.Authors;
|
||||
using LiteCharms.Features.MidrandBooks.Authors.Models;
|
||||
using LiteCharms.Features.MidrandBooks.Payments;
|
||||
using LiteCharms.Features.MidrandBooks.Payments.Models;
|
||||
using LiteCharms.Features.MidrandBooks.Products;
|
||||
using LiteCharms.Features.MidrandBooks.Products.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace MidrandBookshop.Components.Pages;
|
||||
|
||||
public partial class ProductView : ComponentBase
|
||||
{
|
||||
[Parameter] public long BookId { get; set; }
|
||||
|
||||
[Inject] private ProductService ProductService { get; set; } = default!;
|
||||
[Inject] private AuthorService AuthorService { get; set; } = default!;
|
||||
[Inject] private NavigationManager Navigation { get; set; } = default!;
|
||||
[Inject] private CartService CartService { get; set; } = default!;
|
||||
|
||||
protected Cart ShoppingCart => CartService?.ShoppingCart!;
|
||||
|
||||
protected bool IsLoading { get; private set; } = true;
|
||||
protected Product? CurrentProduct { get; private set; }
|
||||
protected ProductPrice? CurrentPrice { get; private set; }
|
||||
protected decimal LivePrice { get; private set; } = 0.00m;
|
||||
protected string AuthorName { get; private set; } = "Unknown Author";
|
||||
protected string PrimaryCategory { get; private set; } = "General";
|
||||
|
||||
protected string ActiveImageUrl { get; set; } = string.Empty;
|
||||
protected List<string> Thumbnails { get; private set; } = [];
|
||||
protected int Quantity { get; private set; } = 1;
|
||||
|
||||
// Track real-time stock limits
|
||||
protected int StockCount { get; private set; } = 5;
|
||||
|
||||
protected Author? CurrentAuthor { get; private set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
CurrentProduct = null;
|
||||
CurrentAuthor = null;
|
||||
Thumbnails.Clear();
|
||||
|
||||
var productResult = await ProductService.GetProductAsync(BookId);
|
||||
|
||||
if (productResult.IsSuccess && productResult.Value != null)
|
||||
{
|
||||
CurrentProduct = productResult.Value;
|
||||
AuthorName = CurrentProduct.Metadata?.Manufacturer ?? "Unknown Author";
|
||||
|
||||
// Mapping real stock integers if available on your Product metadata entity model
|
||||
// StockCount = CurrentProduct.InventoryCount;
|
||||
|
||||
var priceResult = await ProductService.GetProductPriceAsync(BookId);
|
||||
LivePrice = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
|
||||
CurrentPrice = priceResult.IsSuccess ? priceResult.Value : null;
|
||||
|
||||
var categoryResult = await ProductService.GetProductCategoriesAsync(BookId);
|
||||
if (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
|
||||
PrimaryCategory = categoryResult.Value[0].Name ?? "General";
|
||||
|
||||
var authorResult = await AuthorService.GetAuthorByProductIdAsync(BookId);
|
||||
if (authorResult.IsSuccess && authorResult.Value != null)
|
||||
{
|
||||
CurrentAuthor = authorResult.Value;
|
||||
|
||||
if (CurrentAuthor.PublisherType == PublisherTypes.Company && !string.IsNullOrWhiteSpace(CurrentAuthor.Company))
|
||||
AuthorName = CurrentAuthor.Company;
|
||||
else
|
||||
AuthorName = $"{CurrentAuthor.Name} {CurrentAuthor.LastName}".Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CurrentProduct.ImageUrl))
|
||||
Thumbnails.Add(CurrentProduct.ImageUrl);
|
||||
|
||||
if (CurrentProduct.ThumbnailUrls != null && CurrentProduct.ThumbnailUrls?.Length != 0)
|
||||
foreach (var url in CurrentProduct.ThumbnailUrls!)
|
||||
if (!string.IsNullOrWhiteSpace(url) && !Thumbnails.Contains(url)) Thumbnails.Add(url);
|
||||
|
||||
ActiveImageUrl = Thumbnails.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
CurrentProduct = null;
|
||||
CurrentPrice = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async void IncreaseQty()
|
||||
{
|
||||
// Enforce maximum stock bounds limits natively during counter picking
|
||||
if (CurrentPrice is not null && Quantity < StockCount)
|
||||
{
|
||||
CartService.UpdateQuantity(CurrentPrice!.Id, 1);
|
||||
|
||||
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
|
||||
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected async void DecreaseQty()
|
||||
{
|
||||
if (Quantity > 1)
|
||||
{
|
||||
CartService.UpdateQuantity(CurrentPrice!.Id, -1);
|
||||
|
||||
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
|
||||
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected async void HandleAddToCart()
|
||||
{
|
||||
if (CurrentProduct == null || StockCount <= 0) return;
|
||||
|
||||
if (CurrentPrice is not null)
|
||||
{
|
||||
if (ShoppingCart.Items.Any(p => p.Price!.Id == CurrentPrice.Id))
|
||||
{
|
||||
var currentInCart = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
|
||||
if (currentInCart >= StockCount) return;
|
||||
|
||||
CartService.UpdateQuantity(CurrentPrice.Id, 1);
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
CartService.AddItem(CurrentPrice, CurrentProduct, CurrentAuthor!);
|
||||
Quantity = CartService.GetCartItemQuantity(ShoppingCart, CurrentPrice.Id);
|
||||
|
||||
await CartService.SaveCartToStorageAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected void ViewAllAuthorBooks()
|
||||
{
|
||||
if (CurrentAuthor is not null)
|
||||
Navigation.NavigateTo($"/?authorId={CurrentAuthor.Id}");
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Structural Layout Containers
|
||||
========================================================================== */
|
||||
.product-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.product-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Breadcrumb Navigation Links
|
||||
========================================================================== */
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 2.5rem;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.crumb-link {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.crumb-link:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.crumb-separator {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.crumb-current {
|
||||
color: #111;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Left Section: Media Gallery Components
|
||||
========================================================================== */
|
||||
.gallery-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-image-wrapper {
|
||||
position: relative;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 3rem 3rem 4.5rem 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-image {
|
||||
max-height: 450px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* Dynamic Overlaid Attribute Badges - Centered flawlessly without stacking */
|
||||
.format-badges {
|
||||
position: absolute !important;
|
||||
bottom: 1.5rem !important;
|
||||
left: 50% !important; /* Move anchor point to exactly half-width */
|
||||
right: auto !important;
|
||||
transform: translateX(-50%) !important; /* Offset width symmetrically to center perfectly */
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important; /* Firmly prevents items from stacking vertically */
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 0.5rem !important;
|
||||
width: max-content !important; /* Expands safely according to badges text values */
|
||||
max-width: 90% !important; /* Keeps bounding box clean of parent borders */
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 100px;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
display: inline-block !important;
|
||||
white-space: nowrap !important; /* Protects badge inner labels from broken line wrapping */
|
||||
}
|
||||
|
||||
.badge-physical {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-ebook {
|
||||
background-color: #fff;
|
||||
color: #111;
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background-color: #f0fdf4;
|
||||
color: #166534;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
/* Interactive Gallery Thumbnails Grid */
|
||||
.thumbnail-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.thumbnail-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: #f9f9f9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbnail-wrapper:hover,
|
||||
.thumbnail-wrapper.active {
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.thumbnail-wrapper img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Right Section: Product Details & Typography Controls
|
||||
========================================================================== */
|
||||
.details-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Review Star Components */
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #e5e5e5;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
font-size: 2.5rem;
|
||||
font-family: 'Playfair Display', serif, Georgia;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 1rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Interactive E-Commerce Selection Bars
|
||||
========================================================================== */
|
||||
.purchase-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.quantity-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 100px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.qty-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.qty-btn:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.qty-val {
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-add-to-cart {
|
||||
flex-grow: 1;
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.btn-add-to-cart:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 0;
|
||||
border-top: 1px solid #eee;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Informational Text Elements & Links
|
||||
========================================================================== */
|
||||
.info-block {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.info-block h3 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.description-text,
|
||||
.author-bio {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.author-bio-card {
|
||||
background-color: #fafafa;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-text-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.btn-text-link:hover {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive Adaptations & Breakpoints
|
||||
========================================================================== */
|
||||
@media (max-width: 992px) {
|
||||
.product-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.main-image-wrapper {
|
||||
min-height: 380px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.product-container {
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
.main-image-wrapper {
|
||||
min-height: 320px;
|
||||
padding: 2rem 1rem 4.5rem 1rem; /* Extra padding baseline clearance */
|
||||
}
|
||||
|
||||
.main-image {
|
||||
max-height: 240px; /* Fluidly limits image height to avoid overlay overlapping */
|
||||
}
|
||||
|
||||
.format-badges {
|
||||
bottom: 1.25rem !important; /* Fits beautifully aligned at the bottom center */
|
||||
gap: 0.4rem !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem !important; /* Clean uniform scaling factor to clear margins */
|
||||
padding: 0.25rem 0.65rem !important;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.purchase-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quantity-picker {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add-to-cart {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,131 @@
|
||||
@page "/terms"
|
||||
@rendermode InteractiveServer
|
||||
@page "/terms"
|
||||
@namespace MidrandBooks.Components.Pages
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@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>
|
||||
<div id="terms-razor" class="w-full max-w-7xl mx-auto px-4 sm:px-6 my-10 space-y-8 min-h-[600px] animate-in fade-in duration-300">
|
||||
|
||||
<!-- Header Hero Banner -->
|
||||
<header class="relative overflow-hidden rounded-3xl border border-violet-500/30 bg-slate-900 p-8 md:p-12 shadow-xl flex flex-col justify-between gap-6">
|
||||
<div class="absolute top-0 right-0 h-64 w-64 bg-violet-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div class="absolute bottom-0 left-0 h-40 w-40 bg-indigo-600/10 rounded-full blur-2xl pointer-events-none" />
|
||||
|
||||
<div class="space-y-4 max-w-3xl relative z-10">
|
||||
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-violet-500/20 border border-violet-500/30 text-xs font-mono font-bold text-violet-300">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Commercial Legal Framework
|
||||
</div>
|
||||
<h1 class="font-serif text-3xl md:text-4xl font-black text-white tracking-tight">
|
||||
Terms of Sale & Service
|
||||
</h1>
|
||||
<p class="text-xs md:text-sm text-slate-300 leading-relaxed font-medium font-sans">
|
||||
Midrand Books is designed, maintained, owned, and hosted by <strong class="text-violet-300">LiteCharms (PTY) Ltd</strong> (<a href="https://www.litecharms.co.za" target="_blank" rel="noopener noreferrer" class="text-violet-400 hover:text-violet-300 underline">www.litecharms.co.za</a>), a private corporation duly registered and governed in accordance with the corporate statutes of the Companies Act of the Republic of South Africa (Registration No: <span class="font-mono text-xs">2020/269438/07</span>).
|
||||
</p>
|
||||
</div>
|
||||
</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 class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
|
||||
<!-- Sidebar Navigation Index -->
|
||||
<div class="lg:col-span-3 lg:sticky lg:top-24 hidden lg:block">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md p-5 space-y-4 shadow-sm">
|
||||
<span class="text-[10px] font-mono font-bold tracking-wider text-slate-400 uppercase block pb-2 border-b border-white/5">
|
||||
Agreement Index
|
||||
</span>
|
||||
<nav class="flex flex-col gap-2 font-mono text-[11px] font-bold text-slate-300 uppercase">
|
||||
<button @onclick='() => ScrollToSection("section-formation")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
1. Contract Formation
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-pricing")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
2. Pricing & VAT
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-logistics")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
3. Delivery & Risk
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-cooling")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
4. CPA Cooling-Off
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-liability")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4 text-rose-300 hover:text-rose-200">
|
||||
5. Liability Shield
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-jurisdiction")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
6. Jurisdiction
|
||||
</button>
|
||||
</nav>
|
||||
</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.
|
||||
<!-- Scrollable Legal Article Body -->
|
||||
<div class="lg:col-span-9 space-y-6">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/40 backdrop-blur-md p-6 sm:p-8 shadow-sm space-y-8 text-slate-200 text-xs sm:text-sm leading-relaxed">
|
||||
|
||||
<section id="section-formation" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
1. Electronic Contract Formation
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
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 class="text-white">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 LiteCharms (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.
|
||||
<section id="section-pricing" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
2. Currency, Value Added Tax, & Inventory Realism
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
All book pricing listed on our site is formatted exclusively in <strong class="text-white">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>
|
||||
<section id="section-logistics" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
3. Logistics Dispatch & Risk Pass-Through
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
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.
|
||||
<section id="section-cooling" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
4. Statutory Returns & CPA Cooling-Off Windows
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
Pursuant to Section 44 of ECTA and the directives of the <strong class="text-white">Consumer Protection Act, No. 68 of 2008 (CPA)</strong>, you maintain a statutory cooling-off window of <strong class="text-white">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">
|
||||
<section id="section-liability" class="space-y-3 scroll-mt-28 p-5 rounded-2xl bg-rose-500/10 border border-rose-500/20">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-rose-300 flex items-center gap-2">
|
||||
<svg class="h-5.5 w-5.5 text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
5. Complete Limitation of Liability Shield
|
||||
</h3>
|
||||
<p class="font-mono text-[10px] font-bold text-rose-400 uppercase tracking-wider block">
|
||||
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 class="font-semibold text-rose-100">
|
||||
To the maximum limit permitted by Section 61 of the Consumer Protection Act, LiteCharms (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.
|
||||
<section id="section-jurisdiction" class="space-y-3 scroll-mt-28">
|
||||
<h3 class="font-serif text-lg sm:text-xl font-bold text-white border-b border-white/5 pb-2">
|
||||
6. Governing Law & Jurisdiction
|
||||
</h3>
|
||||
<p class="font-medium text-slate-300">
|
||||
This commercial agreement, catalog usage framework, and transaction architecture are subject strictly to the laws and legal structures of the <strong class="text-white">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>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -86,4 +138,4 @@
|
||||
}}
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user