Compare commits

..
21 Commits
Author SHA1 Message Date
khwezi 49279c0cec Merge pull request 'Added port stripping' (#45) from mock-data into main
Reviewed-on: #45
2026-06-05 07:40:24 +02:00
khwezi edabe266e5 Merge pull request 'Refactored https logni proto handling' (#44) from mock-data into main
Reviewed-on: #44
2026-06-05 06:44:12 +02:00
khwezi 248dd32b1b Merge pull request 'Added support for forwarded headers' (#43) from mock-data into main
Reviewed-on: #43
2026-06-05 06:30:37 +02:00
khwezi 1645b6bbae Merge pull request 'Fixed secrets mappings' (#42) from mock-data into main
Reviewed-on: #42
2026-06-05 06:17:30 +02:00
khwezi 72725a302a Merge pull request 'mock-data' (#41) from mock-data into main
Reviewed-on: #41
2026-06-05 05:58:44 +02:00
khwezi f3d79174be Merge pull request 'Upgraded quartz' (#40) from mock-data into main
Reviewed-on: #40
2026-06-03 11:54:17 +02:00
khwezi c086aa60e4 Merge pull request 'Updated backend' (#39) from mock-data into main
Reviewed-on: #39
2026-06-02 00:31:16 +02:00
khwezi 66b377bf69 Merge pull request 'Fixed event service discovery issue' (#38) from mock-data into main
Reviewed-on: #38
2026-06-01 23:39:37 +02:00
khwezi 82389a9304 Merge pull request 'Updated backend' (#37) from mock-data into main
Reviewed-on: #37
2026-06-01 22:58:55 +02:00
khwezi 4bf1d2e77a Merge pull request 'Ensured appsettings aligns with k8s config' (#36) from mock-data into main
Reviewed-on: #36
2026-06-01 16:37:57 +02:00
khwezi 2b6576de85 Merge pull request 'Refactored app k8s manifest' (#35) from mock-data into main
Reviewed-on: #35
2026-06-01 11:16:54 +02:00
khwezi 7de957ed6f Merge pull request 'Refactored manifest to include s3 bucket and HasherService configs and secrets' (#34) from mock-data into main
Reviewed-on: #34
2026-06-01 09:50:59 +02:00
khwezi 16fdcc8005 Merge pull request 'Authors now showing on the listing' (#31) from mock-data into main
Reviewed-on: #31
2026-05-30 22:23:09 +02:00
khwezi a614d14da5 Merge pull request 'mock-data' (#30) from mock-data into main
Reviewed-on: #30
2026-05-30 21:01:05 +02:00
khwezi b722ea2cd0 Merge pull request 'mock-data' (#29) from mock-data into main
Reviewed-on: #29
2026-05-30 19:06:49 +02:00
khwezi 73145fd360 Merge pull request 'Upgraded backend services' (#28) from ui-design into main
Reviewed-on: #28
2026-05-30 00:32:23 +02:00
khwezi 7f29680993 Merge pull request 'ui-design' (#18) from ui-design into main
Reviewed-on: #18
2026-05-24 11:30:13 +02:00
khwezi 56626d2693 Merge pull request 'ui-design' (#17) from ui-design into main
Reviewed-on: #17
2026-05-24 10:48:55 +02:00
khwezi f9f6788c79 Merge pull request 'Updated UAT url' (#16) from project-setup into main
Reviewed-on: #16
2026-05-23 12:17:28 +02:00
khwezi 249ad319d9 Merge pull request 'Fixed dependencies and config' (#15) from project-setup into main
Reviewed-on: #15
2026-05-23 12:06:13 +02:00
khwezi 4a476febf4 Merge pull request 'Basic project setup' (#14) from project-setup into main
Reviewed-on: #14
2026-05-23 10:56:41 +02:00
112 changed files with 63049 additions and 8675 deletions
-4
View File
@@ -31,10 +31,6 @@ steps:
registry: nexus.khongisa.co.za
repo: nexus.khongisa.co.za/midrandbooks
tags: [ latest, "1.${DRONE_BUILD_NUMBER}" ]
use_cache: true
cache_from: nexus.khongisa.co.za/midrandbooks:latest
build_args:
- BUILDKIT_INLINE_CACHE=1
custom_labels:
- org.opencontainers.image.source=https://gitea.khongisa.co.za/litecharms/midrandbooks
- org.opencontainers.image.version=1.${DRONE_BUILD_NUMBER}
+1 -2
View File
@@ -1,10 +1,9 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".drone.yml" />
<File Path=".editorconfig" />
<File Path="Dockerfile" />
<File Path="midrandbooks-uat.yml" />
<File Path="README.md" />
</Folder>
<Project Path="MidrandBooks/MidrandBooks.csproj" />
<Project Path="MidrandBookshop/MidrandBookshop.csproj" />
</Solution>
@@ -1,95 +0,0 @@
@namespace MidrandBooks.Components
<style>
.mesh-bg {
background-image: radial-gradient(at 0% 0%, rgba(245, 158, 11, 0.08) 0, transparent 60%),
radial-gradient(at 50% 0%, rgba(20, 184, 166, 0.06) 0, transparent 60%),
radial-gradient(at 100% 0%, rgba(249, 115, 22, 0.08) 0, transparent 60%);
background-color: #030712;
}
@@keyframes float-up {
0% {
transform: translateY(100vh) rotate(0deg);
opacity: 0;
}
10% {
opacity: var(--float-opacity, 0.2);
}
90% {
opacity: var(--float-opacity, 0.2);
}
100% {
transform: translateY(-20vh) rotate(360deg);
opacity: 0;
}
}
</style>
<div id="animated-bg" class="fixed inset-0 -z-50 overflow-hidden mesh-bg">
<!-- Abstract Elegant Soft Warm Gradients -->
<div class="absolute -top-[30%] -left-[10%] h-[80%] w-[60%] rounded-full bg-amber-500/5 blur-[120px] animate-pulse"
style="animation-duration: 14s;" />
<div class="absolute -bottom-[20%] -right-[5%] h-[80%] w-[50%] rounded-full bg-sky-500/4 blur-[150px] animate-pulse"
style="animation-duration: 20s;" />
<div class="absolute top-[40%] left-[30%] h-[300px] w-[300px] rounded-full bg-orange-400/4 blur-[100px] animate-pulse"
style="animation-duration: 16s;" />
<!-- Grid Pattern Overlay -->
<div class="absolute inset-0 opacity-[0.4]"
style="background-image: radial-gradient(rgba(0, 0, 0, 0.04) 1px, transparent 1px); background-size: 24px 24px;" />
<!-- Floating Particles/Pages -->
<div class="absolute inset-0 pointer-events-none overflow-hidden">
@for (int i = 0; i < PageCount; i++)
{
var config = PageConfigs[i];
<div class="absolute rounded-lg border border-slate-900/10 bg-white/70 backdrop-blur-[1px] transition-transform duration-1000 ease-out shadow-sm"
style="width: @(config.Size)px;
height: @(config.Size * 1.3)px;
left: @(config.Left)%;
bottom: -100px;
opacity: @(config.Opacity);
transform: rotate(@(config.Rotation)deg);
animation: float-up @(config.Duration)s linear infinite;
animation-delay: @(config.Delay)s;">
<!-- Micro lines inside floating "pages" to represent book texture -->
<div class="w-4/5 h-[1.5px] bg-slate-900/10 my-2 mx-auto rounded" />
<div class="w-3/5 h-[1.5px] bg-slate-900/10 my-1 mx-auto rounded" />
<div class="w-1/2 h-[1.5px] bg-slate-900/10 my-1 mx-auto rounded" />
</div>
}
</div>
</div>
@code {
private const int PageCount = 10;
private List<PageConfig> PageConfigs { get; set; } = new();
protected override void OnInitialized()
{
var random = new Random(42); // Seeded random for consistent luxury aesthetic
for (int i = 0; i < PageCount; i++)
{
PageConfigs.Add(new PageConfig
{
Size = random.NextDouble() * 30 + 15,
Left = random.NextDouble() * 100,
Delay = random.NextDouble() * 15,
Duration = random.NextDouble() * 25 + 35,
Opacity = random.NextDouble() * 0.12 + 0.04,
Rotation = random.NextDouble() * 360
});
}
}
private class PageConfig
{
public double Size { get; set; }
public double Left { get; set; }
public double Delay { get; set; }
public double Duration { get; set; }
public double Opacity { get; set; }
public double Rotation { get; set; }
}
}
-57
View File
@@ -1,57 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<!-- Primary SEO Meta Tags -->
<title>Midrand Books | South African Independent Book Publishing &amp; Digital Library</title>
<meta name="description" content="Discover, read, and publish books at Midrand Books. Owned and operated by LiteCharms (PTY) Ltd, we provide an independent portal for local and international ebooks, audiobooks, and South African authors with POPIA-compliant data security." />
<meta name="keywords" content="Midrand Books, LiteCharms, South African books, South African independent authors, ebooks South Africa, publish books online, digital library, POPIA compliant bookshop, PayFast books" />
<meta name="author" content="LiteCharms (PTY) Ltd" />
<meta name="robots" content="index, follow" />
<!-- Answer Engine Optimization (AEO) Meta Tags -->
<meta name="application-name" content="Midrand Books" />
<meta name="theme-color" content="#0f172a" />
<meta name="geo.region" content="ZA-GP" />
<meta name="geo.placename" content="Midrand, Gauteng" />
<meta name="geo.position" content="-25.9983;28.1262" />
<meta name="ICBM" content="-25.9983, 28.1262" />
<!-- Open Graph / Facebook (SEO/AEO rich previews) -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Midrand Books - South African Independent Book Publishing &amp; Digital Library" />
<meta property="og:description" content="Discover and read modern literature. Powered by LiteCharms (PTY) Ltd, supporting local South African authors with safe electronic checkout and reading interfaces." />
<meta property="og:url" content="https://www.midrandbooks.co.za" />
<meta property="og:site_name" content="Midrand Books" />
<meta property="og:image" content="https://www.midrandbooks.co.za/assets/og-image.png" />
<meta property="og:locale" content="en_ZA" />
<!-- Canonical URL -->
<link rel="canonical" href="https://www.midrandbooks.co.za/" />
<!-- AEO Knowledge Graph: JSON-LD Structured Data Schema.org Referenced Externally to Prevent Razor C# Parsing Issues -->
<script type="application/ld+json" src="structured-data.json"></script>
<ResourcePreloader />
<link rel="stylesheet" href="app.min.css" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</body>
</html>
@@ -1,308 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
@if (Book != null)
{
<div class="fixed bottom-0 left-0 right-0 z-50 p-4 transition-all duration-300">
<div class="max-w-4xl mx-auto rounded-3xl border border-slate-800/40 bg-slate-900 shadow-2xl p-5 text-white relative overflow-hidden transition-all duration-300 @(IsMinimized ? "max-h-[85px]" : "max-h-[500px]")">
<!-- Atmospheric ambient blur background -->
<div class="absolute inset-0 bg-gradient-to-tr @Book.CoverColor opacity-10 blur-xl scale-125 pointer-events-none -z-10" />
<!-- Minimized View Layout -->
<div class="flex items-center justify-between gap-4">
<!-- Jacket and Title -->
<div class="flex items-center gap-3 min-w-0">
<div class="h-12 w-9 rounded overflow-hidden shadow-md flex-shrink-0 border border-white/5">
@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 flex items-center justify-center">
<span class="text-[5px] text-white/50">AUDIO</span>
</div>
}
</div>
<div class="min-w-0">
<span class="text-[9px] font-mono font-bold text-violet-400 block uppercase tracking-wider">Now Playing Audiobook</span>
<h4 class="text-xs font-serif font-bold text-white truncate leading-snug">@Book.Title</h4>
<span class="text-[9px] text-slate-400 truncate block">By @Book.Author</span>
</div>
</div>
<!-- Central Mini Controls -->
<div class="flex items-center gap-3">
<button @onclick="TogglePlay" class="h-9 w-9 rounded-full bg-amber-400 text-slate-950 flex items-center justify-center hover:bg-amber-300 hover:scale-105 active:scale-95 transition-all cursor-pointer">
@if (IsPlaying)
{
<svg class="h-4 w-4 fill-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
}
else
{
<svg class="h-4 w-4 fill-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
}
</button>
<button @onclick="ToggleMinimize" class="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer">
@if (IsMinimized)
{
<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="M5 15l7-7 7 7" />
</svg>
}
else
{
<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="M19 9l-7 7-7-7" />
</svg>
}
</button>
<button @onclick="OnClose" class="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-red-400 transition-colors cursor-pointer">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Full Expanded Dashboard -->
@if (!IsMinimized)
{
<div class="mt-6 border-t border-white/5 pt-5 grid grid-cols-1 md:grid-cols-12 gap-6 animate-in slide-in-from-bottom duration-200">
<!-- Sleep timer & Speed Controls (4 cols) -->
<div class="md:col-span-4 bg-white/2 rounded-2xl p-4 border border-white/5 space-y-4">
<!-- Playback Speed -->
<div class="space-y-2">
<label class="text-[10px] font-mono font-bold uppercase text-slate-400 tracking-wider">Playback Speed</label>
<div class="grid grid-cols-4 gap-1.5 p-1 bg-white/5 rounded-xl">
@foreach (var spd in Speeds)
{
<button @onclick="() => SetSpeed(spd)" class="py-1 rounded-lg text-[10px] font-bold font-mono transition-all cursor-pointer @(PlaybackSpeed == spd ? "bg-amber-400 text-slate-950" : "text-slate-400 hover:text-white")">
@(spd)x
</button>
}
</div>
</div>
<!-- Sleep Timer -->
<div class="space-y-2">
<label class="text-[10px] font-mono font-bold uppercase text-slate-400 tracking-wider flex justify-between">
<span>Sleep Timer</span>
@if (SleepTimerValue != null)
{
<span class="text-amber-400 font-bold">@FormatSeconds(TimeLeft) Left</span>
}
</label>
<div class="grid grid-cols-4 gap-1.5 p-1 bg-white/5 rounded-xl">
<button @onclick="ClearSleepTimer" class="py-1 rounded-lg text-[10px] font-mono transition-all cursor-pointer @(SleepTimerValue == null ? "bg-white/10 text-white" : "text-slate-400")">Off</button>
<button @onclick="() => SetSleepTimer(15)" class="py-1 rounded-lg text-[10px] font-mono transition-all cursor-pointer @(SleepTimerValue == 15 ? "bg-amber-400 text-slate-950" : "text-slate-400")">15m</button>
<button @onclick="() => SetSleepTimer(30)" class="py-1 rounded-lg text-[10px] font-mono transition-all cursor-pointer @(SleepTimerValue == 30 ? "bg-amber-400 text-slate-950" : "text-slate-400")">30m</button>
<button @onclick="() => SetSleepTimer(60)" class="py-1 rounded-lg text-[10px] font-mono transition-all cursor-pointer @(SleepTimerValue == 60 ? "bg-amber-400 text-slate-950" : "text-slate-400")">60m</button>
</div>
</div>
</div>
<!-- Main progress seeker & volume controls (8 cols) -->
<div class="md:col-span-8 flex flex-col justify-between space-y-4">
<!-- Seek Bar -->
<div class="space-y-1">
<input type="range" min="0" max="@Duration" value="@Progress" @oninput="SeekChanged" class="w-full h-1.5 bg-slate-700 accent-amber-500 rounded-lg appearance-none cursor-pointer transition-colors" />
<div class="flex justify-between text-[10px] font-mono text-slate-400">
<span>@FormatSeconds(Progress)</span>
<span>@FormatSeconds(Duration)</span>
</div>
</div>
<!-- Central Media Controls Rail -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button @onclick="SkipBack" class="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all cursor-pointer" title="Rewind 15 seconds">
<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="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" />
</svg>
</button>
<button @onclick="TogglePlay" class="h-12 w-12 rounded-full bg-amber-400 text-slate-950 flex items-center justify-center hover:bg-amber-300 hover:scale-105 active:scale-95 transition-all shadow-lg cursor-pointer">
@if (IsPlaying)
{
<svg class="h-5 w-5 fill-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
}
else
{
<svg class="h-5 w-5 fill-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
}
</button>
<button @onclick="SkipForward" class="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white transition-all cursor-pointer" title="Skip 15 seconds">
<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="M11.934 12.8a1 1 0 000-1.6l-5.334-4A1 1 0 005 8v8a1 1 0 001.6.8l5.334-4zM19.934 12.8a1 1 0 000-1.6l-5.334-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.334-4z" />
</svg>
</button>
</div>
<!-- Volume Panel -->
<div class="flex items-center gap-2">
<button @onclick="ToggleMute" class="p-2 rounded-xl bg-white/5 hover:bg-white/10 cursor-pointer">
@if (IsMuted || Volume == 0)
{
<svg class="h-4.5 w-4.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
}
else
{
<svg class="h-4.5 w-4.5 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
}
</button>
<input type="range" min="0" max="1" step="0.05" @bind="Volume" class="w-20 h-1.5 bg-slate-700 accent-amber-500 rounded-lg appearance-none cursor-pointer transition-colors" />
</div>
</div>
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter] public BookModel? Book { get; set; }
[Parameter] public bool IsPlaying { get; set; }
[Parameter] public EventCallback<bool> OnPlayToggle { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
private bool IsMinimized { get; set; } = false;
private double PlaybackSpeed { get; set; } = 1.0;
private double[] Speeds = new double[] { 0.75, 1.0, 1.25, 1.5 };
private double Volume { get; set; } = 0.8;
private bool IsMuted { get; set; } = false;
private double PreviousVolume { get; set; } = 0.8;
private double Progress { get; set; } = 120; // Simulated seconds played
private double Duration { get; set; } = 3600; // Simulated total seconds (1 hour)
// Sleep Timer States
private int? SleepTimerValue { get; set; }
private int TimeLeft { get; set; }
private System.Threading.Timer? SleepEngineTimer { get; set; }
private void ToggleMinimize()
{
IsMinimized = !IsMinimized;
}
private void TogglePlay()
{
IsPlaying = !IsPlaying;
OnPlayToggle.InvokeAsync(IsPlaying);
}
private void SetSpeed(double speed)
{
PlaybackSpeed = speed;
}
private void ToggleMute()
{
if (IsMuted)
{
Volume = PreviousVolume;
IsMuted = false;
}
else
{
PreviousVolume = Volume;
Volume = 0;
IsMuted = true;
}
}
private void SeekChanged(ChangeEventArgs e)
{
if (double.TryParse(e.Value?.ToString(), out var seekVal))
{
Progress = seekVal;
}
}
private void SkipForward()
{
Progress = Math.Min(Duration, Progress + 15);
}
private void SkipBack()
{
Progress = Math.Max(0, Progress - 15);
}
private void SetSleepTimer(int minutes)
{
SleepTimerValue = minutes;
TimeLeft = minutes * 60;
SleepEngineTimer?.Dispose();
SleepEngineTimer = new System.Threading.Timer(_ =>
{
InvokeAsync(() =>
{
if (TimeLeft > 1)
{
TimeLeft--;
}
else
{
IsPlaying = false;
OnPlayToggle.InvokeAsync(false);
SleepTimerValue = null;
SleepEngineTimer?.Dispose();
}
StateHasChanged();
});
}, null, 1000, 1000);
}
private void ClearSleepTimer()
{
SleepTimerValue = null;
SleepEngineTimer?.Dispose();
}
private string FormatSeconds(double totalSecs)
{
var hours = Math.Floor(totalSecs / 3600);
var minutes = Math.Floor((totalSecs % 3600) / 60);
var seconds = Math.Floor(totalSecs % 60);
var formattedSecs = seconds < 10 ? $"0{seconds}" : seconds.ToString();
if (hours > 0)
{
var formattedMins = minutes < 10 ? $"0{minutes}" : minutes.ToString();
return $"{hours}:{formattedMins}:{formattedSecs}";
}
return $"{minutes}:{formattedSecs}";
}
public void Dispose()
{
SleepEngineTimer?.Dispose();
}
}
-80
View File
@@ -1,80 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<div id="book-card-@Book.Id" class="group relative flex flex-col justify-between rounded-2xl border border-slate-200/60 bg-white p-3 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5">
<!-- Cover Artwork Block -->
<div class="relative aspect-[3/4] rounded-xl overflow-hidden shadow-sm bg-slate-50 cursor-pointer" @onclick="HandleSelect">
@if (!string.IsNullOrEmpty(Book.CoverUrl))
{
<img src="@Book.CoverUrl"
alt="@Book.Title"
referrerpolicy="no-referrer"
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-4 flex flex-col justify-between text-white transition-transform duration-500 group-hover:scale-105">
<div class="absolute top-0 right-0 h-16 w-16 bg-white/5 rounded-full blur-sm pointer-events-none" />
<span class="text-[8px] font-mono font-bold tracking-widest block uppercase text-white/40">@Book.Category</span>
<span class="text-sm font-serif font-extrabold line-clamp-3 leading-tight text-white">@Book.Title</span>
<span class="text-[9px] font-sans text-white/70 truncate block font-semibold">By @Book.Author</span>
</div>
}
<!-- Book Spine Shadow -->
<div class="absolute top-0 bottom-0 left-0 w-2 bg-gradient-to-r from-black/25 to-transparent" />
<!-- Formats and Badges -->
<div class="absolute top-2 right-2 flex flex-col gap-1 items-end z-10">
@if (Book.Bestseller)
{
<span class="px-2 py-0.5 rounded-full text-[8px] font-mono font-extrabold uppercase bg-amber-500 text-slate-900 border border-amber-400 shadow-sm">
Bestseller
</span>
}
@if (Book.Trending)
{
<span class="px-2 py-0.5 rounded-full text-[8px] font-mono font-extrabold uppercase bg-violet-600 text-white border border-violet-500 shadow-sm">
Trending
</span>
}
</div>
</div>
<!-- Info Block -->
<div class="mt-3 flex-1 flex flex-col justify-between space-y-2">
<div>
<span class="text-[9px] font-mono font-bold text-violet-600 uppercase tracking-wider block">@Book.Category</span>
<h4 class="font-serif text-sm font-bold text-slate-800 leading-tight line-clamp-1 mt-0.5 hover:text-violet-600 cursor-pointer" @onclick="HandleSelect">
@Book.Title
</h4>
<span class="text-[10px] text-slate-500 font-medium">@Book.Author</span>
</div>
<div class="flex items-center justify-between border-t border-slate-100 pt-2">
<div>
<span class="text-[8px] font-mono font-bold text-slate-400 block uppercase">South African Price</span>
<span class="font-mono text-xs font-bold text-slate-900">R @Book.Price.ToString("F2")</span>
</div>
<div class="flex items-center gap-1.5">
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] font-mono font-bold border border-amber-200/50">
★ @Book.Rating.ToString("F1")
</span>
<span class="text-[9px] font-mono text-slate-400">(@Book.ReviewsCount)</span>
</div>
</div>
</div>
</div>
@code {
[Parameter] public BookModel Book { get; set; } = new();
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
private async Task HandleSelect()
{
await OnSelectBook.InvokeAsync(Book);
}
}
@@ -1,280 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/60 backdrop-blur-sm">
<div class="bg-white rounded-3xl border border-slate-200 shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto relative p-6 sm:p-8 animate-in zoom-in-95 duration-200">
<!-- Close Button -->
<button @onclick="OnClose" class="absolute top-4 right-4 p-2 rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="grid grid-cols-1 md:grid-cols-12 gap-8 mt-4">
<!-- Left Cover Art & Quick Stats (4 cols) -->
<div class="md:col-span-4 flex flex-col items-center md:items-stretch">
<div class="relative h-72 w-52 md:h-84 md:w-full rounded-2xl overflow-hidden shadow-xl border border-slate-200 mx-auto bg-slate-50">
@if (!string.IsNullOrEmpty(Book.CoverUrl))
{
<img src="@Book.CoverUrl" alt="@Book.Title" referrerpolicy="no-referrer" class="h-full w-full object-cover" />
}
else
{
<div class="h-full w-full bg-gradient-to-br @(Book.CoverColor) p-6 flex flex-col justify-between text-white">
<div class="absolute top-0 right-0 h-24 w-24 bg-white/5 rounded-full blur-md pointer-events-none" />
<span class="text-xs font-mono font-bold tracking-widest block uppercase text-white/40">@Book.Category</span>
<span class="text-xl font-serif font-extrabold line-clamp-4 leading-tight text-white">@Book.Title</span>
<span class="text-xs font-sans text-white/70 truncate block font-semibold">By @Book.Author</span>
</div>
}
<div class="absolute top-0 bottom-0 left-0 w-3 bg-gradient-to-r from-black/20 to-transparent" />
</div>
<!-- Quick Stats Grid -->
<div class="grid grid-cols-2 gap-3 mt-6 w-full max-w-xs md:max-w-none">
<div class="p-3 rounded-xl border border-slate-100 bg-white shadow-sm flex flex-col items-center justify-center text-center">
<svg class="h-4 w-4 text-slate-600 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-[10px] text-slate-400 font-mono">PUBLISHED</span>
<span class="text-xs font-semibold text-slate-800 mt-0.5">@Book.PublishedYear</span>
</div>
<div class="p-3 rounded-xl border border-slate-100 bg-white shadow-sm flex flex-col items-center justify-center text-center">
<svg class="h-4 w-4 text-slate-600 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span class="text-[10px] text-slate-400 font-mono">PAGES</span>
<span class="text-xs font-semibold text-slate-800 mt-0.5">@(Book.PagesCount?.ToString() ?? "N/A")</span>
</div>
<div class="p-3 rounded-xl border border-slate-100 bg-white shadow-sm flex flex-col items-center justify-center text-center">
<svg class="h-4 w-4 text-slate-600 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-[10px] text-slate-400 font-mono">AUDIO TIME</span>
<span class="text-xs font-semibold text-slate-800 mt-0.5 truncate w-full">@(Book.AudioDuration ?? "N/A")</span>
</div>
<div class="p-3 rounded-xl border border-slate-100 bg-white shadow-sm flex flex-col items-center justify-center text-center">
<svg class="h-4 w-4 text-slate-600 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-[10px] text-slate-400 font-mono">NARRATOR</span>
<span class="text-[11px] font-semibold text-slate-800 mt-0.5 truncate w-full" title="@Book.Narrator">
@(Book.Narrator ?? "None")
</span>
</div>
</div>
</div>
<!-- Right Details Section (8 cols) -->
<div class="md:col-span-8 flex flex-col h-full">
<div class="mb-4">
<span class="text-[10px] font-mono tracking-wider font-semibold px-2.5 py-1 rounded bg-slate-100 text-slate-600 border border-slate-200">
@Book.Category.ToUpper()
</span>
</div>
<h2 class="font-serif text-2xl sm:text-3xl font-bold text-slate-800 leading-tight mb-2">
@Book.Title
</h2>
<p class="text-sm text-slate-500 mb-4">
By <span class="font-semibold text-slate-700">@Book.Author</span>
</p>
<div class="flex items-center gap-3 border-b border-slate-100 pb-4 mb-4">
<div class="flex items-center text-amber-400">
@for (int i = 0; i < 5; i++)
{
<svg class="h-4 w-4 @(i < Math.Floor(Book.Rating) ? "fill-amber-400 text-amber-400" : "text-slate-200")" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
}
</div>
<span class="text-xs text-slate-700 font-semibold">@Book.Rating.ToString("F1") Rating</span>
<span class="text-slate-300">•</span>
<span class="text-xs text-slate-500">(@Book.ReviewsCount customer reviews)</span>
</div>
<!-- Description/Overview -->
<div class="flex-1">
<h4 class="text-xs font-mono text-slate-400 tracking-wider uppercase mb-2 font-bold">Overview</h4>
<p class="text-sm text-slate-600 leading-relaxed mb-6 font-light">
@Book.Description
</p>
</div>
<!-- Interactive Rating Section -->
<div class="p-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/50 mb-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="text-center sm:text-left">
<h5 class="text-xs font-mono font-bold text-slate-600 uppercase tracking-wider">Have you read or listened to this?</h5>
<p class="text-[11px] text-slate-400 mt-0.5">
@if (UserRating.HasValue)
{
<span>You rated this title @UserRating.Value star@(UserRating.Value > 1 ? "s" : ""). Thank you!</span>
}
else
{
<span>Tap a star to rate this title instantly.</span>
}
</p>
</div>
<div class="flex items-center gap-1.5 bg-white px-3.5 py-1.5 rounded-full shadow-sm border border-slate-100">
@for (int starValue = 1; starValue <= 5; starValue++)
{
var currentStar = starValue;
bool isHovered = hoverRating.HasValue && currentStar <= hoverRating.Value;
bool isSelected = UserRating.HasValue && currentStar <= UserRating.Value;
bool isActive = hoverRating.HasValue ? isHovered : isSelected;
<button
type="button"
@onmouseenter="() => hoverRating = currentStar"
@onmouseleave="() => hoverRating = null"
@onclick="() => RateBook(currentStar)"
class="p-1 text-slate-200 hover:scale-125 hover:text-amber-400 active:scale-95 transition-all cursor-pointer focus:outline-none"
aria-label="Rate @currentStar stars"
>
<svg class="h-5 w-5 transition-colors @(isActive ? "fill-amber-400 text-amber-400" : "text-slate-300")" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
</button>
}
@if (UserRating.HasValue)
{
<span class="text-xs font-mono font-bold text-amber-600 bg-amber-50 px-2 py-0.5 rounded ml-1">
@UserRating.Value/5
</span>
}
</div>
</div>
<!-- Purchase / Format Rail -->
<div class="space-y-4">
<h4 class="text-xs font-mono text-slate-400 tracking-wider uppercase font-bold">Purchase &amp; Access Formats</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Ebook Card -->
<div class="p-4 rounded-2xl border border-slate-200 bg-white shadow-sm hover:border-slate-300 transition-all flex flex-col justify-between min-h-[140px]">
<div>
<span class="flex items-center gap-1 text-xs font-bold text-indigo-600 font-mono tracking-wide">
<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="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>
PROPRIETARY EBOOK
</span>
<p class="text-[11px] text-slate-400 mt-1">Read on any device in our premium reader. Offline support.</p>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
@if (IsEbookOwned)
{
<div class="w-full space-y-2">
<div class="flex items-center justify-between">
<span class="text-[11px] text-emerald-600 flex items-center gap-1 font-semibold">
✓ Owned
</span>
@if (IsEbookCached)
{
<span class="text-[10px] text-indigo-600 flex items-center gap-0.5 font-mono font-semibold">
✓ Cached
</span>
}
</div>
<div class="flex gap-2">
<button @onclick="ReadEbook" class="flex-1 py-2 text-center text-xs font-bold rounded-xl bg-slate-900 hover:bg-slate-800 text-white shadow-sm transition-colors flex items-center justify-center gap-1 cursor-pointer">
Read Now
</button>
</div>
</div>
}
else
{
<span class="font-mono text-sm font-semibold text-slate-800">R @Book.Price.ToString("F2")</span>
<button @onclick='() => BuyBook("ebook")' disabled="@EbookInCart" class="px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer @(EbookInCart ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-slate-900 hover:bg-slate-800 text-white shadow-sm")">
@(EbookInCart ? "In Cart" : "Buy Ebook")
</button>
}
</div>
</div>
<!-- Audiobook Card -->
<div class="p-4 rounded-2xl border border-slate-200 bg-white shadow-sm hover:border-slate-300 transition-all flex flex-col justify-between min-h-[140px]">
<div>
<span class="flex items-center gap-1 text-xs font-bold text-violet-600 font-mono tracking-wide">
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
STUDIO AUDIOBOOK
</span>
<p class="text-[11px] text-slate-400 mt-1">High fidelity streaming or offline cached audio player.</p>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
@if (IsAudioOwned)
{
<button @onclick="PlayAudiobook" class="w-full py-2 text-center text-xs font-bold rounded-xl bg-amber-400 hover:bg-amber-300 text-slate-950 shadow-sm transition-colors flex items-center justify-center gap-1.5 cursor-pointer">
Play Audiobook
</button>
}
else
{
<span class="font-mono text-sm font-semibold text-slate-800">R @((Book.Price * 1.25).ToString("F2"))</span>
<button @onclick='() => BuyBook("audiobook")' disabled="@AudioInCart" class="px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer @(AudioInCart ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-slate-900 hover:bg-slate-800 text-white shadow-sm")">
@(AudioInCart ? "In Cart" : "Buy Audiobook")
</button>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@code {
[Parameter] public BookModel Book { get; set; } = new();
[Parameter] public bool IsEbookOwned { get; set; }
[Parameter] public bool IsEbookCached { get; set; }
[Parameter] public bool IsAudioOwned { get; set; }
[Parameter] public bool EbookInCart { get; set; }
[Parameter] public bool AudioInCart { get; set; }
[Parameter] public int? UserRating { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback<string> OnAddToCart { get; set; }
[Parameter] public EventCallback OnReadEbook { get; set; }
[Parameter] public EventCallback OnPlayAudiobook { get; set; }
[Parameter] public EventCallback<int> OnRateBook { get; set; }
private int? hoverRating = null;
private void BuyBook(string format)
{
OnAddToCart.InvokeAsync(format);
}
private void ReadEbook()
{
OnReadEbook.InvokeAsync();
}
private void PlayAudiobook()
{
OnPlayAudiobook.InvokeAsync();
}
private void RateBook(int rating)
{
OnRateBook.InvokeAsync(rating);
}
}
-115
View File
@@ -1,115 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<style>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<div id="razor-book-grid" class="max-w-7xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<!-- Filters Rail -->
<div class="flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between border-b border-slate-200/50 pb-5">
<!-- Search and Selection info -->
<div class="flex-1 max-w-md relative">
<span class="absolute inset-y-0 left-3 flex items-center text-slate-400">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="text"
placeholder="Search manuscripts, authors, titles..."
value="@SearchQuery"
@oninput="HandleSearchInput"
class="w-full rounded-xl border border-slate-200 bg-white pl-9 pr-4 py-2 text-xs text-slate-800 placeholder-slate-400 outline-none focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all font-sans"
/>
</div>
<!-- Horizontal Scrollable Genre Filters -->
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full no-scrollbar">
<button
@onclick='() => SelectCategory("All")'
class="px-3.5 py-1.5 rounded-full text-[10px] font-mono font-bold tracking-wider uppercase transition-all whitespace-nowrap cursor-pointer border @(SelectedCategory == "All" ? "bg-violet-600 border-violet-600 text-white" : "bg-white text-slate-600 border-slate-200/80 hover:bg-slate-50")">
All Genres
</button>
@foreach (var cat in Categories)
{
<button
@onclick="() => SelectCategory(cat)"
class="px-3.5 py-1.5 rounded-full text-[10px] font-mono font-bold tracking-wider uppercase transition-all whitespace-nowrap cursor-pointer border @(SelectedCategory == cat ? "bg-violet-600 border-violet-600 text-white" : "bg-white text-slate-600 border-slate-200/80 hover:bg-slate-50")">
@cat
</button>
}
</div>
</div>
<!-- Active Catalog Grid -->
@if (FilteredBooks().Any())
{
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 sm:gap-6">
@foreach (var book in FilteredBooks())
{
<BookCard Book="book" OnSelectBook="OnSelectBook" />
}
</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 active publications match your search</h4>
<p class="text-[10px] text-slate-500 font-sans max-w-sm mx-auto leading-relaxed">
Try resetting your filters or browsing with different keywords. Direct self-published uploads require moderation prior to live activation.
</p>
</div>
}
</div>
@code {
[Parameter] public List<BookModel> Books { get; set; } = new();
[Parameter] public List<string> Categories { get; set; } = new();
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
private string SelectedCategory { get; set; } = "All";
private string SearchQuery { get; set; } = string.Empty;
private void SelectCategory(string category)
{
SelectedCategory = category;
}
private void HandleSearchInput(ChangeEventArgs e)
{
SearchQuery = e.Value?.ToString() ?? string.Empty;
}
private IEnumerable<BookModel> FilteredBooks()
{
var result = Books.Where(b => b.Status == "published");
if (SelectedCategory != "All")
{
result = result.Where(b => b.Category.Equals(SelectedCategory, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
var query = SearchQuery.Trim().ToLower();
result = result.Where(b =>
b.Title.ToLower().Contains(query) ||
b.Author.ToLower().Contains(query) ||
b.Description.ToLower().Contains(query)
);
}
return result;
}
}
@@ -1,370 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
@if (IsOpen)
{
<div class="fixed inset-0 z-50 overflow-hidden bg-slate-950/60 backdrop-blur-sm flex justify-end">
<!-- Cart Drawer Panel -->
<div class="w-full max-w-md bg-white border-l border-slate-200 h-full shadow-2xl flex flex-col justify-between relative animate-in slide-in-from-right duration-200">
<!-- Header -->
<header class="p-5 border-b border-slate-100 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-slate-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h3 class="font-serif text-lg font-bold text-slate-800">Secure Checkout Gate</h3>
</div>
<button @onclick="OnClose" class="p-2 rounded-xl hover:bg-slate-50 text-slate-400 hover:text-slate-600 cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<!-- Main Content Scroll Area -->
<div class="flex-1 overflow-y-auto p-5 space-y-6">
@if (CurrentStep == "cart")
{
<!-- Step 1: Cart Listing -->
@if (CartItems.Count > 0)
{
<div class="space-y-4">
@foreach (var item in CartItems)
{
<div class="flex gap-3.5 p-3 rounded-2xl border border-slate-100 bg-slate-50/50">
<div class="h-16 w-11 rounded overflow-hidden shadow-sm flex-shrink-0 border border-slate-100 bg-slate-200">
@if (!string.IsNullOrEmpty(item.Book.CoverUrl))
{
<img src="@item.Book.CoverUrl" alt="@item.Book.Title" class="h-full w-full object-cover" />
}
else
{
<div class="h-full w-full bg-gradient-to-br @item.Book.CoverColor flex items-center justify-center">
<span class="text-[5px] text-white/50">BOOK</span>
</div>
}
</div>
<div class="flex-1 min-w-0">
<h4 class="text-xs font-serif font-bold text-slate-800 truncate">@item.Book.Title</h4>
<p class="text-[10px] text-slate-400">By @item.Book.Author</p>
<span class="inline-block mt-1 text-[8px] font-mono font-bold uppercase rounded px-1.5 py-0.5 @(item.Format == "ebook" ? "bg-indigo-50 text-indigo-700" : "bg-violet-50 text-violet-700")">
@item.Format
</span>
</div>
<div class="text-right flex flex-col justify-between items-end">
<button @onclick="() => RemoveItem(item)" class="p-1.5 text-slate-400 hover:text-red-500 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span class="font-mono text-xs font-bold text-slate-800">R @item.Price.ToString("F2")</span>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-16 space-y-3">
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h4 class="font-serif text-sm font-bold text-slate-700">Your shopping cart is empty</h4>
<p class="text-[10px] text-slate-400 max-w-xs mx-auto leading-relaxed">
Browse our catalogs and add premium South African manuscripts or immersive studio audiobooks to check out.
</p>
</div>
}
}
else if (CurrentStep == "details")
{
<!-- Step 2: PayFast Details Form -->
<div class="space-y-4">
<div class="space-y-1">
<span class="text-[9px] font-mono font-bold text-violet-600 uppercase tracking-widest block">Secure Payment Methods</span>
<h3 class="font-serif text-base font-bold text-slate-800">Complete South African Settlement</h3>
</div>
<!-- Payment Method Toggle -->
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
<button @onclick='() => SetPaymentMethod("card")' class="py-2 rounded-lg text-xs font-bold transition-all cursor-pointer @(PaymentMethod == "card" ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-800")">
Credit / Debit Card
</button>
<button @onclick='() => SetPaymentMethod("eft")' class="py-2 rounded-lg text-xs font-bold transition-all cursor-pointer @(PaymentMethod == "eft" ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-800")">
Secure Instant EFT
</button>
</div>
@if (PaymentMethod == "card")
{
<!-- Card Form -->
<div class="space-y-3.5 pt-2">
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">Cardholder Name</label>
<input type="text" placeholder="e.g. A K Mthembu" @bind="CardName" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">Card Number</label>
<input type="text" placeholder="xxxx xxxx xxxx xxxx" @bind="CardNumber" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">Expiry Date</label>
<input type="text" placeholder="MM/YY" @bind="CardExpiry" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">CVV</label>
<input type="text" placeholder="123" @bind="CardCvv" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
</div>
</div>
}
else
{
<!-- EFT Bank Form -->
<div class="space-y-3.5 pt-2">
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">Select SA Bank</label>
<select @bind="SelectedBank" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-800 outline-none">
<option value="Capitec">Capitec Bank</option>
<option value="Standard Bank">Standard Bank</option>
<option value="FNB">First National Bank (FNB)</option>
<option value="Nedbank">Nedbank</option>
<option value="Absa">Absa</option>
</select>
</div>
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">Online Banking Username</label>
<input type="text" placeholder="e.g. 10482901" @bind="EftUsername" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
<div class="space-y-1">
<label class="text-[9px] font-mono font-bold text-slate-500 uppercase block">PIN / Password</label>
<input type="password" placeholder="••••••••" @bind="EftPassword" class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
</div>
</div>
}
</div>
}
else if (CurrentStep == "processing")
{
<!-- Step 3: Secure Processing Sandbox -->
<div class="text-center py-16 space-y-6">
<div class="relative mx-auto h-16 w-16 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-4 border-violet-100 border-t-violet-600 animate-spin" />
<svg class="h-6 w-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
<div class="space-y-2">
<h4 class="font-serif text-sm font-bold text-slate-800">Processing Secure Transaction</h4>
<p class="text-[11px] text-slate-500 max-w-xs mx-auto leading-relaxed">
@ProcessingStatus
</p>
</div>
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div class="bg-violet-600 h-full transition-all duration-300" style="width: @(ProcessingProgress)%" />
</div>
</div>
}
else if (CurrentStep == "otp")
{
<!-- Step 4: OTP Screen -->
<div class="space-y-4 pt-4">
<div class="text-center space-y-2">
<div class="mx-auto h-12 w-12 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center border border-amber-100">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h4 class="font-serif text-sm font-bold text-slate-800">Dual-Factor Verification Required</h4>
<p class="text-[10px] text-slate-500 leading-relaxed max-w-xs mx-auto">
Enter the simulated SMS OTP code dispatched to secure checkout settlement: <strong class="text-slate-800 font-mono text-xs">@GeneratedOtp</strong>
</p>
</div>
<div class="space-y-2 max-w-xs mx-auto">
<input type="text" placeholder="e.g. 482903" @bind="OtpInput" class="w-full text-center tracking-[0.5em] font-mono text-lg rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15" />
@if (!string.IsNullOrEmpty(OtpError))
{
<span class="block text-center text-[10px] font-semibold text-red-500">@OtpError</span>
}
</div>
</div>
}
else if (CurrentStep == "success")
{
<!-- Step 5: Purchase Successful -->
<div class="text-center py-12 space-y-6">
<div class="mx-auto h-16 w-16 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100 flex items-center justify-center">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="space-y-2">
<h3 class="font-serif text-base font-bold text-slate-800">Authorization Passed</h3>
<p class="text-[11px] text-slate-500 leading-relaxed max-w-xs mx-auto">
Thank you! Your PayFast checkout transaction was approved. Your newly acquired digital books have been unlocked and compiled into your Secure Library.
</p>
</div>
</div>
}
</div>
<!-- Footer Billing Info & Action Button -->
<footer class="p-5 border-t border-slate-100 bg-slate-50/50 space-y-4">
@if (CurrentStep == "cart" || CurrentStep == "details")
{
<div class="space-y-2 text-xs">
<div class="flex justify-between text-slate-500">
<span>Subtotal (ZAR)</span>
<span class="font-mono font-medium">R @Subtotal().ToString("F2")</span>
</div>
<div class="flex justify-between text-slate-500">
<span>VAT (15% SA)</span>
<span class="font-mono font-medium">R @Vat().ToString("F2")</span>
</div>
<div class="flex justify-between font-serif text-sm font-bold text-slate-800 border-t border-slate-100 pt-2">
<span>Total Due</span>
<span class="font-mono">R @Total().ToString("F2")</span>
</div>
</div>
}
@if (CurrentStep == "cart")
{
<button @onclick="ProceedToDetails" disabled="@(CartItems.Count == 0)" class="w-full py-3.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-bold text-xs shadow-md cursor-pointer transition-all disabled:opacity-50 flex items-center justify-center gap-1.5">
Proceed to Checkout
</button>
}
else if (CurrentStep == "details")
{
<button @onclick="ProcessSecureCheckout" class="w-full py-3.5 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-1.5">
Authorize Payment
</button>
}
else if (CurrentStep == "otp")
{
<button @onclick="VerifyOtp" class="w-full py-3.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-bold text-xs shadow-md cursor-pointer transition-all flex items-center justify-center gap-1.5">
Submit OTP Code
</button>
}
else if (CurrentStep == "success")
{
<button @onclick="CloseAndFinish" class="w-full py-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs shadow-md cursor-pointer transition-all flex items-center justify-center gap-1.5">
Open My Library
</button>
}
</footer>
</div>
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public List<CartItem> CartItems { get; set; } = new();
[Parameter] public EventCallback<List<CartItem>> OnCheckoutSuccess { get; set; }
[Parameter] public EventCallback<CartItem> OnRemoveFromCart { get; set; }
private string CurrentStep { get; set; } = "cart"; // "cart", "details", "processing", "otp", "success"
private string PaymentMethod { get; set; } = "card"; // "card", "eft"
// Processing parameters
private string ProcessingStatus { get; set; } = string.Empty;
private int ProcessingProgress { get; set; } = 0;
// Form inputs
private string CardName { get; set; } = string.Empty;
private string CardNumber { get; set; } = string.Empty;
private string CardExpiry { get; set; } = string.Empty;
private string CardCvv { get; set; } = string.Empty;
private string SelectedBank { get; set; } = "Capitec";
private string EftUsername { get; set; } = string.Empty;
private string EftPassword { get; set; } = string.Empty;
// OTP Parameters
private string GeneratedOtp { get; set; } = "482903";
private string OtpInput { get; set; } = string.Empty;
private string OtpError { get; set; } = string.Empty;
private double Subtotal()
{
return CartItems.Sum(i => i.Price);
}
private double Vat()
{
return Subtotal() * 0.15;
}
private double Total()
{
return Subtotal(); // ZAR pricing includes VAT
}
private void ProceedToDetails()
{
CurrentStep = "details";
}
private void SetPaymentMethod(string method)
{
PaymentMethod = method;
}
private void RemoveItem(CartItem item)
{
OnRemoveFromCart.InvokeAsync(item);
}
private async Task ProcessSecureCheckout()
{
CurrentStep = "processing";
ProcessingProgress = 15;
ProcessingStatus = "Establishing secure proxy to South African gateway nodes...";
await Task.Delay(1000);
ProcessingProgress = 45;
ProcessingStatus = "Securing socket with SSL 256-bit encryption...";
await Task.Delay(1000);
ProcessingProgress = 75;
ProcessingStatus = "Contacting issuing merchant bank for authorization...";
await Task.Delay(1000);
ProcessingProgress = 90;
ProcessingStatus = "Spawning 3D Secure 2.0 dual-factor authentication challenge...";
await Task.Delay(800);
GeneratedOtp = new Random().Next(100000, 999999).ToString();
OtpError = string.Empty;
OtpInput = string.Empty;
CurrentStep = "otp";
}
private void VerifyOtp()
{
if (OtpInput.Trim() == GeneratedOtp)
{
CurrentStep = "success";
OnCheckoutSuccess.InvokeAsync(CartItems);
}
else
{
OtpError = "The authentication code is incorrect. Please check SMS dispatch.";
}
}
private void CloseAndFinish()
{
CurrentStep = "cart"; // reset step for next load
OnClose.InvokeAsync();
}
}
-222
View File
@@ -1,222 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<style>
.reader-text-serif {
font-family: "Playfair Display", serif;
}
.reader-text-sans {
font-family: "Poppins", sans-serif;
}
.reader-text-mono {
font-family: "JetBrains Mono", monospace;
}
</style>
<div class="fixed inset-0 z-50 flex flex-col h-screen overflow-hidden @ThemeBgClass()">
<!-- Header Controls -->
<header class="flex items-center justify-between px-6 py-4 border-b @HeaderBorderClass() transition-all">
<div class="flex items-center gap-4">
<button @onclick="OnClose" class="p-2 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 text-slate-500 hover:text-slate-800 dark:hover:text-slate-100 cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<div>
<h2 class="font-serif text-sm font-bold truncate max-w-xs sm:max-w-md">@Book.Title</h2>
<p class="text-[10px] text-slate-400 font-mono uppercase">By @Book.Author • Chapter Page @(CurrentPage + 1) of @PagesCount()</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Bookmark Button -->
<button @onclick="ToggleBookmark" class="p-2 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" title="Toggle Bookmark">
<svg class="h-5 w-5 @(IsBookmarked ? "text-amber-500 fill-amber-500" : "text-slate-400")" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z" />
</svg>
</button>
<!-- Reader Settings Button -->
<button @onclick="ToggleSettings" class="p-2 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 text-slate-500 hover:text-slate-800 dark:hover:text-slate-100 cursor-pointer" title="Reader Font / Theme Customization">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</header>
<!-- Reader Settings Overlay Panel -->
@if (ShowSettings)
{
<div class="p-6 border-b @HeaderBorderClass() bg-black/5 dark:bg-white/5 space-y-4 animate-in slide-in-from-top duration-200">
<div class="max-w-md mx-auto grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Theme Selectors -->
<div class="space-y-1.5">
<label class="text-[10px] font-mono font-bold uppercase block opacity-70">Theme</label>
<div class="flex bg-black/10 dark:bg-white/10 p-1 rounded-xl">
<button @onclick='() => SetTheme("sepia")' class="flex-1 py-1 text-xs font-bold rounded-lg @(Theme == "sepia" ? "bg-amber-100 text-amber-950" : "text-slate-500")">Sepia</button>
<button @onclick='() => SetTheme("dark")' class="flex-1 py-1 text-xs font-bold rounded-lg @(Theme == "dark" ? "bg-slate-800 text-white" : "text-slate-500")">Dark</button>
<button @onclick='() => SetTheme("light")' class="flex-1 py-1 text-xs font-bold rounded-lg @(Theme == "light" ? "bg-white text-slate-800" : "text-slate-500")">Light</button>
</div>
</div>
<!-- Font Selectors -->
<div class="space-y-1.5">
<label class="text-[10px] font-mono font-bold uppercase block opacity-70">Font Style</label>
<div class="flex bg-black/10 dark:bg-white/10 p-1 rounded-xl">
<button @onclick='() => SetFont("serif")' class="flex-1 py-1 text-xs font-bold font-serif rounded-lg @(Font == "serif" ? "bg-white/40 text-current" : "text-slate-500")">Serif</button>
<button @onclick='() => SetFont("sans")' class="flex-1 py-1 text-xs font-bold font-sans rounded-lg @(Font == "sans" ? "bg-white/40 text-current" : "text-slate-500")">Sans</button>
<button @onclick='() => SetFont("mono")' class="flex-1 py-1 text-xs font-bold font-mono rounded-lg @(Font == "mono" ? "bg-white/40 text-current" : "text-slate-500")">Mono</button>
</div>
</div>
<!-- Font Size Config -->
<div class="sm:col-span-2 space-y-1">
<div class="flex justify-between text-[10px] font-mono opacity-70">
<span>Font Size</span>
<span>@(FontSize)px</span>
</div>
<input type="range" min="12" max="24" @bind="FontSize" class="w-full h-1.5 bg-slate-300 dark:bg-slate-700 accent-amber-500 rounded-lg appearance-none cursor-pointer transition-colors" />
</div>
</div>
</div>
}
<!-- Reading Canvas Area -->
<div class="flex-1 overflow-y-auto px-6 py-12 flex justify-center">
<div class="max-w-2xl w-full leading-relaxed text-justify @FontClass() @TextContrastClass()" style="font-size: @(FontSize)px;">
@if (Book.FullText != null && Book.FullText.Count > CurrentPage)
{
<p class="whitespace-pre-wrap">@Book.FullText[CurrentPage]</p>
}
else
{
<p class="italic text-center py-12">
Digital copy protection sandbox active. Reflowable text formatting is loading.
</p>
}
</div>
</div>
<!-- Footer Controls -->
<footer class="px-6 py-4 border-t @HeaderBorderClass() flex items-center justify-between">
<button @onclick="PrevPage" class="px-4 py-2 rounded-xl bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-xs font-bold transition-all flex items-center gap-1 cursor-pointer">
Previous Page
</button>
<span class="text-xs font-mono font-semibold">Page @(CurrentPage + 1) of @PagesCount()</span>
<button @onclick="NextPage" class="px-4 py-2 rounded-xl bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-xs font-bold transition-all flex items-center gap-1 cursor-pointer">
Next Page
</button>
</footer>
</div>
@code {
[Parameter] public BookModel Book { get; set; } = new();
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public int CurrentPage { get; set; } = 0;
[Parameter] public EventCallback<int> OnPageChanged { get; set; }
private bool ShowSettings { get; set; } = false;
private string Theme { get; set; } = "sepia";
private string Font { get; set; } = "serif";
private int FontSize { get; set; } = 16;
private bool IsBookmarked { get; set; } = false;
private List<int> Bookmarks { get; set; } = new();
private void ToggleSettings()
{
ShowSettings = !ShowSettings;
}
private void ToggleBookmark()
{
if (Bookmarks.Contains(CurrentPage))
{
Bookmarks.Remove(CurrentPage);
IsBookmarked = false;
}
else
{
Bookmarks.Add(CurrentPage);
IsBookmarked = true;
}
}
private int PagesCount()
{
return Book.FullText?.Count ?? 1;
}
private void NextPage()
{
if (Book.FullText != null && CurrentPage < Book.FullText.Count - 1)
{
CurrentPage++;
IsBookmarked = Bookmarks.Contains(CurrentPage);
OnPageChanged.InvokeAsync(CurrentPage);
}
}
private void PrevPage()
{
if (CurrentPage > 0)
{
CurrentPage--;
IsBookmarked = Bookmarks.Contains(CurrentPage);
OnPageChanged.InvokeAsync(CurrentPage);
}
}
private void SetTheme(string theme)
{
Theme = theme;
}
private void SetFont(string font)
{
Font = font;
}
private string ThemeBgClass()
{
return Theme switch
{
"sepia" => "bg-amber-50 text-amber-950",
"dark" => "bg-slate-950 text-slate-100",
_ => "bg-white text-slate-900"
};
}
private string HeaderBorderClass()
{
return Theme == "dark" ? "border-white/5 bg-slate-900/40" : "border-black/5 bg-slate-100/40";
}
private string FontClass()
{
return Font switch
{
"sans" => "reader-text-sans",
"mono" => "reader-text-mono",
_ => "reader-text-serif"
};
}
private string TextContrastClass()
{
return Theme switch
{
"sepia" => "text-amber-900",
"dark" => "text-slate-200",
_ => "text-slate-800"
};
}
}
@@ -1,240 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<div class="relative w-full max-w-7xl mx-auto px-4 sm:px-6 my-8 md:my-12">
<!-- Decorative Label -->
<div class="flex items-center gap-2 mb-4">
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600">
<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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<span class="font-display text-xs font-bold tracking-wider text-slate-500 uppercase">
Bestsellers &amp; Monthly Trending
</span>
<span class="h-[1.5px] flex-1 bg-slate-100" />
</div>
@if (FeaturedBooks != null && FeaturedBooks.Count > 0)
{
var book = FeaturedBooks[ActiveIndex];
var isEbookOwned = Purchases.Any(p => p.BookId == book.Id && p.Format == "ebook");
var isAudioowned = Purchases.Any(p => p.BookId == book.Id && p.Format == "audiobook");
var ebookInCart = Cart.Any(i => i.Book.Id == book.Id && i.Format == "ebook");
var audioInCart = Cart.Any(i => i.Book.Id == book.Id && i.Format == "audiobook");
<!-- Main Carousel Card -->
<div class="relative overflow-hidden rounded-3xl border border-slate-800/80 bg-slate-900/95 backdrop-blur-md shadow-lg shadow-slate-950/25 min-h-[480px] md:min-h-[380px] flex items-center">
<!-- Dynamic Background Glow -->
<div class="absolute inset-0 bg-gradient-to-br @book.CoverColor opacity-25 blur-2xl scale-125 transition-all duration-1000 -z-10" />
<div class="w-full px-6 py-8 md:p-12 flex flex-col md:flex-row items-center gap-8 md:gap-12 relative z-10">
<!-- Ebook Cover Wrapper -->
<div class="relative cursor-pointer group flex-shrink-0" @onclick="() => SelectBook(book)">
<!-- Floating Shadow Backdrop -->
<div class="absolute -inset-1 rounded-2xl bg-white opacity-10 group-hover:opacity-20 blur-lg transition-opacity" />
<!-- Book Card Graphic Cover -->
<div class="relative h-64 w-44 md:h-76 md:w-52 rounded-xl overflow-hidden shadow-2xl border border-white/10 transition-transform duration-500 group-hover:scale-105">
@if (!string.IsNullOrEmpty(book.CoverUrl))
{
<img src="@book.CoverUrl" alt="@book.Title" referrerpolicy="no-referrer" class="h-full w-full object-cover" />
}
else
{
<div class="h-full w-full bg-gradient-to-br @book.CoverColor p-5 flex flex-col justify-between text-white">
<span class="text-[10px] font-mono font-bold tracking-widest uppercase text-white/40">@book.Category</span>
<span class="text-lg font-serif font-extrabold line-clamp-3 leading-tight">@book.Title</span>
<span class="text-xs font-sans text-white/70 block">By @book.Author</span>
</div>
}
<!-- Subtle spine shadow representing real bound book -->
<div class="absolute top-0 bottom-0 left-0 w-2.5 bg-gradient-to-r from-black/40 via-black/10 to-transparent" />
<!-- Category Overlay tag -->
<div class="absolute top-3 right-3 bg-slate-950/80 backdrop-blur-md border border-white/10 text-white font-mono text-[9px] uppercase tracking-wider px-2 py-0.5 rounded-full">
@book.Category
</div>
</div>
<!-- Bestseller Banner Ribbon -->
@if (book.Bestseller)
{
<div class="absolute -top-3 -left-3 bg-amber-400 text-slate-950 text-[10px] font-bold uppercase tracking-wider px-2.5 py-1 rounded-lg shadow-lg flex items-center gap-1">
<svg class="h-3 w-3 fill-slate-950 text-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
Bestseller
</div>
}
</div>
<!-- Book Description details -->
<div class="flex-1 text-center md:text-left">
<div class="flex items-center justify-center md:justify-start gap-2 mb-2">
<!-- Rating Stars -->
<div class="flex items-center text-amber-400">
@for (int i = 0; i < 5; i++)
{
<svg class="h-3.5 w-3.5 @(i < Math.Floor(book.Rating) ? "text-amber-400 fill-amber-400" : "text-slate-600")" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
}
</div>
<span class="text-xs text-slate-400">(@book.ReviewsCount reviews)</span>
</div>
<h1 class="font-serif text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight text-white mb-3 leading-tight">
@book.Title
</h1>
<p class="text-sm text-slate-300 mb-4">
By <span class="font-semibold text-white">@book.Author</span> • Published @book.PublishedYear
</p>
<p class="text-xs md:text-sm text-slate-200/95 leading-relaxed max-w-xl mb-6 line-clamp-3 md:line-clamp-4 font-light">
@book.Description
</p>
<!-- Actions Section -->
<div class="flex flex-wrap items-center justify-center md:justify-start gap-3">
@if (isEbookOwned)
{
<button @onclick="() => ReadEbook(book)" class="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white hover:bg-slate-100 text-slate-900 font-bold text-xs shadow-md transition-all cursor-pointer">
<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="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>
Read Ebook Now
</button>
}
else
{
<button @onclick='() => AddToCart(book, "ebook")' disabled="@ebookInCart" class="flex items-center gap-1.5 px-4 py-2.5 rounded-xl border border-white/20 bg-white/10 hover:bg-white/20 text-white font-medium text-xs transition-all cursor-pointer disabled:opacity-50">
Buy Ebook (R @book.Price.ToString("F2"))
</button>
}
@if (isAudioowned)
{
<button @onclick="() => StreamAudiobook(book)" class="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-amber-400 hover:bg-amber-300 text-slate-950 font-bold text-xs shadow-md transition-all cursor-pointer">
<svg class="h-4 w-4 fill-slate-950" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
Listen Audiobook
</button>
}
else
{
<button @onclick='() => AddToCart(book, "audiobook")' disabled="@audioInCart" class="flex items-center gap-1.5 px-4 py-2.5 rounded-xl border border-white/20 bg-white/10 hover:bg-white/20 text-white font-medium text-xs transition-all cursor-pointer disabled:opacity-50">
Buy Audio (R @((book.Price * 1.25).ToString("F2")))
</button>
}
<button @onclick="() => SelectBook(book)" class="px-4 py-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 text-slate-200 border border-slate-700/50 font-medium text-xs transition-colors cursor-pointer">
View Details
</button>
</div>
</div>
</div>
<!-- Carousel Navigation Arrows -->
<button @onclick="HandlePrev" class="absolute left-3 md:left-4 p-2 rounded-full border border-slate-700 bg-slate-900/60 hover:bg-slate-900 text-white hover:scale-105 transition-all z-20 backdrop-blur-sm cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button @onclick="HandleNext" class="absolute right-3 md:right-4 p-2 rounded-full border border-slate-700 bg-slate-900/60 hover:bg-slate-900 text-white hover:scale-105 transition-all z-20 backdrop-blur-sm cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Bottom indicators dots -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 z-20">
@for (int idx = 0; idx < FeaturedBooks.Count; idx++)
{
var i = idx;
<button @onclick="() => SetActive(i)" class="h-1.5 rounded-full transition-all duration-300 cursor-pointer @(i == ActiveIndex ? "w-5 bg-white" : "w-1.5 bg-white/20")" />
}
</div>
</div>
}
</div>
@code {
[Parameter] public List<BookModel> FeaturedBooks { get; set; } = new();
[Parameter] public List<PurchaseRecord> Purchases { get; set; } = new();
[Parameter] public List<CartItem> Cart { get; set; } = new();
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
[Parameter] public EventCallback<BookModel> OnReadEbook { get; set; }
[Parameter] public EventCallback<BookModel> OnStreamAudiobook { get; set; }
[Parameter] public EventCallback<(BookModel Book, string Format)> OnAddToCart { get; set; }
private int ActiveIndex { get; set; } = 0;
private System.Threading.Timer? AutoRotationTimer { get; set; }
protected override void OnInitialized()
{
AutoRotationTimer = new System.Threading.Timer(_ =>
{
InvokeAsync(() =>
{
if (FeaturedBooks.Count > 0)
{
ActiveIndex = (ActiveIndex + 1) % FeaturedBooks.Count;
StateHasChanged();
}
});
}, null, 8000, 8000);
}
private void HandleNext()
{
ActiveIndex = (ActiveIndex + 1) % FeaturedBooks.Count;
}
private void HandlePrev()
{
ActiveIndex = (ActiveIndex - 1 + FeaturedBooks.Count) % FeaturedBooks.Count;
}
private void SetActive(int index)
{
ActiveIndex = index;
}
private void SelectBook(BookModel book)
{
OnSelectBook.InvokeAsync(book);
}
private void ReadEbook(BookModel book)
{
OnReadEbook.InvokeAsync(book);
}
private void StreamAudiobook(BookModel book)
{
OnStreamAudiobook.InvokeAsync(book);
}
private void AddToCart(BookModel book, string format)
{
OnAddToCart.InvokeAsync((book, format));
}
public void Dispose()
{
AutoRotationTimer?.Dispose();
}
}
@@ -1,165 +0,0 @@
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
@if (ShowBanner || ShowPrivacyModal)
{
@if (ShowBanner)
{
<div id="cookie-acceptance-banner"
class="fixed bottom-4 left-4 z-40 max-w-sm w-[calc(100vw-2rem)] sm:w-[380px] bg-white/90 backdrop-blur-xl border border-slate-200/80 rounded-2xl p-4 shadow-xl flex flex-col gap-3 animate-in slide-in-from-bottom-8 fade-in duration-500 hover:shadow-slate-200/50 transition-all">
<!-- Accent Line -->
<div class="absolute top-0 left-6 right-6 h-0.5 bg-gradient-to-r from-amber-500/0 via-amber-500/60 to-amber-500/0"></div>
<div class="flex items-start gap-3">
<!-- Visual Icon -->
<div class="h-9 w-9 rounded-xl bg-amber-50 border border-amber-200/60 text-amber-600 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 animate-pulse" 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" />
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5" />
<path d="M8.5 8.5v.01" />
<path d="M16 15.5v.01" />
<path d="M12 12v.01" />
<path d="M11 16v.01" />
<path d="M15 11v.01" />
</svg>
</div>
<div class="space-y-1">
<h4 class="text-xs font-sans font-bold text-slate-800 tracking-tight">
Cookie &amp; POPIA Protocol
</h4>
<p class="text-[11px] text-slate-600 leading-relaxed font-medium">
We utilize essential identifiers to facilitate secure electronic catalog licensing, sandboxed media playbacks, and cryptographic PayFast gateway handshake handoffs under <strong class="text-amber-600 font-bold">Lite Charms (Pty) Ltd</strong>.
</p>
</div>
<button @onclick="DismissBanner"
class="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer p-0.5"
aria-label="Dismiss banner">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Action Links & Buttons -->
<div class="flex items-center justify-between mt-1 pt-2.5 border-t border-slate-100">
<button @onclick="OpenPrivacyModal"
class="text-[11px] font-semibold text-amber-600 hover:text-amber-700 underline cursor-pointer flex items-center gap-1 focus:outline-none">
<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="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>
Privacy Policy
</button>
<div class="flex items-center gap-2">
<button @onclick="AcceptCookies"
class="bg-slate-900 hover:bg-slate-800 text-white rounded-xl py-1.5 px-3.5 text-[10px] font-mono font-bold uppercase tracking-wider transition-all cursor-pointer shadow shadow-slate-900/10 hover:scale-[1.02] active:scale-[0.98]">
Accept All
</button>
</div>
</div>
</div>
}
@if (ShowPrivacyModal)
{
<div id="cookie-privacy-modal-overlay"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
<div id="cookie-privacy-modal-content"
class="relative w-full max-w-5xl bg-slate-950 border border-white/10 rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<!-- Upper Highlight Bar -->
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-600" />
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-slate-900/40 relative z-10 flex-shrink-0">
<div class="flex items-center gap-2 text-white">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<span class="font-mono text-xs font-bold uppercase tracking-wider text-slate-300">
Secure Legal Document Portal
</span>
</div>
<button @onclick="ClosePrivacyModal"
class="p-1.5 rounded-xl border border-white/10 bg-white/5 text-slate-400 hover:text-white shadow-sm hover:scale-105 transition-all cursor-pointer"
aria-label="Close modal">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Scrollable Body -->
<div class="flex-1 overflow-y-auto bg-slate-950/50 p-4 sm:p-6 md:p-8">
<div class="max-w-4xl mx-auto -my-10">
<MidrandBooks.Components.Privacy />
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-white/10 bg-slate-900/40 flex items-center justify-end flex-shrink-0">
<button @onclick="ClosePrivacyModal"
class="bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl py-2 px-5 text-xs font-mono font-bold uppercase tracking-wider transition-all cursor-pointer">
Close Document
</button>
</div>
</div>
</div>
}
}
@code {
private bool ShowBanner { get; set; } = false;
private bool ShowPrivacyModal { get; set; } = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var isAccepted = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "midrandbooks_cookie_accepted");
if (isAccepted != "true")
{
await Task.Delay(1500);
ShowBanner = true;
StateHasChanged();
}
}
catch
{
// Fail gracefully if localStorage is blocked
}
}
}
private async Task AcceptCookies()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "midrandbooks_cookie_accepted", "true");
}
catch
{
// Fail gracefully
}
ShowBanner = false;
}
private void DismissBanner()
{
ShowBanner = false;
}
private void OpenPrivacyModal()
{
ShowPrivacyModal = true;
}
private void ClosePrivacyModal()
{
ShowPrivacyModal = false;
}
}
@@ -1,437 +0,0 @@
@inherits LayoutComponentBase
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Components
@using MidrandBooks.Models
<div class="relative min-h-screen flex flex-col justify-between selection:bg-violet-500/10 selection:text-violet-700">
<!-- Dynamic Ambient Motion Background -->
<AnimatedBackground />
<!-- Top Navigation Header -->
<Navbar
ActiveTab="@ActiveTab"
OnActiveTabChanged="HandleActiveTabChanged"
OnOpenCart="HandleOpenCart"
OnSelectBook="HandleSelectBook"
Books="CatalogBooks"
CartCount="CartItems.Count"
IsOfflineMode="IsOfflineMode"
OnOfflineModeToggle="HandleOfflineToggle"
/>
<!-- Main Central Work Space -->
<main class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 relative z-10 py-6">
@if (ActiveTab == "catalog")
{
<!-- Featured Slide Carousel Section -->
<FeaturedCarousel
FeaturedBooks="FeaturedBooks"
Purchases="PurchasedRecords"
Cart="CartItems"
OnSelectBook="HandleSelectBook"
OnReadEbook="HandleReadEbook"
OnStreamAudiobook="HandlePlayAudiobook"
OnAddToCart="HandleAddToCart"
/>
<!-- Main Interactive Catalog Grid -->
<BookGrid
Books="CatalogBooks"
Categories="Categories"
OnSelectBook="HandleSelectBook"
/>
}
else if (ActiveTab == "library")
{
<!-- Library View component -->
<LibraryView
Purchases="PurchasedRecords"
Books="CatalogBooks"
OfflineBooks="OfflineCachedBooks"
IsOfflineMode="IsOfflineMode"
OnSelectBook="HandleSelectBook"
OnReadBook="HandleReadEbook"
OnPlayBook="HandlePlayAudiobook"
OnCacheBookOffline="HandleCacheBookOffline"
/>
}
else if (ActiveTab == "author")
{
<!-- Author Portal component -->
<AuthorPortal
Categories="Categories"
OnAddCategory="HandleAddCategory"
OnPublishBook="HandlePublishBook"
/>
}
else if (ActiveTab == "terms")
{
<!-- Terms & Conditions view -->
<Terms />
}
else if (ActiveTab == "privacy")
{
<!-- Privacy Policy view -->
<Privacy />
}
<!-- Fallback or standard Blazor body placeholder -->
@Body
</main>
<!-- Detailed Book Information Modal Popup -->
@if (SelectedBook != null)
{
var isEbookOwned = PurchasedRecords.Any(p => p.BookId == SelectedBook.Id && p.Format == "ebook");
var isEbookCached = OfflineCachedBooks.Contains(SelectedBook.Id);
var isAudioOwned = PurchasedRecords.Any(p => p.BookId == SelectedBook.Id && p.Format == "audiobook");
var ebookInCart = CartItems.Any(i => i.Book.Id == SelectedBook.Id && i.Format == "ebook");
var audioInCart = CartItems.Any(i => i.Book.Id == SelectedBook.Id && i.Format == "audiobook");
<BookDetailModal
Book="SelectedBook"
IsEbookOwned="isEbookOwned"
IsEbookCached="isEbookCached"
IsAudioOwned="isAudioOwned"
EbookInCart="ebookInCart"
AudioInCart="audioInCart"
UserRating="UserRatings.ContainsKey(SelectedBook.Id) ? UserRatings[SelectedBook.Id] : (int?)null"
OnClose="HandleCloseModal"
OnAddToCart="HandleAddToCartFromModal"
OnReadEbook="() => HandleReadEbook(SelectedBook)"
OnPlayAudiobook="() => HandlePlayAudiobook(SelectedBook)"
OnRateBook="rating => HandleRateBook(SelectedBook.Id, rating)"
/>
}
<!-- Secure Checkout Side Panel Drawer -->
<CartAndCheckout
IsOpen="IsCartOpen"
OnClose="HandleCloseCart"
CartItems="CartItems"
OnRemoveFromCart="HandleRemoveFromCart"
OnCheckoutSuccess="HandleCheckoutSuccess"
/>
<!-- Embedded Proprietary Ebook Reader Overlay -->
@if (ActiveReadingBook != null)
{
<EbookReader
Book="ActiveReadingBook"
CurrentPage="ActiveReadingPage"
OnClose="HandleCloseReader"
OnPageChanged="HandlePageProgressChanged"
/>
}
<!-- Persistent Bottom Audiobook Player Node -->
@if (ActivePlayingBook != null)
{
<AudiobookPlayer
Book="ActivePlayingBook"
IsPlaying="IsAudioPlaying"
OnPlayToggle="HandleAudioPlayToggle"
OnClose="HandleCloseAudioPlayer"
/>
}
<!-- Global High Contrast Footer -->
<footer class="border-t border-white/5 bg-slate-950 mt-16 py-12 relative z-10">
<div class="max-w-7xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row items-center justify-between gap-6 text-center md:text-left">
<div>
<span class="font-sans text-sm font-extrabold text-white tracking-tight uppercase">
Midrand<span class="font-light text-slate-300">books</span><span class="text-[10px] font-normal lowercase text-violet-400">.co.za</span>
</span>
<p class="text-[11px] text-slate-300 mt-1.5 font-sans">
Legal Deposit Compliant • National Library Services of South Africa • Est. 2026
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-6 text-[11px] font-mono font-bold uppercase text-slate-300 tracking-wider">
<button @onclick='() => HandleActiveTabChanged("catalog")' class="hover:text-white transition-colors cursor-pointer focus:outline-none">Catalog</button>
<button @onclick='() => HandleActiveTabChanged("terms")' class="hover:text-white transition-colors cursor-pointer focus:outline-none text-violet-400">Terms of Sale</button>
<button @onclick='() => HandleActiveTabChanged("privacy")' class="hover:text-white transition-colors cursor-pointer focus:outline-none text-violet-400">Privacy Policy</button>
<a href="#contact" class="hover:text-white transition-colors">Midrand, Gauteng</a>
</div>
<div class="flex flex-col items-center md:items-end gap-1">
<p class="text-[10px] text-slate-400 font-mono">
© 2026 Midrand Books. Securely compiled under sandbox rules.
</p>
<p class="text-[10px] text-slate-500 font-mono">
Designed, maintained, owned &amp; hosted by <a href="https://www.litecharms.co.za" target="_blank" rel="noopener noreferrer" class="text-violet-400 hover:text-violet-300 underline">LiteCharms (PTY) Ltd</a>
</p>
</div>
</div>
</footer>
<!-- Signal Interruption Reconnect Modal Overlay -->
<ReconnectModal />
<!-- Cookie Acceptance Banner and Privacy Modal -->
<CookieBanner />
</div>
@code {
private string ActiveTab { get; set; } = "catalog";
private bool IsCartOpen { get; set; } = false;
private bool IsOfflineMode { get; set; } = false;
// Master list of categories
private List<string> Categories { get; set; } = new()
{
"Fiction", "Non-Fiction", "African Literature", "Poetry", "Academic & History", "Biography"
};
// Books and States
private List<BookModel> CatalogBooks { get; set; } = new();
private List<BookModel> FeaturedBooks { get; set; } = new();
private BookModel? SelectedBook { get; set; }
private List<CartItem> CartItems { get; set; } = new();
private List<PurchaseRecord> PurchasedRecords { get; set; } = new();
private List<string> OfflineCachedBooks { get; set; } = new();
private Dictionary<string, int> UserRatings { get; set; } = new();
// Active Reader/Player Overlays
private BookModel? ActiveReadingBook { get; set; }
private int ActiveReadingPage { get; set; } = 0;
private BookModel? ActivePlayingBook { get; set; }
private bool IsAudioPlaying { get; set; } = false;
protected override void OnInitialized()
{
// Load default premium seed publications
var random = new Random(2026);
var categoriesList = new[] { "Fiction", "African Literature", "Poetry", "Academic & History", "Biography", "Non-Fiction" };
var colors = new[] {
"from-indigo-900 to-purple-950",
"from-rose-950 to-orange-900",
"from-slate-900 to-teal-950",
"from-amber-950 to-emerald-950"
};
var titles = new[] {
"Whispers of the Highveld", "Gauteng Gold Rush Chronicles", "Soweto Symphony of Dreams",
"Limpopo Rain Queen", "The Karoo Echoes", "Table Mountain Sunrise",
"Durban Beachfront Rhythms", "Zululand Warriors"
};
var authors = new[] {
"N. Khumalo", "S. Ndlovu", "T. Mokoena", "K. Naidoo", "M. van der Merwe",
"L. Sibanda", "J. Govender", "Z. Zuma"
};
var descriptions = new[] {
"An epic South African saga highlighting historical transformations, personal sacrifices, and cultural richness in the Gauteng plains.",
"A comprehensive investigative review documenting the rich socio-economic changes driven by gold discoveries in early Johannesburg.",
"A beautifully written modern poetry collection exploring life, struggle, and celebratory musical triumphs in South Africa's heartland.",
"An enchanting fantasy drawing on classical folklore, rainmaking traditions, and political intrigue in the northern provinces.",
"A mystery novel detailing hidden family histories buried deep in the silent, expansive landscapes of the great Karoo desert."
};
for (int i = 0; i < titles.Length; i++)
{
var book = new BookModel
{
Id = $"book-seed-{i + 1}",
Title = titles[i],
Author = authors[i],
AuthorId = $"seed-auth-{i + 1}",
Description = descriptions[i % descriptions.Length],
Price = random.Next(95, 295),
Category = categoriesList[i % categoriesList.Length],
CoverColor = colors[i % colors.Length],
Rating = 4.0 + random.NextDouble(),
ReviewsCount = random.Next(5, 85),
PublishedYear = random.Next(2018, 2026),
PagesCount = random.Next(120, 480),
AudioDuration = $"{random.Next(4, 12)}h {random.Next(10, 59)}m",
Narrator = authors[(i + 2) % authors.Length],
Bestseller = i < 2,
Trending = i >= 2 && i < 4,
Status = "published"
};
// Populate some dummy chapters
book.FullText = new List<string>
{
$"Chapter 1 of {book.Title}.\n\nThe morning sun breaks across the rolling hills of the Highveld, painting the Gauteng skies in shades of gold and amber. The dusty streets of Midrand begin to hum with the early morning life, as commuters fill the platforms and local merchants raise their shutters.\n\nIt was here that Khwezi first realized that the stories written in these lands had a rhythm of their own—a heartbeat that echoed the rich histories of those who walked before.",
"Chapter 2.\n\nAs the afternoon heat settles over the vast landscapes, a quiet stillness descends. Under the shade of an ancient thorn tree, old journals are opened, revealing handwritten accounts of early migrations, family secrets, and triumphant breakthroughs. The digital DRM safeguards preserve these fragile pages for generations to come."
};
CatalogBooks.Add(book);
}
FeaturedBooks = CatalogBooks.Where(b => b.Bestseller || b.Trending).Take(3).ToList();
// Pre-purchase one book to show active items in Library immediately
PurchasedRecords.Add(new PurchaseRecord
{
BookId = "book-seed-3",
Format = "ebook",
CurrentPage = 0,
Progress = 15
});
}
private void HandleActiveTabChanged(string tab)
{
ActiveTab = tab;
}
private void HandleOpenCart()
{
IsCartOpen = true;
}
private void HandleCloseCart()
{
IsCartOpen = false;
}
private void HandleOfflineToggle(bool value)
{
IsOfflineMode = value;
}
private void HandleSelectBook(BookModel book)
{
SelectedBook = book;
}
private void HandleCloseModal()
{
SelectedBook = null;
}
private void HandleAddToCart((BookModel Book, string Format) tuple)
{
if (!CartItems.Any(i => i.Book.Id == tuple.Book.Id && i.Format == tuple.Format))
{
CartItems.Add(new CartItem { Book = tuple.Book, Format = tuple.Format });
}
}
private void HandleAddToCartFromModal(string format)
{
if (SelectedBook != null)
{
HandleAddToCart((SelectedBook, format));
}
}
private void HandleRemoveFromCart(CartItem item)
{
CartItems.Remove(item);
}
private void HandleCheckoutSuccess(List<CartItem> items)
{
foreach (var item in items)
{
if (!PurchasedRecords.Any(p => p.BookId == item.Book.Id && p.Format == item.Format))
{
PurchasedRecords.Add(new PurchaseRecord
{
BookId = item.Book.Id,
Format = item.Format,
CurrentPage = 0,
Progress = 0
});
}
}
CartItems.Clear();
ActiveTab = "library";
}
private void HandleReadEbook(BookModel book)
{
ActiveReadingBook = book;
var purchase = PurchasedRecords.FirstOrDefault(p => p.BookId == book.Id && p.Format == "ebook");
ActiveReadingPage = purchase?.CurrentPage ?? 0;
SelectedBook = null; // Close detail modal if open
}
private void HandleCloseReader()
{
ActiveReadingBook = null;
}
private void HandlePageProgressChanged(int page)
{
ActiveReadingPage = page;
if (ActiveReadingBook != null)
{
var purchase = PurchasedRecords.FirstOrDefault(p => p.BookId == ActiveReadingBook.Id && p.Format == "ebook");
if (purchase != null)
{
purchase.CurrentPage = page;
}
}
}
private void HandlePlayAudiobook(BookModel book)
{
ActivePlayingBook = book;
IsAudioPlaying = true;
SelectedBook = null; // Close detail modal if open
}
private void HandleCloseAudioPlayer()
{
ActivePlayingBook = null;
IsAudioPlaying = false;
}
private void HandleAudioPlayToggle(bool isPlaying)
{
IsAudioPlaying = isPlaying;
}
private void HandleCacheBookOffline(string bookId)
{
if (!OfflineCachedBooks.Contains(bookId))
{
OfflineCachedBooks.Add(bookId);
}
}
private void HandleAddCategory(string category)
{
if (!Categories.Contains(category))
{
Categories.Add(category);
}
}
private void HandlePublishBook(BookModel book)
{
CatalogBooks.Insert(0, book);
}
private void HandleRateBook(string bookId, int rating)
{
int? oldRating = UserRatings.ContainsKey(bookId) ? UserRatings[bookId] : null;
UserRatings[bookId] = rating;
var book = CatalogBooks.FirstOrDefault(b => b.Id == bookId);
if (book != null)
{
double oldTotal = book.Rating * book.ReviewsCount;
if (oldRating.HasValue)
{
book.Rating = (oldTotal - oldRating.Value + rating) / Math.Max(1, book.ReviewsCount);
}
else
{
book.ReviewsCount += 1;
book.Rating = (oldTotal + rating) / book.ReviewsCount;
}
book.Rating = Math.Round(book.Rating * 10) / 10;
}
}
}
-240
View File
@@ -1,240 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<nav class="sticky top-0 z-40 border-b border-slate-100 bg-white/95 backdrop-blur-md px-4 sm:px-6 py-4">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Brand Logo & South African Context -->
<div class="flex items-center justify-between">
<div @onclick='() => SetTab("catalog")' class="flex items-center gap-2.5 cursor-pointer group">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm group-hover:scale-105 transition-transform">
<svg class="h-5 w-5 text-amber-400" 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>
</div>
<div>
<span class="font-sans text-xl font-bold tracking-tight text-slate-800 uppercase">
Midrand<span class="font-light text-slate-500">books</span><span class="text-xs font-normal lowercase text-violet-500">.co.za</span>
</span>
<p class="text-[9px] text-slate-400 tracking-wider uppercase font-mono">Est. 2026 • South Africa</p>
</div>
</div>
<!-- Quick Mobile Action Icons -->
<div class="flex items-center gap-2 md:hidden">
<button
@onclick="ToggleOfflineMode"
class="p-2 rounded-lg border @(IsOfflineMode ? "border-amber-500/20 bg-amber-50/80 text-amber-600" : "border-emerald-500/20 bg-emerald-50/80 text-emerald-600")"
title="@(IsOfflineMode ? "Offline Mode Active" : "Online Mode")">
@if (IsOfflineMode)
{
<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="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-3.536 5 5 0 011.414-3.536M4.93 19.07a9 9 0 010-12.728M12 12v.01" />
</svg>
}
else
{
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
</button>
<button
@onclick="HandleOpenCart"
class="relative p-2 rounded-lg border border-slate-200 bg-slate-50 text-slate-700 hover:bg-slate-100">
<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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
@if (CartCount > 0)
{
<span class="absolute -top-1 -right-1 flex h-4.5 w-4.5 items-center justify-center rounded-full bg-slate-900 text-[10px] font-bold text-white">
@CartCount
</span>
}
</button>
</div>
</div>
<!-- Search Bar Node and Desktop Navigation links -->
<div class="flex-1 flex flex-col md:flex-row items-stretch md:items-center justify-end gap-4">
<!-- Dynamic Search input block -->
<div class="relative flex-1 max-w-md" @onfocusout="HandleFocusOut">
<span class="absolute inset-y-0 left-3 flex items-center text-slate-400">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="text"
placeholder="Search over 1,500 local publications..."
value="@SearchQuery"
@oninput="HandleSearchInput"
@onfocus="() => ShowResults = true"
class="w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-4 py-2 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 font-sans"
/>
<!-- Floating Search Results Drawer -->
@if (ShowResults && FilteredBooks().Any())
{
<div class="absolute left-0 right-0 mt-2 rounded-2xl border border-slate-200/80 bg-white p-2 shadow-xl z-50">
<div class="px-3 py-1.5 border-b border-slate-50">
<span class="text-[9px] font-mono font-bold tracking-wider text-slate-400 uppercase">Interactive Catalog Search Results</span>
</div>
<div class="divide-y divide-slate-50 max-h-[300px] overflow-y-auto">
@foreach (var book in FilteredBooks())
{
<div @onclick="() => ClickSearchResult(book)" class="p-2.5 hover:bg-slate-50 rounded-xl flex items-center gap-3 cursor-pointer transition-colors text-left">
<div class="h-10 w-7 rounded bg-slate-100 overflow-hidden flex-shrink-0 shadow-sm border border-slate-100">
@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 flex items-center justify-center">
<span class="text-[6px] text-white/50">BOOK</span>
</div>
}
</div>
<div class="min-w-0 flex-1">
<span class="block text-xs font-bold text-slate-800 truncate">@book.Title</span>
<span class="block text-[10px] text-slate-400 truncate">By @book.Author • <span class="font-mono text-[9px] text-violet-500 font-bold uppercase">@book.Category</span></span>
</div>
</div>
}
</div>
</div>
}
</div>
<!-- Central Platform Tabs navigation -->
<div class="flex items-center justify-around md:justify-end gap-1 border-t md:border-t-0 border-slate-100 pt-3 md:pt-0">
<button
@onclick='() => SetTab("catalog")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "catalog" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Browse Store
</button>
<button
@onclick='() => SetTab("library")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "library" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<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="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
</svg>
My Library
</button>
<button
@onclick='() => SetTab("author")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "author" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.907c.961 0 1.364 1.241.588 1.81l-3.974 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.974-2.888c-.776-.569-.373-1.81.588-1.81h4.906a1 1 0 00.95-.69l1.519-4.674z" />
</svg>
Author Studio
</button>
<!-- Desktop Quick Actions -->
<div class="hidden md:flex items-center gap-2 border-l border-slate-100 pl-4 ml-1">
<button
@onclick="ToggleOfflineMode"
class="p-2 rounded-xl border transition-colors cursor-pointer @(IsOfflineMode ? "border-amber-500/20 bg-amber-50/80 text-amber-600 hover:bg-amber-100/80" : "border-emerald-500/20 bg-emerald-50/80 text-emerald-600 hover:bg-emerald-100/80")"
title="@(IsOfflineMode ? "Offline Mode Active" : "Online Mode Live")">
@if (IsOfflineMode)
{
<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="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-3.536 5 5 0 011.414-3.536M4.93 19.07a9 9 0 010-12.728M12 12v.01" />
</svg>
}
else
{
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
</button>
<button
@onclick="HandleOpenCart"
class="relative p-2 rounded-xl border border-slate-200 bg-slate-50 text-slate-700 hover:bg-slate-100 transition-all cursor-pointer">
<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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
@if (CartCount > 0)
{
<span class="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-slate-900 text-[10px] font-bold text-white shadow-sm border border-white">
@CartCount
</span>
}
</button>
</div>
</div>
</div>
</div>
</nav>
@code {
[Parameter] public string ActiveTab { get; set; } = "catalog";
[Parameter] public EventCallback<string> OnActiveTabChanged { get; set; }
[Parameter] public EventCallback OnOpenCart { get; set; }
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
[Parameter] public List<BookModel> Books { get; set; } = new();
[Parameter] public int CartCount { get; set; }
[Parameter] public bool IsOfflineMode { get; set; }
[Parameter] public EventCallback<bool> OnOfflineModeToggle { get; set; }
private string SearchQuery { get; set; } = string.Empty;
private bool ShowResults { get; set; } = false;
private void SetTab(string tab)
{
OnActiveTabChanged.InvokeAsync(tab);
}
private void ToggleOfflineMode()
{
OnOfflineModeToggle.InvokeAsync(!IsOfflineMode);
}
private void HandleOpenCart()
{
OnOpenCart.InvokeAsync();
}
private void HandleSearchInput(ChangeEventArgs e)
{
SearchQuery = e.Value?.ToString() ?? string.Empty;
ShowResults = !string.IsNullOrWhiteSpace(SearchQuery);
}
private IEnumerable<BookModel> FilteredBooks()
{
if (string.IsNullOrWhiteSpace(SearchQuery)) return Enumerable.Empty<BookModel>();
var query = SearchQuery.Trim().ToLower();
return Books
.Where(b => b.Status == "published" &&
(b.Title.ToLower().Contains(query) ||
b.Author.ToLower().Contains(query) ||
b.Category.ToLower().Contains(query)))
.Take(5);
}
private void ClickSearchResult(BookModel book)
{
OnSelectBook.InvokeAsync(book);
SearchQuery = string.Empty;
ShowResults = false;
}
private async Task HandleFocusOut()
{
// Simple delay to allow click events on items to execute before drawer closes
await Task.Delay(200);
ShowResults = false;
}
}
@@ -1,205 +0,0 @@
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
<dialog id="components-reconnect-modal" data-nosnippet class="fixed inset-0 w-screen h-screen m-0 p-0 border-0 bg-transparent z-[99999] overflow-hidden flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300">
<!-- Dark Frosted Jacket Layer -->
<div class="absolute inset-0 bg-slate-950/60 backdrop-blur-md cursor-wait z-[1] animate-in fade-in duration-300"></div>
<!-- Centered Content Wrapper -->
<div class="reconnect-content relative z-[2] flex flex-col items-center gap-6 text-center text-slate-100 max-w-sm px-6">
<!-- Waveform Animation -->
<svg class="waveform-svg text-violet-400" viewBox="0 0 54 50" width="60" height="60" xmlns="http://www.w3.org/2000/svg">
<rect class="bar bar-1" x="2" y="15" width="4" height="20" rx="2" fill="currentColor" />
<rect class="bar bar-2" x="14" y="5" width="4" height="40" rx="2" fill="currentColor" />
<rect class="bar bar-3" x="26" y="20" width="4" height="10" rx="2" fill="currentColor" />
<rect class="bar bar-4" x="38" y="10" width="4" height="30" rx="2" fill="currentColor" />
<rect class="bar bar-5" x="50" y="18" width="4" height="14" rx="2" fill="currentColor" />
</svg>
<!-- Message Body -->
<div class="sync-message-body font-mono uppercase text-xs tracking-widest text-slate-300 space-y-2">
<span class="components-reconnect-first-attempt-visible hidden">
Losing signal...
</span>
<span class="components-reconnect-repeated-attempt-visible hidden">
reconnecting in <span id="components-seconds-to-next-attempt" class="font-bold text-violet-400">0</span>s...
</span>
<span class="components-reconnect-failed-visible text-rose-400 font-semibold hidden">
Playback disconnected.
</span>
<span class="components-pause-visible hidden">
Reading layout paused.
</span>
<span class="components-resume-failed-visible text-rose-400 font-semibold hidden">
State alignment broken.
</span>
</div>
<!-- Glass Action Buttons -->
<div class="sync-action-node flex items-center justify-center gap-3">
<button id="components-reconnect-button" class="btn-glass-action hidden px-4 py-2.5 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-mono uppercase text-[10px] tracking-widest rounded-xl transition-all items-center gap-2 cursor-pointer shadow-lg hover:scale-102 hover:border-violet-500/30">
<span>Retry Sync</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg>
</button>
<button id="components-resume-button" class="btn-glass-action hidden px-4 py-2.5 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-mono uppercase text-[10px] tracking-widest rounded-xl transition-all items-center gap-2 cursor-pointer shadow-lg hover:scale-102 hover:border-violet-500/30">
<span>Reload Session</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</div>
</dialog>
<style>
/* --- Show the modal smoothly when Blazor attaches state classes --- */
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-rejected {
opacity: 1 !important;
pointer-events: all !important;
}
#components-reconnect-modal.components-reconnect-hide {
display: none !important;
}
/* --- SVG Waveform Animation --- */
#components-reconnect-modal .bar {
transform-origin: center;
animation: reconnectWavePulse 1.2s ease-in-out infinite alternate;
}
#components-reconnect-modal .bar-1 { animation-delay: 0.0s; }
#components-reconnect-modal .bar-2 { animation-delay: 0.3s; }
#components-reconnect-modal .bar-3 { animation-delay: 0.6s; }
#components-reconnect-modal .bar-4 { animation-delay: 0.2s; }
#components-reconnect-modal .bar-5 { animation-delay: 0.5s; }
@@keyframes reconnectWavePulse {
0% {
transform: scaleY(0.3);
opacity: 0.5;
}
100% {
transform: scaleY(1);
opacity: 1;
}
}
/* Stop the waveform if the connection completely fails */
#components-reconnect-modal.components-reconnect-failed .bar,
#components-reconnect-modal.components-reconnect-rejected .bar,
#components-reconnect-modal.components-reconnect-paused .bar {
animation: none !important;
transform: scaleY(0.1) !important;
opacity: 0.3 !important;
}
/* --- Display Mechanics Matrix Controllers --- */
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: inline-block !important;
}
#components-reconnect-modal.components-reconnect-failed .btn-glass-action,
#components-reconnect-modal.components-reconnect-paused .btn-glass-action,
#components-reconnect-modal.components-reconnect-resume-failed .btn-glass-action {
display: inline-flex !important;
}
</style>
<script>
(function() {
function initReconnectModal() {
const reconnectModal = document.getElementById("components-reconnect-modal");
if (!reconnectModal) {
setTimeout(initReconnectModal, 100);
return;
}
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
if (retryButton) {
retryButton.addEventListener("click", retry);
}
const resumeButton = document.getElementById("components-resume-button");
if (resumeButton) {
resumeButton.addEventListener("click", resume);
}
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
if (window.Blazor) {
const successful = await window.Blazor.reconnect();
if (!successful) {
const resumeSuccessful = await window.Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
}
} catch (err) {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
if (window.Blazor) {
const successful = await window.Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initReconnectModal);
} else {
initReconnectModal();
}
})();
</script>
@@ -1,455 +0,0 @@
@namespace MidrandBooks.Components
@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 &amp; 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 &amp; 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 &amp; 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
});
}
}
}
-53
View File
@@ -1,53 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
<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="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>
@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 {
[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; }
private async Task HandleRetry()
{
await OnRetry.InvokeAsync();
}
private async Task HandleBackToHome()
{
await OnBackToHome.InvokeAsync();
}
}
-9
View File
@@ -1,9 +0,0 @@
@page "/"
@namespace MidrandBooks.Components.Pages
<div class="space-y-4">
<!-- Active MainLayout triggers rendering and manages central state. This Home page acts as the default routing node in Blazor structures. -->
<div class="hidden">
<span class="font-mono text-[9px] text-slate-400">Blazor route target loaded.</span>
</div>
</div>
@@ -1,223 +0,0 @@
@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,33 +0,0 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
<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 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();
}
}
-128
View File
@@ -1,128 +0,0 @@
@page "/privacy"
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
<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 &amp; 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="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>
<!-- 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="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 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="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 &amp; 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="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="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 &amp; 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>
</div>
</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' }});
}}
");
}
}
-141
View File
@@ -1,141 +0,0 @@
@page "/terms"
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
<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 &amp; 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="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 &amp; 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 &amp; 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>
<!-- 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="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, &amp; 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="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 &amp; 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="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 &amp; 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="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 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="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 &amp; 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>
</div>
</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' }});
}}
");
}
}
-61
View File
@@ -1,61 +0,0 @@
namespace MidrandBooks.Models;
public class BookModel
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public string AuthorId { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public double Price { get; set; }
public string Category { get; set; } = "Fiction";
public string? CoverUrl { get; set; }
public string CoverColor { get; set; } = "from-indigo-900 to-purple-950";
public string Type { get; set; } = "ebook"; // "ebook", "audiobook", "both"
public string Status { get; set; } = "published"; // "published", "converting", "pending_review", "rejected", "taken_down"
public string? ModerationFeedback { get; set; }
public double Rating { get; set; } = 4.5;
public int ReviewsCount { get; set; } = 12;
public bool Bestseller { get; set; }
public bool Trending { get; set; }
public string? PdfUrl { get; set; }
public string? PdfName { get; set; }
public string? AudioName { get; set; }
public string? AudioUrl { get; set; }
public int PublishedYear { get; set; } = 2026;
public int? PagesCount { get; set; }
public string? AudioDuration { get; set; }
public string? Narrator { get; set; }
public List<string> FullText { get; set; } = new();
public List<ModerationHistoryEntry> ModerationHistory { get; set; } = new();
}
public class ModerationHistoryEntry
{
public string Status { get; set; } = string.Empty;
public string Date { get; set; } = string.Empty;
public string Feedback { get; set; } = string.Empty;
}
public class CartItem
{
public BookModel Book { get; set; } = new();
public string Format { get; set; } = "ebook"; // "ebook", "audiobook"
public double Price => Format == "audiobook" ? Book.Price * 1.25 : Book.Price;
}
public class PurchaseRecord
{
public string BookId { get; set; } = string.Empty;
public string Format { get; set; } = "ebook";
public int CurrentPage { get; set; } = 0;
public int Progress { get; set; } = 0;
}
public class LibraryItem
{
public BookModel Book { get; set; } = new();
public string Format { get; set; } = "ebook";
public int CurrentPage { get; set; } = 0;
public int Progress { get; set; } = 0;
}
-52
View File
@@ -1,52 +0,0 @@
using LiteCharms.Features.Extensions;
using LiteCharms.Features.Postgres;
using MidrandBooks;
using MidrandBooks.Components;
using static LiteCharms.Features.Extensions.Quartz;
var builder = WebApplication.CreateBuilder(args);
builder.AddMonitoring();
builder.Services.RegisterServices(builder.Configuration);
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapStaticAssets();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.AddSecurityEndpoints();
using (var security = app.Services.CreateScope())
{
var dataProtectionContext = security.ServiceProvider.GetRequiredService<DataProtectionDbContext>();
await dataProtectionContext.Database.MigrateAsync();
}
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(MidrandShopSchedulerName);
if (!scheduler!.IsStarted) await scheduler.Start();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
-61
View File
@@ -1,61 +0,0 @@
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.Extensions;
using LiteCharms.Features.MidrandBooks.Extensions;
using static LiteCharms.Features.Extensions.Quartz;
namespace MidrandBooks;
public static class Setup
{
public static IServiceCollection RegisterServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddCancellationToken();
services.AddAntiforgery();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services.AddBlazoredToast();
services.AddEndpointsApiExplorer();
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
services.AddQuartzSchedulerClient(MidrandShopSchedulerName, configuration);
services.AddMediator();
services.AddEmailServices(configuration);
services.AddEmailServiceBus();
services.AddHttpClient();
services.AddScoped<CartService>();
services.AddShopServices(includeLocalStorage: true);
services.AddHashServices(configuration);
services.AddPayfastServices(configuration);
services.AddDataProtectionDatabase(configuration);
services.AddMidrandShopDatabase(configuration);
services.AddSecurityApiSdk(configuration);
services.AddLiteCharmsWebSecurity(configuration);
services.AddMidrandShopPostgresHealthCheck();
services.AddMidrandShopQuartzHealthCheck();
services.AddHealthChecksSupport(configuration);
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
options.ForwardLimit = null;
options.RequireHeaderSymmetry = false;
});
return services;
}
}
-4
View File
@@ -1,4 +0,0 @@
@import "tailwindcss";
body {
}
-1109
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
{
"name": "MidrandBookshop",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"tailwindcss": "^4.3.3"
},
"dependencies": {
"@tailwindcss/cli": "^4.3.3"
}
}
-3026
View File
File diff suppressed because it is too large Load Diff
-255
View File
@@ -1,255 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/remapping@^2.3.5":
version "2.3.5"
resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz"
integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
dependencies:
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/resolve-uri@^3.1.0":
version "3.1.2"
resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
version "1.5.5"
resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
"@jridgewell/trace-mapping@^0.3.24":
version "0.3.31"
resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@parcel/watcher-win32-x64@2.5.1":
version "2.5.1"
resolved "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz"
integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==
"@parcel/watcher@2.5.1":
version "2.5.1"
resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz"
integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==
dependencies:
detect-libc "^1.0.3"
is-glob "^4.0.3"
micromatch "^4.0.5"
node-addon-api "^7.0.0"
optionalDependencies:
"@parcel/watcher-android-arm64" "2.5.1"
"@parcel/watcher-darwin-arm64" "2.5.1"
"@parcel/watcher-darwin-x64" "2.5.1"
"@parcel/watcher-freebsd-x64" "2.5.1"
"@parcel/watcher-linux-arm-glibc" "2.5.1"
"@parcel/watcher-linux-arm-musl" "2.5.1"
"@parcel/watcher-linux-arm64-glibc" "2.5.1"
"@parcel/watcher-linux-arm64-musl" "2.5.1"
"@parcel/watcher-linux-x64-glibc" "2.5.1"
"@parcel/watcher-linux-x64-musl" "2.5.1"
"@parcel/watcher-win32-arm64" "2.5.1"
"@parcel/watcher-win32-ia32" "2.5.1"
"@parcel/watcher-win32-x64" "2.5.1"
"@tailwindcss/cli@^4.3.3":
version "4.3.3"
resolved "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz"
integrity sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==
dependencies:
"@parcel/watcher" "2.5.1"
"@tailwindcss/node" "4.3.3"
"@tailwindcss/oxide" "4.3.3"
enhanced-resolve "^5.24.1"
mri "^1.2.0"
picocolors "^1.1.1"
tailwindcss "4.3.3"
"@tailwindcss/node@4.3.3":
version "4.3.3"
resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz"
integrity sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==
dependencies:
"@jridgewell/remapping" "^2.3.5"
enhanced-resolve "^5.24.1"
jiti "^2.7.0"
lightningcss "1.32.0"
magic-string "^0.30.21"
source-map-js "^1.2.1"
tailwindcss "4.3.3"
"@tailwindcss/oxide-win32-x64-msvc@4.3.3":
version "4.3.3"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz"
integrity sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==
"@tailwindcss/oxide@4.3.3":
version "4.3.3"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz"
integrity sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==
optionalDependencies:
"@tailwindcss/oxide-android-arm64" "4.3.3"
"@tailwindcss/oxide-darwin-arm64" "4.3.3"
"@tailwindcss/oxide-darwin-x64" "4.3.3"
"@tailwindcss/oxide-freebsd-x64" "4.3.3"
"@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.3"
"@tailwindcss/oxide-linux-arm64-gnu" "4.3.3"
"@tailwindcss/oxide-linux-arm64-musl" "4.3.3"
"@tailwindcss/oxide-linux-x64-gnu" "4.3.3"
"@tailwindcss/oxide-linux-x64-musl" "4.3.3"
"@tailwindcss/oxide-wasm32-wasi" "4.3.3"
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.3"
"@tailwindcss/oxide-win32-x64-msvc" "4.3.3"
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
detect-libc@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz"
integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
detect-libc@^2.0.3:
version "2.1.2"
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
enhanced-resolve@^5.24.1:
version "5.24.2"
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz"
integrity sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.3.3"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-glob@^4.0.3:
version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
jiti@^2.7.0:
version "2.7.0"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz"
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
lightningcss-win32-x64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz"
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
lightningcss@1.32.0:
version "1.32.0"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz"
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
lightningcss-android-arm64 "1.32.0"
lightningcss-darwin-arm64 "1.32.0"
lightningcss-darwin-x64 "1.32.0"
lightningcss-freebsd-x64 "1.32.0"
lightningcss-linux-arm-gnueabihf "1.32.0"
lightningcss-linux-arm64-gnu "1.32.0"
lightningcss-linux-arm64-musl "1.32.0"
lightningcss-linux-x64-gnu "1.32.0"
lightningcss-linux-x64-musl "1.32.0"
lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0"
magic-string@^0.30.21:
version "0.30.21"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
micromatch@^4.0.5:
version "4.0.8"
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.3"
picomatch "^2.3.1"
mri@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
node-addon-api@^7.0.0:
version "7.1.1"
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz"
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.3.1:
version "2.3.2"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
tailwindcss@^4.3.3, tailwindcss@4.3.3:
version "4.3.3"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz"
integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==
tapable@^2.3.3:
version "2.3.3"
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["MidrandBookshop.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<div class="col-12 col-md-6 col-lg-4 mb-4 h-100">
<div class="d-flex flex-column h-100 justify-content-between">
<div class="card border-0 p-4 position-relative book-grid-card d-flex flex-column justify-content-between"
style="background-color: #F1F1F1; border-radius: var(--mb-radius); cursor: pointer; min-height: 280px;"
@onclick="OnCardClick">
<div class="d-flex justify-content-between align-items-center mb-3">
@if (IsNew)
{
<span class="badge rounded-pill px-3 py-2 badge-new-arrival" style="background-color: var(--mb-accent-red); font-weight: 500;">New</span>
}
else
{
<div></div>
}
<button class="btn bg-white rounded-circle d-flex align-items-center justify-content-center p-2 shadow-sm border-0" style="width: 32px; height: 32px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--mb-text-dark)" stroke-width="2.5">
<line x1="7" y1="17" x2="17" y2="7" />
<polyline points="7,7 17,7 17,17" />
</svg>
</button>
</div>
<div class="d-flex justify-content-center align-items-center flex-grow-1 my-2">
@if (!string.IsNullOrWhiteSpace(BookImageUrl))
{
<img src="@BookImageUrl" class="img-fluid book-shadow" style="max-height: 240px; object-fit: contain;" alt="@Title" />
}
else
{
<div class="book-spine-fallback bg-dark d-flex align-items-center justify-content-center text-center p-3 text-white-50 shadow-sm"
style="width: 50%; max-width: 140px; aspect-ratio: 2 / 3; border-radius: 6px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.05em; line-height: 1.4; box-shadow: 0 10px 24px rgba(0,0,0,0.16);">
<div style="max-width: 100%; overflow: hidden; word-break: break-word;">
@Category.ToUpper()<br><span class="opacity-50" style="font-size: 0.55rem;">EDITION</span>
</div>
</div>
}
</div>
</div>
<div class="d-flex justify-content-between align-items-start mt-3 mb-2 px-2" style="cursor: pointer;" @onclick="OnCardClick">
<div style="max-width: 72%;">
<span class="fw-medium text-dark d-block text-truncate product-card-title" style="font-size: 0.95rem; line-height: 1.25;">@Title</span>
<span class="text-muted small d-block text-truncate mt-1" style="font-size: 0.8rem;">by @Author</span>
</div>
<span class="font-monospace fw-semibold text-secondary pt-0.5" style="font-size: 0.9rem; white-space: nowrap;">R @Price.ToString("N0")</span>
</div>
</div>
</div>
@code {
[Parameter] public long Id { get; set; }
[Parameter] public string Title { get; set; } = string.Empty;
[Parameter] public string Author { get; set; } = string.Empty;
[Parameter] public decimal Price { get; set; }
[Parameter] public string Category { get; set; } = string.Empty;
[Parameter] public bool IsNew { get; set; }
[Parameter] public string BookImageUrl { get; set; } = string.Empty;
[Parameter] public EventCallback OnCardClick { get; set; }
}
@@ -0,0 +1,9 @@
.book-shadow {
filter: drop-shadow(5px 10px 15px rgba(0, 0, 0, 0.15)) drop-shadow(1px 2px 4px rgba(0, 0, 0, 0.1));
}
.sm-icon {
width: 14px;
height: 14px;
vertical-align: middle;
}
@@ -0,0 +1,245 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation
<div class="position-relative vh-100 d-flex flex-column justify-content-between overflow-hidden" style="background-color: #F9F9F9;">
<div class="cart-overlay @(IsCartOpen ? "is-visible" : "")" @onclick="ToggleCart"></div>
<div class="cart-drawer @(IsCartOpen ? "is-open" : "") d-flex flex-column bg-white shadow-lg">
<div class="cart-header d-flex align-items-center justify-content-between p-4 border-bottom">
<h5 class="fw-bold m-0 text-dark tracking-tight" style="font-family: 'Inter', sans-serif; font-size: 1.1rem;">
YOUR CART (@CartItems.Sum(i => i.Quantity))
</h5>
<button class="btn btn-sm text-dark p-1 border-0" @onclick="ToggleCart" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="cart-body flex-grow-1 overflow-y-auto p-4">
@if (!CartItems.Any())
{
<div class="h-100 d-flex flex-column align-items-center justify-content-center text-muted py-5">
<svg class="mb-3 opacity-50" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z M3 6h18 M16 10a4 4 0 0 1-8 0" />
</svg>
<span class="small tracking-wide">Your collection is empty.</span>
</div>
}
else
{
<div class="d-flex flex-column gap-4">
@foreach (var item in CartItems)
{
<div class="cart-item d-flex gap-3 align-items-start pb-3 border-bottom-dashed">
<div class="cart-item-thumb bg-dark text-white-50 d-flex align-items-center justify-content-center px-2 text-center" style="width: 54px; height: 74px; font-size: 0.45rem; letter-spacing: 0.5px;">
[ COVER ]
</div>
<div class="flex-grow-1">
<h6 class="text-dark small fw-bold mb-0 text-truncate" style="max-width: 180px;">@item.Title</h6>
<p class="text-muted xx-small mb-2">by @item.Author</p>
<div class="d-flex align-items-center justify-content-between">
<div class="quantity-picker d-flex align-items-center border rounded-pill bg-light">
<button class="btn btn-sm py-0 px-2 text-dark border-0" @onclick="() => ChangeQuantity(item, -1)" type="button">-</button>
<span class="px-1 text-dark fw-medium" style="font-size: 0.75rem;">@item.Quantity</span>
<button class="btn btn-sm py-0 px-2 text-dark border-0" @onclick="() => ChangeQuantity(item, 1)" type="button">+</button>
</div>
<span class="small fw-semibold text-dark">R @(item.Price * item.Quantity)</span>
</div>
</div>
<button class="btn text-muted p-0 border-0 mt-1 align-self-start" style="background: none;" @onclick="() => RemoveFromCart(item)" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
}
</div>
}
</div>
@if (CartItems.Any())
{
<div class="cart-footer p-4 bg-light border-top mt-auto">
<div class="d-flex align-items-center justify-content-between mb-4">
<span class="text-secondary small fw-medium tracking-wider">TOTAL DUE</span>
<span class="text-dark h5 fw-bold mb-0">R @GetCartTotal()</span>
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-dark w-100 rounded-pill py-2.5 fw-medium shadow-sm small tracking-wide" @onclick="RedirectToCheckout" type="button">
Proceed to Checkout
</button>
<button class="btn btn-outline-dark w-100 rounded-pill py-2.5 fw-medium bg-white small tracking-wide" @onclick="RedirectToCart" type="button">
Go to Cart Page
</button>
</div>
</div>
}
</div>
<div class="w-100 position-relative flex-shrink-0" style="z-index: 1020;">
<div class="position-absolute top-0 start-0 overflow-hidden d-none d-md-block" style="z-index: -1; pointer-events: none; opacity: 0.08; transform: translate(-10%, -10%);">
<svg width="480" height="480" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="98" stroke="#1A1A1A" stroke-width="0.25" stroke-dasharray="0.5 1.5" />
<circle cx="100" cy="100" r="92" stroke="#1A1A1A" stroke-width="0.4" />
<circle cx="100" cy="100" r="86" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="2 1" />
<circle cx="100" cy="100" r="80" stroke="#1A1A1A" stroke-width="0.3" />
<path d="M100 20 A80 80 0 0 1 180 100 A80 80 0 0 1 100 180 A80 80 0 0 1 20 100 A80 80 0 0 1 100 20" stroke="#1A1A1A" stroke-width="0.15" />
<path d="M100 20 A80 80 0 0 0 180 100 A80 80 0 0 0 100 180 A80 80 0 0 0 20 100 A80 80 0 0 0 100 20" stroke="#1A1A1A" stroke-width="0.15" />
<line x1="40" y1="100" x2="160" y2="100" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="1 4" />
<line x1="100" y1="40" x2="100" y2="160" stroke="#1A1A1A" stroke-width="0.2" stroke-dasharray="1 4" />
<g transform="translate(65, 82)" stroke="#1A1A1A" stroke-width="0.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M16 18 C 16 10, 24 10, 24 18" stroke-width="0.5" />
<line x1="20" y1="10" x2="20" y2="4" />
<path d="M28 18 C 28 14, 34 14, 34 18" stroke-width="0.5" />
<line x1="31" y1="14" x2="31" y2="7" />
<rect x="3" y="8" width="5" height="10" stroke-width="0.5" />
<rect x="11" y="5" width="5" height="13" stroke-width="0.5" />
<rect x="38" y="3" width="7" height="15" stroke-width="0.5" />
<polygon points="38,3 41.5,0 45,3" stroke-width="0.4" />
<rect x="49" y="9" width="6" height="9" stroke-width="0.5" />
<line x1="0" y1="18" x2="70" y2="18" stroke-width="0.75" />
</g>
</svg>
</div>
<nav class="navbar navbar-expand-lg py-3" style="pointer-events: auto;">
<div class="container-fluid px-md-5">
<a class="navbar-brand d-flex align-items-center" href="/" style="transform: scale(1.6); transform-origin: left center; margin-right: 4rem;">
<svg class="me-2" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1A1A1A" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17c-2.5 0-4.5-1.8-4.5-4s2-4 4.5-4 4.5 1.8 4.5 4-2 4-4.5 4Z" />
<path d="M12 9c2.2 0 4-1.6 4-3.5S14.2 2 12 2 8 3.6 8 5.5 9.8 9 12 9Z" />
<path d="M2 12h20" stroke-width="1" stroke-dasharray="1 2" opacity="0.5" />
</svg>
<span class="fw-bold tracking-tight text-dark" style="font-size: 0.65rem; letter-spacing: 0.8px; font-family: 'Inter', sans-serif;">MIDRAND BOOKS</span>
</a>
<div class="collapse navbar-collapse justify-content-center" id="navbarNav">
<div class="nav-pill-wrapper d-flex align-items-center">
<a class="nav-pill-link active" href="/">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>
Books
</a>
<a class="nav-pill-link" href="/audiobooks">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z" /></svg>
Audiobooks
</a>
<a class="nav-pill-link" href="/ebooks">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12.01" y2="18" /></svg>
E-Books
</a>
</div>
</div>
<div class="d-flex align-items-center gap-2 position-relative">
<div class="search-input-container d-flex align-items-center @(IsSearchActive ? "is-active" : "")">
<input type="text"
class="form-control form-control-sm rounded-pill border-light-subtle px-3 text-dark bg-light custom-search-field"
placeholder="Search by ISBN, Author, Title..."
@bind="SearchInputBuffer"
@bind:event="oninput"
@onkeydown="HandleSearchKeyDown" />
</div>
<button class="btn btn-link text-dark p-1 me-2 border-0 header-action-btn"
@onclick="ToggleGlobalSearch"
type="button"
aria-label="Toggle Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button class="btn d-flex align-items-center justify-content-center p-2 position-relative border-0"
style="background: transparent; color: #1A1A1A; width: 36px; height: 36px;"
@onclick="ToggleCart"
type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z M3 6h18 M16 10a4 4 0 0 1-8 0" />
</svg>
@if (CartItems.Any())
{
<span class="cart-badge">@CartItems.Sum(i => i.Quantity)</span>
}
</button>
<a href="/profile" class="btn btn-dark rounded-pill px-3 py-1 d-none d-md-inline-flex align-items-center gap-2 btn-sm fw-medium shadow-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Account
</a>
<a href="/profile" class="btn btn-sm btn-dark rounded-circle d-inline-flex d-md-none align-items-center justify-content-center border-0 p-0 shadow-sm"
style="width: 32px; height: 32px;"
aria-label="Log In">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</div>
</div>
</nav>
</div>
<div class="w-100 flex-grow-1 overflow-y-auto d-flex flex-column justify-content-between">
<main class="position-relative" style="z-index: 5;">
<CascadingValue Value="GlobalSearchQuery">
@Body
</CascadingValue>
</main>
<footer class="custom-site-footer border-top mt-auto">
<div class="container-fluid px-md-5">
<div class="row g-4 pb-4 justify-content-between footer-content-section">
<div class="col-12 col-md-5">
<span class="footer-brand-title d-block mb-2">MIDRAND BOOKS</span>
<p class="footer-desc mb-3">An architectural destination for curated print, thoughtful reading culture, and global design perspectives.</p>
<div class="d-flex flex-column gap-2">
<a href="mailto:info@midrandbooks.co.za" class="footer-contact-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></svg>
info@midrandbooks.co.za
</a>
<a href="tel:+27872650198" class="footer-contact-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 .2.81.7A2 2 0 0 1 22 16.92z" /></svg>
+27 87 265 9463
</a>
</div>
</div>
<div class="col-12 col-md-6 col-lg-5 text-start">
<div class="row row-cols-2 justify-content-md-end g-3">
<div style="max-width: 160px;">
<span class="footer-section-heading d-block mb-2">PLATFORM</span>
<ul class="list-unstyled d-flex flex-column gap-1.5 m-0">
<li><a href="/about" class="footer-nav-link">About Us</a></li>
<li><a href="/" class="footer-nav-link">Browse Catalog</a></li>
<li><a href="/contact" class="footer-nav-link">Contact Us</a></li>
</ul>
</div>
<div style="max-width: 210px;">
<span class="footer-section-heading d-block mb-2">ENTERPRISE</span>
<ul class="list-unstyled d-flex flex-column gap-1.5 m-0">
<li class="footer-meta-item d-flex align-items-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /></svg>
Lite Charms (PTY) Ltd
</li>
<li><a href="/terms" class="footer-nav-link">Terms & Conditions</a></li>
<li><a href="/privacy" class="footer-nav-link">Privacy Policy</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="row align-items-center pt-3 footer-copyright-section">
<div class="col-12 text-center text-md-start">
<span class="footer-copyright">
&copy; @DateTime.Now.Year Midrand Books. All rights reserved.
</span>
</div>
</div>
</div>
</footer>
</div>
</div>
@@ -0,0 +1,131 @@
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.WebUtilities;
namespace MidrandBookshop.Components.Layout;
public partial class MainLayout : IDisposable
{
private string SearchInputBuffer { get; set; } = string.Empty;
private string GlobalSearchQuery { get; set; } = string.Empty;
private bool IsSearchActive { get; set; } = false;
private bool IsCartOpen { get; set; } = false;
private List<CartItem> CartItems = new()
{
new CartItem { Id = 1, Title = "Letters from M/M (Paris)", Author = "M/M Paris", Price = 720, Quantity = 1 },
new CartItem { Id = 2, Title = "Daan Paans: Floating Signifiers", Author = "Daan Paans", Price = 540, Quantity = 1 },
new CartItem { Id = 3, Title = "Album Architectures, Maputo", Author = "Guedes Archive", Price = 350, Quantity = 1 }
};
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
SyncSearchQueryFromUrl();
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
SyncSearchQueryFromUrl();
StateHasChanged();
}
private void SyncSearchQueryFromUrl()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var queryParameters = QueryHelpers.ParseQuery(uri.Query);
if (queryParameters.TryGetValue("q", out var queryVal) && !string.IsNullOrWhiteSpace(queryVal))
{
GlobalSearchQuery = queryVal.ToString();
SearchInputBuffer = GlobalSearchQuery;
IsSearchActive = true;
}
else
{
GlobalSearchQuery = string.Empty;
SearchInputBuffer = string.Empty;
IsSearchActive = false;
}
}
private void ToggleGlobalSearch()
{
if (!IsSearchActive)
IsSearchActive = true;
else
CommitSearchNavigation();
}
private void HandleSearchKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") CommitSearchNavigation();
}
private void CommitSearchNavigation()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var queryParameters = QueryHelpers.ParseQuery(uri.Query);
var newRouteParams = new Dictionary<string, object?>();
foreach (var param in queryParameters)
{
if (param.Key != "q")
{
newRouteParams[param.Key] = param.Value.ToString();
}
}
if (!string.IsNullOrWhiteSpace(SearchInputBuffer))
newRouteParams["q"] = SearchInputBuffer.Trim();
else
newRouteParams["q"] = null;
var baseRoute = uri.AbsolutePath.StartsWith("/product/", StringComparison.OrdinalIgnoreCase) ? "/" : uri.AbsolutePath;
var updatedUri = Navigation.GetUriWithQueryParameters(baseRoute, newRouteParams);
Navigation.NavigateTo(updatedUri);
}
private void ToggleCart() => IsCartOpen = !IsCartOpen;
private void ChangeQuantity(CartItem item, int delta)
{
item.Quantity += delta;
if (item.Quantity <= 0)
{
CartItems.Remove(item);
}
}
private void RemoveFromCart(CartItem item) => CartItems.Remove(item);
private int GetCartTotal() => CartItems.Sum(item => item.Price * item.Quantity);
private void RedirectToCart()
{
IsCartOpen = false;
Navigation.NavigateTo("/cart");
}
private void RedirectToCheckout()
{
IsCartOpen = false;
Navigation.NavigateTo("/checkout");
}
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
}
public class CartItem
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public int Price { get; set; }
public int Quantity { get; set; }
}
}
@@ -0,0 +1,193 @@
.sticky-top {
position: -webkit-sticky !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
}
.cart-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 1040;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.cart-overlay.is-visible {
opacity: 1;
pointer-events: auto;
}
.cart-drawer {
position: fixed;
top: 0;
right: -420px;
width: 100%;
max-width: 400px;
height: 100vh;
z-index: 1050;
pointer-events: none;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.cart-drawer.is-open {
transform: translateX(-420px);
pointer-events: auto;
}
.cart-badge {
position: absolute;
top: 2px;
right: 2px;
background-color: #1A1A1A;
color: #FFF;
font-size: 0.62rem;
font-weight: 700;
border-radius: 50%;
width: 15px;
height: 15px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.xx-small {
font-size: 0.68rem;
}
.border-bottom-dashed {
border-bottom: 1px dashed rgba(0, 0, 0, 0.12);
}
.quantity-picker {
padding: 2px 4px;
}
.quantity-picker button {
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
}
.quantity-picker button:hover {
background-color: rgba(0,0,0,0.05);
border-radius: 50%;
}
.custom-site-footer {
width: 100%;
position: relative;
z-index: 10;
background-color: #1A1A1A !important;
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
padding: 3rem 0 2rem 0;
}
.footer-content-section {
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
}
.footer-brand-title {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 1px;
color: #FFFFFF !important;
}
.footer-desc {
font-size: 0.8rem;
line-height: 1.6;
max-width: 360px;
color: #A0AEC0 !important;
}
.footer-contact-link {
text-decoration: none;
font-size: 0.8rem;
color: #A0AEC0 !important;
display: inline-flex;
align-items: center;
gap: 8px;
transition: color 0.2s ease;
}
.footer-contact-link:hover {
color: #FFFFFF !important;
}
.footer-section-heading {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 1.2px;
color: #FFFFFF !important;
}
.footer-nav-link {
text-decoration: none;
font-size: 0.8rem;
color: #A0AEC0 !important;
transition: color 0.2s ease;
}
.footer-nav-link:hover {
color: #FFFFFF !important;
}
.footer-meta-item {
font-size: 0.8rem;
font-weight: 500;
color: #FFFFFF !important;
}
.footer-copyright-section {
color: #718096 !important;
}
.footer-copyright {
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.2px;
}
.btn-back-to-top {
position: absolute;
bottom: 2rem;
right: 2rem;
width: 42px !important;
min-width: 42px !important;
max-width: 42px !important;
height: 42px !important;
border-radius: 50% !important;
background-color: #1A1A1A;
color: #FFFFFF;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1010;
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
}
.btn-back-to-top:hover {
background-color: #333333;
transform: translateY(-2px);
}
.btn-back-to-top:active {
transform: translateY(0);
}
.scroll-container {
scroll-behavior: smooth;
}
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
+106
View File
@@ -0,0 +1,106 @@
@page "/cart"
<div class="container py-5" style="max-width: 900px;">
<div class="d-flex align-items-center justify-content-between mb-5">
<h2 class="fw-bold m-0">Your Cart</h2>
<a href="/" class="text-dark text-decoration-none small fw-bold tracking-widest">CONTINUE SHOPPING</a>
</div>
@if (!CartItems.Any())
{
<div class="text-center py-5 border rounded-3 bg-white">
<p class="text-muted mb-4">Your collection is currently empty.</p>
<a href="/" class="btn btn-dark rounded-pill px-4">Browse Catalog</a>
</div>
}
else
{
<div class="card border-0 shadow-sm p-4 mb-4">
@foreach (var item in CartItems)
{
<div class="row align-items-center py-4 @(item != CartItems.Last() ? "border-bottom" : "")">
<!-- Item Detail -->
<div class="col-12 col-md-6 d-flex align-items-center gap-4">
<div class="bg-light d-flex align-items-center justify-content-center" style="width: 70px; height: 95px;">
<span class="text-muted" style="font-size: 0.5rem;">[COVER]</span>
</div>
<div>
<h5 class="fw-bold mb-1">@item.Title</h5>
<p class="text-muted small mb-0">by @item.Author</p>
</div>
</div>
<!-- Quantity -->
<div class="col-6 col-md-3 d-flex justify-content-center">
<div class="d-flex align-items-center border rounded-pill bg-light px-2">
<button class="btn btn-sm border-0 text-dark" @onclick="() => ChangeQuantity(item, -1)">-</button>
<span class="px-3 fw-bold">@item.Quantity</span>
<button class="btn btn-sm border-0 text-dark" @onclick="() => ChangeQuantity(item, 1)">+</button>
</div>
</div>
<!-- Price & Remove -->
<div class="col-6 col-md-3 text-end d-flex align-items-center justify-content-end gap-3">
<span class="fw-bold">R @(item.Price * item.Quantity)</span>
<button class="btn btn-sm p-1 text-muted" @onclick="() => RemoveFromCart(item)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
</div>
}
</div>
<!-- Cart Totals Section -->
<div class="row justify-content-end">
<div class="col-md-5">
<div class="bg-white p-4 rounded-3 border">
<div class="d-flex justify-content-between mb-3">
<span class="text-muted">Subtotal</span>
<span class="fw-bold">R @Subtotal.ToString("F2")</span>
</div>
<div class="d-flex justify-content-between mb-4">
<span class="text-muted">VAT (15%)</span>
<span class="fw-bold">R @VatAmount.ToString("F2")</span>
</div>
<hr />
<div class="d-flex justify-content-between align-items-center mb-4">
<span class="fw-bold h5 mb-0">Total</span>
<span class="fw-bold h4 mb-0">R @Total.ToString("F2")</span>
</div>
<a href="/checkout" class="btn btn-dark w-100 rounded-pill py-3">Proceed to Checkout</a>
</div>
</div>
</div>
}
</div>
@code {
public class CartItem
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public int Price { get; set; }
public int Quantity { get; set; }
}
private List<CartItem> CartItems = new()
{
new CartItem { Id = 1, Title = "Letters from M/M (Paris)", Author = "M/M Paris", Price = 720, Quantity = 1 },
new CartItem { Id = 2, Title = "Daan Paans: Floating Signifiers", Author = "Daan Paans", Price = 540, Quantity = 1 },
new CartItem { Id = 3, Title = "Album Architectures, Maputo", Author = "Guedes Archive", Price = 350, Quantity = 1 }
};
// Computed Properties for Calculations
private decimal Subtotal => CartItems.Sum(i => (decimal)i.Price * i.Quantity);
private decimal VatAmount => Subtotal * 0.15m;
private decimal Total => Subtotal + VatAmount;
private void ChangeQuantity(CartItem item, int delta)
{
item.Quantity += delta;
if (item.Quantity <= 0) CartItems.Remove(item);
}
private void RemoveFromCart(CartItem item) => CartItems.Remove(item);
}
@@ -0,0 +1,110 @@
@page "/checkout"
@inject NavigationManager Navigation
<div class="container py-5">
<h2 class="fw-bold mb-4">Checkout</h2>
<div class="row g-5">
<!-- LEFT COLUMN: SHIPPING & CART -->
<div class="col-lg-8">
<!-- 1. Cart Items -->
<div class="card border-0 shadow-sm p-4 mb-4">
<h5 class="fw-bold mb-3">Your Items</h5>
@foreach (var item in CartItems)
{
<div class="d-flex align-items-center justify-content-between pb-3 border-bottom mb-3">
<div><h6 class="mb-0">@item.Title</h6><small class="text-muted">@item.Author</small></div>
<div class="d-flex align-items-center gap-3">
<div class="d-flex border rounded-pill">
<button class="btn btn-sm px-2" @onclick="() => ChangeQuantity(item, -1)">-</button>
<span class="px-2 pt-1">@item.Quantity</span>
<button class="btn btn-sm px-2" @onclick="() => ChangeQuantity(item, 1)">+</button>
</div>
<button class="btn btn-sm text-danger" @onclick="() => RemoveFromCart(item)">Remove</button>
</div>
</div>
}
</div>
<!-- 2. Shipping Options -->
<div class="card border-0 shadow-sm p-4 mb-4">
<h5 class="fw-bold mb-3">Shipping Method</h5>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="shipping" id="pickup"
checked=@(ShippingCost == 0) @onclick="() => ShippingCost = 0">
<label class="form-check-label" for="pickup">Pickup from Bookshop (Free)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="shipping" id="delivery"
checked=@(ShippingCost == 60) @onclick="() => ShippingCost = 60">
<label class="form-check-label" for="delivery">Home Delivery (R60.00)</label>
</div>
</div>
<!-- 3. Address Fields -->
<div class="card border-0 shadow-sm p-4">
<h5 class="fw-bold mb-3">Shipping Address</h5>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="sameAsBilling" @bind="IsSameAddress">
<label class="form-check-label" for="sameAsBilling">Billing address same as shipping</label>
</div>
<!-- Add text inputs for address here, show/hide based on IsSameAddress -->
</div>
</div>
<!-- RIGHT COLUMN: STICKY SUMMARY -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm p-4 sticky-top" style="top: 100px;">
<h5 class="fw-bold mb-3">Order Summary</h5>
<div class="d-flex justify-content-between mb-2"><span>Subtotal</span><span>R @Subtotal.ToString("F2")</span></div>
<div class="d-flex justify-content-between mb-2"><span>VAT (15%)</span><span>R @VatAmount.ToString("F2")</span></div>
<div class="d-flex justify-content-between mb-2"><span>Shipping</span><span>R @ShippingCost.ToString("F2")</span></div>
<hr />
<div class="d-flex justify-content-between mb-4">
<span class="fw-bold">Total Due</span>
<h4 class="fw-bold">R @((Subtotal + VatAmount + ShippingCost).ToString("F2"))</h4>
</div>
<button class="btn btn-dark w-100 py-3 rounded-pill" @onclick="CompletePurchase">Complete Purchase</button>
</div>
</div>
</div>
</div>
@code {
private decimal ShippingCost = 0;
private bool IsSameAddress = true;
// Calculations
private decimal Subtotal => CartItems.Sum(i => (decimal)i.Price * i.Quantity);
private decimal VatAmount => Subtotal * 0.15m;
// Assuming your CartItems list is managed via a Service or cascading parameter
// Here it is locally mocked for this example
private List<CartItem> CartItems = new()
{
new CartItem { Id = 1, Title = "Letters from M/M (Paris)", Author = "M/M Paris", Price = 720, Quantity = 1 },
new CartItem { Id = 2, Title = "Daan Paans: Floating Signifiers", Author = "Daan Paans", Price = 540, Quantity = 1 }
};
private void ChangeQuantity(CartItem item, int delta)
{
item.Quantity += delta;
if (item.Quantity <= 0) CartItems.Remove(item);
}
private void RemoveFromCart(CartItem item) => CartItems.Remove(item);
private int GetCartTotal() => CartItems.Sum(i => i.Price * i.Quantity);
public class CartItem
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public int Price { get; set; }
public int Quantity { get; set; }
}
private void CompletePurchase(MouseEventArgs args)
{
Navigation.NavigateTo("/payment-confirmation");
}
}
@@ -0,0 +1,49 @@
@page "/Error"
@using System.Diagnostics
@inject NavigationManager Navigation
<PageTitle>System Exception | Midrand Books</PageTitle>
<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="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>
</div>
</div>
</div>
@code {
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,37 @@
.artistic-error-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
color: #dc3545; /* Subtle red for system status */
}
.btn-outline-dark {
border: 1px solid #111;
transition: all 0.3s ease;
}
.btn-outline-dark:hover {
background-color: #111;
color: #fff;
}
+203
View File
@@ -0,0 +1,203 @@
@page "/"
@rendermode InteractiveServer
<div id="top-target" @ref="topTargetRef" class="container text-center text-hero-wrapper" />
<div class="container text-center text-hero-wrapper">
<h1 class="display-3 text-dark mb-3 px-2 master-headline">
Discover thoughtfully curated<br>books for every reader.
</h1>
<p class="text-muted mx-auto mb-0 sub-headline">
Explorations into books, reading culture, and the art of thoughtful curation from Midrand to the world.
</p>
</div>
<div class="container mb-5 px-md-5">
@if (AuthorId.HasValue && !string.IsNullOrEmpty(ActiveAuthorFilterName))
{
<div class="alert alert-light border d-flex align-items-center justify-content-between rounded-pill px-4 py-2 mb-4 shadow-sm mx-auto" style="max-width: 520px;">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-dark rounded-pill fw-normal px-2.5 py-1">Author Collection</span>
<span class="text-dark fw-medium small">Archived items from <strong>@ActiveAuthorFilterName</strong></span>
</div>
<button class="btn btn-close btn-sm p-1 ms-3" @onclick="ClearAuthorFilter" aria-label="Clear Filter"></button>
</div>
}
<div class="row align-items-center justify-content-between pb-3 g-3">
<div class="col-12 col-md-8">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-start">
@if (!AuthorId.HasValue)
{
@foreach (var category in MainCategories)
{
var catName = category;
<button class="btn btn-sm rounded-pill px-3 py-1-5 fw-medium transition-smooth
@(ActiveCategory == catName ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle bg-white")"
@onclick="() => SelectCategory(catName)">
@catName
</button>
}
@if (DynamicExtendedCategories.Count > 0)
{
<button class="btn btn-link text-muted btn-sm text-decoration-none fw-medium transition-smooth d-inline-flex align-items-center gap-1 control-expansion-trigger"
@onclick="ToggleExtraCategories">
<span>@(ShowExpandedCategories ? "Collapse Cloud" : "More Genres")</span>
<svg class="transition-smooth @(ShowExpandedCategories ? "rotate-180" : "")" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6,9 12,15 18,9" /></svg>
</button>
}
}
else
{
<button class="btn btn-sm btn-outline-secondary text-dark bg-white border-light-subtle rounded-pill px-3.5 py-1.5 fw-medium" @onclick="ClearAuthorFilter">
&larr; Return to Catalog Index
</button>
}
</div>
</div>
<div class="col-12 col-md-4">
<div class="d-flex align-items-center justify-content-md-end gap-2 text-md-end row-actions-wrapper">
<button class="btn btn-sm btn-outline-secondary text-dark bg-white border-light-subtle rounded-pill px-3.5 py-1.5 fw-medium d-inline-flex align-items-center gap-2"
@onclick="ToggleFilterMenu">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
<span>Refine Curation</span>
</button>
<div class="btn-group bg-white rounded-pill p-1 border border-light-subtle shadow-sm" role="group">
<button class="btn btn-sm rounded-pill p-1 d-flex align-items-center justify-content-center toggle-layout-action @(CurrentViewMode == ViewMode.Grid ? "btn-dark text-white shadow-sm" : "btn-link text-muted")"
style="width:28px; height:28px;" @onclick="() => SetViewMode(ViewMode.Grid)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg>
</button>
<button class="btn btn-sm rounded-pill p-1 d-flex align-items-center justify-content-center toggle-layout-action @(CurrentViewMode == ViewMode.List ? "btn-dark text-white shadow-sm" : "btn-link text-muted")"
style="width:28px; height:28px;" @onclick="() => SetViewMode(ViewMode.List)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" /></svg>
</button>
</div>
</div>
</div>
</div>
@if (ShowExpandedCategories && DynamicExtendedCategories.Count > 0 && !AuthorId.HasValue)
{
<div class="category-cloud-drawer p-4 mb-4 border border-light-subtle rounded animate-slide-down bg-light">
<h2 class="h6 text-muted mb-3 text-uppercase tracking-wider" style="font-size:0.7rem; font-weight:700;">Curated Subgenre Tag Index</h2>
<div class="d-flex flex-wrap gap-2">
@foreach (var subCategory in DynamicExtendedCategories)
{
var subName = subCategory;
<button class="btn btn-xs rounded-pill px-2.5 py-1 text-dark border-light-subtle transition-smooth
@(ActiveCategory == subName ? "btn-dark text-white" : "bg-white btn-outline-secondary")"
style="font-size:0.75rem;" @onclick="() => SelectCategory(subName)">
#@subName
</button>
}
</div>
</div>
}
@if (ShowFilterMenu)
{
<div class="filter-panel-drawer p-4 mb-4 border border-light-subtle rounded animate-slide-down bg-white shadow-sm">
<div class="row g-4 text-start">
<div class="col-12 col-sm-4">
<label class="form-label small fw-semibold text-dark">Sort Artifacts</label>
<select class="form-select form-select-sm rounded-pill px-3" value="@SelectedSortOption" @onchange="(e) => ChangeSort(e.Value?.ToString()!)">
<option value="default">Release Timeline</option>
<option value="price-low">Value: Low to High</option>
<option value="price-high">Value: High to Low</option>
<option value="title-asc">Alphabetical Order</option>
</select>
</div>
<div class="col-12 col-sm-4">
<label class="form-label small fw-semibold text-dark">Price Thresholds</label>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "all" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("all")'>All Prices</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "under-500" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("under-500")'>Under R500</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "500-1000" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("500-1000")'>R500 - R1,000</button>
<button class="btn btn-xs rounded-pill px-3 py-1 @(ActivePriceFilter == "over-1000" ? "btn-dark" : "btn-outline-secondary text-dark border-light-subtle")" @onclick='() => ChangePriceFilter("over-1000")'>Over R1,000</button>
</div>
</div>
<div class="col-12 col-sm-4 d-flex align-items-center mt-sm-4">
<div class="form-check form-switch p-0 m-0 d-flex align-items-center gap-2">
<input class="form-check-input ms-0 mt-0 styled-switch-toggle" type="checkbox" role="switch" id="newArrivalsToggle" checked="@OnlyShowNew" @onchange="ToggleNewArrivalsOnly">
<label class="form-check-label small fw-medium text-dark user-select-none" for="newArrivalsToggle">New Acquisitions Only</label>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-4 pt-3 border-top border-light-subtle">
<button class="btn btn-link btn-sm text-decoration-none text-muted fw-medium px-3" @onclick="ResetFilters">Purge Filters</button>
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="ToggleFilterMenu">Apply Selection</button>
</div>
</div>
}
@if (!PaginatedBooks.Any())
{
<div class="text-center py-5 my-4 bg-light rounded border border-dashed border-light-subtle animate-fade-in">
<svg class="text-muted mb-3" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<p class="text-secondary mb-1 fw-medium">No archived books match your active criteria.</p>
<span class="text-muted small">Try adjusting your category definitions or search phrase query.</span>
</div>
}
else if (CurrentViewMode == ViewMode.Grid)
{
<div class="row g-4 animate-fade-in">
@foreach (var book in PaginatedBooks)
{
<BookCard Id="@book.Id"
Title="@book.Name"
Author="@(ProductAuthorCache.TryGetValue(book.Id, out var authorName) ? authorName : "Unknown Author")"
Price="@ProductPriceCache[book.Id]"
Category="@ProductPrimaryCategoryCache[book.Id]"
IsNew="@book.Enabled"
BookImageUrl="@book.ImageUrl"
OnCardClick="() => NavigateToProduct(book.Id)" />
}
</div>
}
else if (CurrentViewMode == ViewMode.List)
{
<div class="d-flex flex-column border rounded bg-white overflow-hidden shadow-sm animate-fade-in list-container-wrapper">
@foreach (var book in PaginatedBooks)
{
<div class="d-flex align-items-center justify-content-between py-3 px-2 list-row-item"
style="cursor: pointer;"
@onclick="() => NavigateToProduct(book.Id)">
<div class="d-flex align-items-center gap-4 structural-list-left">
<span class="text-dark fw-medium list-item-title">@book.Name</span>
<span class="text-muted small list-item-author">by @(ProductAuthorCache.TryGetValue(book.Id, out var authorName) ? authorName : "Unknown Author")</span>
<span class="badge bg-light text-secondary border rounded-pill px-2.5 py-1 font-monospace list-item-tag">@ProductPrimaryCategoryCache[book.Id].ToUpper()</span>
</div>
<div class="d-flex align-items-center gap-4">
@if (book.Enabled)
{
<span class="badge rounded-pill bg-danger-subtle text-danger px-2.5 py-1 list-new-badge">NEW</span>
}
<span class="text-dark font-monospace fw-medium list-item-price">R @ProductPriceCache[book.Id].ToString("N0")</span>
<button class="btn btn-link text-dark p-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="7" y1="17" x2="17" y2="7" /><polyline points="7,7 17,7 17,17" /></svg>
</button>
</div>
</div>
}
</div>
}
@if (HasMoreItems)
{
<div class="d-flex flex-column align-items-center justify-content-center mt-5 pt-4">
<button class="btn btn-outline-dark rounded-pill px-5 py-2.5 fw-medium shadow-sm" style="font-size: 0.85rem;" @onclick="LoadNextPage">
<span>Show More Artifacts</span>
</button>
</div>
}
</div>
<a class="back-to-top-btn d-flex align-items-center justify-content-center"
aria-label="Back to top"
href="#top-target">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5,12 12,5 19,12" /></svg>
</a>
@@ -0,0 +1,224 @@
using LiteCharms.Features;
using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Categories;
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!;
[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 OnParametersSetAsync() => await LoadCatalogDataAsync();
private async Task LoadCatalogDataAsync()
{
ProductsCollection.Clear();
ProductPriceCache.Clear();
ProductPrimaryCategoryCache.Clear();
ProductAuthorCache.Clear();
ActiveAuthorFilterName = null;
if (AuthorId.HasValue)
{
var authorResult = await AuthorService.GetAuthorAsync(AuthorId.Value);
if (authorResult.IsSuccess && authorResult.Value != null)
{
var author = authorResult.Value;
ActiveAuthorFilterName = author.PublisherType == PublisherTypes.Company && !string.IsNullOrWhiteSpace(author.Company)
? author.Company
: $"{author.Name} {author.LastName}".Trim();
}
var authorBooksResult = await BooksService.GetBooksByAuthorAsync(AuthorId.Value);
if (authorBooksResult.IsSuccess && authorBooksResult.Value != null)
{
foreach (var authorBook in authorBooksResult.Value)
{
if (authorBook.Product != null)
{
var product = authorBook.Product;
ProductsCollection.Add(product);
ProductPriceCache[product.Id] = product.Price?.Amount ?? 0m;
ProductAuthorCache[product.Id] = ActiveAuthorFilterName ?? "Unknown Author";
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
? (categoryResult.Value[0].Name ?? "General")
: "General";
}
}
}
return;
}
var selectionRange = new DateRange
{
From = new DateOnly(2020, 1, 1),
To = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)),
MaxRecords = 100
};
var allProductsResult = await ProductService.GetProductsAsync(0, selectionRange);
if (allProductsResult.IsSuccess && allProductsResult.Value != null)
{
ProductsCollection = allProductsResult.Value.ToList();
foreach (var product in ProductsCollection)
{
var priceResult = await ProductService.GetProductPriceAsync(product.Id);
ProductPriceCache[product.Id] = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
var categoryResult = await ProductService.GetProductCategoriesAsync(product.Id);
ProductPrimaryCategoryCache[product.Id] = (categoryResult.IsSuccess && categoryResult.Value.Length > 0)
? (categoryResult.Value[0].Name ?? "General")
: "General";
ProductAuthorCache[product.Id] = !string.IsNullOrWhiteSpace(product.Metadata?.Manufacturer)
? product.Metadata.Manufacturer
: "Unknown Author";
}
}
var categoriesResult = await CategoryService.GetCategoriesAsync();
if (categoriesResult.IsSuccess && categoriesResult.Value != null)
{
var cleanNames = categoriesResult.Value
.Select(c => c.Name)
.Where(n => !string.IsNullOrEmpty(n)).Cast<string>()
.ToList();
MainCategories = ["All", .. cleanNames.Take(3)];
DynamicExtendedCategories = [.. cleanNames.Skip(3)];
}
}
private void ClearAuthorFilter()
{
var newUri = Navigation.GetUriWithQueryParameters("/", new Dictionary<string, object?> { { "authorId", null } });
Navigation.NavigateTo(newUri);
}
private void ResetFilters()
{
SelectedSortOption = "default";
ActivePriceFilter = "all";
OnlyShowNew = false;
VisibleCount = ItemsPerPage;
}
private void NavigateToProduct(long id) => Navigation.NavigateTo($"/product/{id}");
private void SetViewMode(ViewMode targetMode) => CurrentViewMode = targetMode;
private void SelectCategory(string categoryName)
{
ActiveCategory = categoryName;
VisibleCount = ItemsPerPage;
var updatedUri = Navigation.GetUriWithQueryParameters(Navigation.Uri, new Dictionary<string, object?> {{ "q", null }});
Navigation.NavigateTo(updatedUri);
}
private void ToggleExtraCategories() => ShowExpandedCategories = !ShowExpandedCategories;
private void ToggleFilterMenu() => ShowFilterMenu = !ShowFilterMenu;
private void ChangeSort(string sortOption) => SelectedSortOption = sortOption;
private void ChangePriceFilter(string priceBracket) { ActivePriceFilter = priceBracket; VisibleCount = ItemsPerPage; }
private void ToggleNewArrivalsOnly(ChangeEventArgs e) { OnlyShowNew = e.Value is bool b && b; VisibleCount = ItemsPerPage; }
private void LoadNextPage() { if (HasMoreItems) VisibleCount += ItemsPerPage; }
}
@@ -0,0 +1,177 @@
/* Expanding Search Input Controls */
.search-input-container {
max-width: 0;
opacity: 0;
overflow: hidden;
transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
}
.search-input-container.is-active {
max-width: 280px;
opacity: 1;
}
.custom-search-field {
font-size: 0.8rem;
height: 32px;
box-shadow: none !important;
}
/* Structural Layout Typography */
.branding-logo {
font-size: 1.1rem;
letter-spacing: -0.5px;
}
.text-hero-wrapper {
margin-top: 5rem;
margin-bottom: 3.5rem;
}
.master-headline {
letter-spacing: -1.5px;
font-weight: 400;
line-height: 1.15;
}
.sub-headline {
max-width: 520px;
font-size: 0.95rem;
font-weight: 300;
line-height: 1.6;
}
/* Filtering Dropdown Drawer Settings */
.filter-dropdown-panel {
border-radius: 12px;
}
.panel-section-heading {
letter-spacing: 0.5px;
font-size: 0.7rem;
}
.reset-link-btn {
font-size: 0.7rem;
}
/* Custom Minimal Separator Rule Layout */
.custom-milled-line {
height: 35px;
margin-top: 5px;
margin-bottom: 25px;
z-index: 0;
pointer-events: none;
}
.center-bloom-shadow {
bottom: 15px;
width: 55%;
height: 50px;
background: radial-gradient(ellipse at bottom, rgba(20, 20, 20, 0.07) 0%, rgba(40, 40, 40, 0.02) 60%, rgba(255, 255, 255, 0) 100%);
filter: blur(10px);
}
.core-horizontal-rule {
bottom: 15px;
height: 1px;
background: linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0) 100%);
}
/* View Engine Layout Presentation Variants: Grid Cards */
.book-grid-card {
background-color: #F1F1F1;
border-radius: 12px;
min-height: 380px;
}
.badge-new-arrival {
background-color: #E63946;
font-size: 0.75rem;
}
.book-spine-fallback {
width: 130px;
height: 185px;
font-size: 0.65rem;
letter-spacing: 1px;
border-radius: 2px;
}
.product-card-title {
font-size: 0.95rem;
font-weight: 500;
}
/* View Engine Layout Presentation Variants: List Rows */
.list-row-item {
border-bottom: 1px solid rgba(0,0,0,0.06);
transition: background-color 0.2s ease;
}
.list-row-item:hover {
background-color: rgba(0,0,0,0.01);
}
.structural-list-left {
flex: 2;
}
.list-item-title {
font-size: 0.95rem;
min-width: 260px;
}
.list-item-author {
min-width: 160px;
}
.list-item-tag {
font-size: 0.65rem;
}
.list-new-badge {
font-size: 0.65rem;
}
.list-item-price {
font-size: 0.9rem;
min-width: 80px;
text-align: right;
}
/* Enable native smooth scrolling page-wide */
html {
scroll-behavior: smooth;
}
/* Back to Top Icon Layout Presentation */
.back-to-top-btn {
position: fixed !important;
bottom: 32px;
right: 32px;
width: 42px;
height: 42px;
border-radius: 50% !important;
text-decoration: none !important;
/* Dual-tone contrast visibility profile */
background-color: #ffffff !important;
color: #1a1a1a !important;
border: 2px solid #1a1a1a !important;
box-shadow: 0 0 0 2px #ffffff, 0 4px 12px rgba(0, 0, 0, 0.15) !important;
z-index: 2147483647 !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.back-to-top-btn:hover {
transform: translateY(-4px);
background-color: #1a1a1a !important;
color: #ffffff !important;
border-color: #ffffff !important;
box-shadow: 0 0 0 2px #1a1a1a, 0 6px 16px rgba(0, 0, 0, 0.25) !important;
}
/* Direct the smooth scroll action straight to the layout viewport boundary */
#top-target {
scroll-margin-top: 100vh;
}
@@ -0,0 +1,35 @@
@page "/not-found"
@inject NavigationManager Navigation
<div class="artistic-404-container">
<div class="container h-100">
<div class="row align-items-center justify-content-center min-vh-70 py-5">
<div class="col-12 col-md-5 d-flex justify-content-center mb-5 mb-md-0">
<div class="artistic-svg-wrapper">
<svg viewBox="0 0 400 400" width="350" height="350" stroke="#1A1A1A" stroke-width="2" fill="none">
<path d="M60 80 Q80 60 100 80" />
<path d="M100 60 Q120 40 140 60" />
<path d="M280 70 Q300 50 320 70" />
<path d="M100 200 L200 180 L300 200 L300 300 L200 320 L100 300 Z" fill="#F8F8F8" />
<path d="M200 180 L200 320" />
<path d="M120 220 L180 220 M120 250 L180 250 M220 220 L280 220 M220 250 L280 250" stroke="#DDD" />
<circle cx="230" cy="230" r="40" />
<line x1="258" y1="258" x2="290" y2="290" />
</svg>
</div>
</div>
<div class="col-12 col-md-6 offset-md-1">
<div class="meta-tag font-monospace text-uppercase text-muted mb-3">System Index // Uncharted Territory</div>
<h1 class="display-4 text-dark mb-4 editorial-headline">Nothing was found<br />in this search.</h1>
<p class="text-muted mb-5 body-manifesto-text">
Even with careful scrutiny, the requested volume remains elusive. It appears the shelf you are exploring is currently vacant.
</p>
<button class="btn btn-dark rounded-pill px-5 py-3" @onclick='() => Navigation.NavigateTo("/")'>Return to Collection</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,76 @@
.artistic-404-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.terrain-wrapper {
display: flex;
justify-content: center;
align-items: center;
/* Static, grounded look */
border-bottom: 2px solid #f0f0f0;
padding-bottom: 2rem;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
}
.btn-dark {
transition: background 0.3s ease;
}
.btn-dark:hover {
background: #444;
}
.artistic-404-container {
background-color: #ffffff;
min-height: 70vh;
display: flex;
align-items: center;
padding: 5rem 0;
}
.artistic-svg-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.editorial-headline {
font-family: 'Playfair Display', serif;
font-size: 3.5rem;
line-height: 1.1;
letter-spacing: -0.02em;
color: #111111;
}
.body-manifesto-text {
font-size: 1.1rem;
max-width: 400px;
line-height: 1.7;
}
.meta-tag {
font-size: 0.75rem;
letter-spacing: 0.2em;
}
@@ -0,0 +1,40 @@
@page "/payment-confirmation"
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 text-center">
<!-- Success Icon & Message -->
<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">#MB-2026-8834</h5>
</div>
</div>
<!-- Calls to Action -->
<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="/order-history" class="btn btn-outline-dark w-100 rounded-pill py-3">Order History</a>
</div>
<div class="col-6">
<a href="/track-order" class="btn btn-outline-dark w-100 rounded-pill py-3">Track Order</a>
</div>
</div>
</div>
<!-- Optional Trust Footer -->
<p class="mt-5 text-muted small">You will receive a confirmation email shortly at <strong>user@email.com</strong>.</p>
</div>
</div>
</div>
@@ -0,0 +1,126 @@
@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>
}
<span class="badge badge-ebook">In Stock</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">
<button @onclick="DecreaseQty" class="qty-btn">-</button>
<span class="qty-val">@Quantity</span>
<button @onclick="IncreaseQty" class="qty-btn">+</button>
</div>
<button class="btn-add-to-cart" @onclick="HandleAddToCart">
Add to Cart
</button>
</div>
<hr class="divider" />
@if (!string.IsNullOrWhiteSpace(CurrentProduct.Description))
{
<div class="info-block">
<h3>Description</h3>
<p class="description-text">@CurrentProduct.Description</p>
</div>
}
<div class="info-block author-bio-card">
<h3>About the Author</h3>
<p class="author-bio">
@(string.IsNullOrWhiteSpace(CurrentProduct.Metadata?.Manufacturer)
? "Details regarding this publisher/author are maintained inside our curated archives."
: $"{CurrentProduct.Metadata.Manufacturer} is globally recognized for their direct contribution to this specific genre spectrum.")
</p>
<button class="btn-text-link" @onclick="ViewAllAuthorBooks">
View all books by @AuthorName →
</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,96 @@
using LiteCharms.Features;
using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Products.Models;
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!;
protected bool IsLoading { get; private set; } = true;
protected Product? CurrentProduct { 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;
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";
var priceResult = await ProductService.GetProductPriceAsync(BookId);
LivePrice = priceResult.IsSuccess ? priceResult.Value.Amount : 0m;
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;
}
finally
{
IsLoading = false;
}
}
protected void IncreaseQty() => Quantity++;
protected void DecreaseQty() { if (Quantity > 1) Quantity--; }
protected void HandleAddToCart()
{
if (CurrentProduct == null) return;
}
protected void ViewAllAuthorBooks()
{
if (CurrentAuthor is not null)
Navigation.NavigateTo($"/?authorId={CurrentAuthor.Id}");
}
}
@@ -0,0 +1,379 @@
/* ==========================================================================
Structural Layout Containers
========================================================================== */
.product-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
font-family: system-ui, -apple-system, sans-serif;
}
.product-layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 4rem;
}
/* ==========================================================================
Breadcrumb Navigation Links
========================================================================== */
.breadcrumb {
font-size: 0.85rem;
margin-bottom: 2.5rem;
color: #8c8c8c;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.crumb-link {
cursor: pointer;
transition: color 0.2s ease;
}
.crumb-link:hover {
color: #111;
}
.crumb-separator {
margin: 0 0.5rem;
}
.crumb-current {
color: #111;
font-weight: 500;
}
/* ==========================================================================
Left Section: Media Gallery Components
========================================================================== */
.gallery-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.main-image-wrapper {
position: relative;
background-color: #f9f9f9;
border-radius: 8px;
padding: 3rem 3rem 4.5rem 3rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 480px;
width: 100%;
}
.main-image {
max-height: 450px;
max-width: 100%;
object-fit: contain;
mix-blend-mode: multiply;
}
/* Dynamic Overlaid Attribute Badges - Centered flawlessly without stacking */
.format-badges {
position: absolute !important;
bottom: 1.5rem !important;
left: 50% !important; /* Move anchor point to exactly half-width */
right: auto !important;
transform: translateX(-50%) !important; /* Offset width symmetrically to center perfectly */
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important; /* Firmly prevents items from stacking vertically */
justify-content: center !important;
align-items: center !important;
gap: 0.5rem !important;
width: max-content !important; /* Expands safely according to badges text values */
max-width: 90% !important; /* Keeps bounding box clean of parent borders */
}
.badge {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.35rem 0.75rem;
border-radius: 100px;
font-weight: 600;
border: 1px solid transparent;
display: inline-block !important;
white-space: nowrap !important; /* Protects badge inner labels from broken line wrapping */
}
.badge-physical {
background-color: #111;
color: #fff;
}
.badge-ebook {
background-color: #fff;
color: #111;
border-color: #e5e5e5;
}
.badge-online {
background-color: #f0fdf4;
color: #166534;
border-color: #bbf7d0;
}
/* Interactive Gallery Thumbnails Grid */
.thumbnail-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.thumbnail-wrapper {
width: 80px;
height: 80px;
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 0.5rem;
cursor: pointer;
background: #f9f9f9;
transition: all 0.2s ease;
}
.thumbnail-wrapper:hover,
.thumbnail-wrapper.active {
border-color: #111;
}
.thumbnail-wrapper img {
width: 100%;
height: 100%;
object-fit: contain;
mix-blend-mode: multiply;
}
/* ==========================================================================
Right Section: Product Details & Typography Controls
========================================================================== */
.details-section {
display: flex;
flex-direction: column;
}
.meta-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.author-name {
font-size: 1.1rem;
color: #666;
}
/* Review Star Components */
.rating-stars {
display: flex;
align-items: center;
gap: 0.2rem;
}
.star {
color: #e5e5e5;
font-size: 1.1rem;
}
.star.filled {
color: #ffc107;
}
.rating-text {
font-size: 0.85rem;
color: #666;
margin-left: 0.4rem;
}
.product-title {
font-size: 2.5rem;
font-family: 'Playfair Display', serif, Georgia;
font-weight: 400;
line-height: 1.2;
margin-bottom: 1rem;
color: #111;
}
.product-price {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 2rem;
color: #111;
}
/* ==========================================================================
Interactive E-Commerce Selection Bars
========================================================================== */
.purchase-actions {
display: flex;
gap: 1rem;
margin-bottom: 2.5rem;
}
.quantity-picker {
display: flex;
align-items: center;
border: 1px solid #e5e5e5;
border-radius: 100px;
padding: 0.25rem 0.5rem;
}
.qty-btn {
background: none;
border: none;
width: 36px;
height: 36px;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.qty-btn:hover {
background-color: #f5f5f5;
}
.qty-val {
min-width: 30px;
text-align: center;
font-weight: 500;
}
.btn-add-to-cart {
flex-grow: 1;
background-color: #111;
color: #fff;
border: none;
border-radius: 100px;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
transition: background-color 0.2s ease;
padding: 0.5rem 1.5rem;
}
.btn-add-to-cart:hover {
background-color: #2a2a2a;
}
.divider {
border: 0;
border-top: 1px solid #eee;
margin-bottom: 2rem;
}
/* ==========================================================================
Informational Text Elements & Links
========================================================================== */
.info-block {
margin-bottom: 2rem;
}
.info-block h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8c8c8c;
margin-bottom: 0.75rem;
}
.description-text,
.author-bio {
font-size: 0.95rem;
line-height: 1.6;
color: #444;
}
.author-bio-card {
background-color: #fafafa;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.btn-text-link {
background: none;
border: none;
padding: 0;
margin-top: 1rem;
font-weight: 600;
font-size: 0.9rem;
color: #111;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 4px;
}
.btn-text-link:hover {
color: #444;
}
/* ==========================================================================
Responsive Adaptations & Breakpoints
========================================================================== */
@media (max-width: 992px) {
.product-layout {
grid-template-columns: 1fr;
gap: 2.5rem;
}
.main-image-wrapper {
min-height: 380px;
}
}
@media (max-width: 576px) {
.product-container {
padding: 1rem 1rem;
}
.main-image-wrapper {
min-height: 320px;
padding: 2rem 1rem 4.5rem 1rem; /* Extra padding baseline clearance */
}
.main-image {
max-height: 240px; /* Fluidly limits image height to avoid overlay overlapping */
}
.format-badges {
bottom: 1.25rem !important; /* Fits beautifully aligned at the bottom center */
gap: 0.4rem !important;
}
.badge {
font-size: 0.65rem !important; /* Clean uniform scaling factor to clear margins */
padding: 0.25rem 0.65rem !important;
}
.product-title {
font-size: 1.85rem;
}
.purchase-actions {
flex-direction: column;
gap: 0.75rem;
}
.quantity-picker {
justify-content: space-between;
width: 100%;
padding: 0.5rem;
}
.btn-add-to-cart {
width: 100%;
padding: 0.85rem;
}
}
@@ -0,0 +1,377 @@
@page "/profile"
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@rendermode InteractiveServer
<AuthorizeView>
<Authorized>
<div class="container py-5">
<h2 class="fw-bold mb-5 tracking-tight">My Account</h2>
<div class="row g-5">
<div class="col-md-3">
<div class="nav flex-column nav-pills" role="tablist">
<button class="nav-link active text-start" data-bs-toggle="pill" data-bs-target="#orders" role="tab">Order History</button>
<button class="nav-link text-start" data-bs-toggle="pill" data-bs-target="#shipping" role="tab">Shipping Address</button>
<button class="nav-link text-start" data-bs-toggle="pill" data-bs-target="#profile" role="tab">Profile Settings</button>
<hr />
<button class="nav-link text-danger text-start" @onclick="TriggerLogout">Logout</button>
</div>
</div>
<div class="col-md-9">
<div class="tab-content">
<div class="tab-pane fade show active" id="orders" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0">Order History</h5>
</div>
<div class="d-flex flex-column gap-3">
@if (orderHistory != null)
{
@foreach (var order in orderHistory)
{
<div class="card p-4 shadow-sm order-history-card">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start gap-3">
<div class="flex-grow-1 w-100">
<div class="order-meta-track mb-2">
<div class="meta-item-id">
<span class="fw-bold text-dark">@order.OrderId</span>
</div>
<div class="meta-item-date">
<span class="text-muted small">@order.OrderDate.ToString("MMM dd, yyyy")</span>
</div>
<div class="meta-item-status">
<span class="badge @(order.Status?.ToLower() == "shipped" ? "status-shipped" : "status-delivered") text-uppercase">
@order.Status
</span>
</div>
</div>
<h6 class="mb-2">
<a href="/products/@order.ProductId" class="product-link fw-medium" title="@order.ProductTitle">
@order.ProductTitle
</a>
</h6>
<div class="d-flex align-items-center text-secondary small">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="12" height="12" fill="currentColor" class="me-1 text-muted flex-shrink-0">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
<span class="text-muted">Shipped to:</span>&nbsp;@order.ShippingAddressName
</div>
</div>
<div class="d-flex flex-row flex-sm-column align-items-center align-items-sm-end justify-content-between w-100 w-sm-auto pt-2 pt-sm-0 border-top border-sm-top-0 border-light">
<div class="text-sm-end mb-sm-2">
<span class="text-muted xx-small d-block text-uppercase font-monospace tracking-wider">Total Paid</span>
<span class="fw-bold text-dark fs-5">R @order.Total.ToString("N2")</span>
</div>
<button class="btn btn-link p-0 text-dark action-btn mt-sm-1" title="Download Invoice" @onclick="() => DownloadInvoice(order.OrderId)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="svg-icon">
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zm-6 .67l2.59-2.58L17 11.5l-5 5-5-5 1.41-1.41L11 12.67V3h2v9.67z" />
</svg>
</button>
</div>
</div>
</div>
}
}
else
{
<div class="card p-4 text-center text-muted">
Loading order history...
</div>
}
</div>
</div>
<div class="tab-pane fade" id="shipping" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0">Saved Addresses</h5>
@if (!showAddForm && editingAddress == null)
{
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="OpenAddForm">+ Add New</button>
}
</div>
@if (showAddForm)
{
<div class="card p-4 border shadow-sm mb-4 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold m-0">New Address</h6>
<button type="button" class="btn-close" @onclick="() => showAddForm = false"></button>
</div>
<input type="text" class="form-control mb-2" placeholder="Address Name (e.g. Home, Office)" @bind="newAddressName" />
<input type="text" class="form-control mb-2" placeholder="Street Address" @bind="newStreetAddress" />
<div class="d-flex gap-2 mb-3">
<input type="text" class="form-control" placeholder="City" @bind="newCity" />
<input type="text" class="form-control" placeholder="Postal Code" @bind="newPostalCode" />
</div>
<div class="mb-3 d-flex gap-3">
<label class="pointer-label"><input type="checkbox" @bind="isBilling" /> Billing</label>
<label class="pointer-label"><input type="checkbox" @bind="isShipping" /> Shipping</label>
</div>
<div class="d-flex">
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="SaveAddress">Save Address</button>
</div>
</div>
}
@if (editingAddress != null)
{
<div class="card p-4 border shadow-sm mb-4 bg-light">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold m-0">Edit Address</h6>
<button type="button" class="btn-close" @onclick="CancelEditing"></button>
</div>
<input type="text" class="form-control mb-2" placeholder="Address Name" @bind="editingAddress.Name" />
<input type="text" class="form-control mb-2" placeholder="Street Address" @bind="editingAddress.Street" />
<div class="d-flex gap-2 mb-3">
<input type="text" class="form-control" placeholder="City" @bind="editingAddress.City" />
<input type="text" class="form-control" placeholder="Postal Code" @bind="editingAddress.PostalCode" />
</div>
<div class="mb-3 d-flex gap-3">
<label class="pointer-label"><input type="checkbox" @bind="editingAddress.IsBilling" /> Billing</label>
<label class="pointer-label"><input type="checkbox" @bind="editingAddress.IsShipping" /> Shipping</label>
</div>
<div class="d-flex">
<button class="btn btn-dark btn-sm rounded-pill px-4" @onclick="UpdateAddress">Update Address</button>
</div>
</div>
}
@foreach (var addr in savedAddresses)
{
<div class="card p-4 shadow-sm mb-3 address-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="fw-bold mb-1">@addr.Name</h6>
<p class="mb-2 text-muted">@addr.Street, @addr.City, @addr.PostalCode</p>
<div class="d-flex gap-2 text-uppercase font-monospace text-muted small">
@if (addr.IsBilling)
{
<span class="badge badge-tag">[Billing]</span>
}
@if (addr.IsShipping)
{
<span class="badge badge-tag">[Shipping]</span>
}
</div>
</div>
<div class="d-flex align-items-center gap-2 actions-container">
<label class="small text-muted d-flex align-items-center gap-1 m-0 pointer-label me-2">
<input type="checkbox" checked="@addr.IsPrimary" @onchange="(e) => SetPrimary(addr, e)" /> Primary
</label>
<button class="btn btn-link p-0 text-dark action-btn" title="Edit Address" @onclick="() => StartEditing(addr)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="svg-icon">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
</button>
<button class="btn btn-link p-0 text-danger action-btn" title="Delete Address" @onclick="() => DeleteAddress(addr)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="svg-icon">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
</div>
</div>
}
</div>
<div class="tab-pane fade" id="profile" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold m-0">Profile Settings</h5>
</div>
<div class="card p-4 shadow-sm">
<p class="text-muted mb-0">Manage your password and profile data here.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Authorized>
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeView>
@code {
private bool showAddForm = false;
private AddressItem? editingAddress = null;
private string newAddressName = "";
private string newStreetAddress = "";
private string newCity = "";
private string newPostalCode = "";
private bool isBilling, isShipping;
private List<OrderItem> orderHistory = new()
{
new OrderItem { OrderId = "#MB-2026-9481", ProductId = "introduction-to-blazor", ProductTitle = "Introduction to Blazor WebAssembly Framework Development", OrderDate = new DateTime(2026, 5, 20), ShippingAddressName = "Home Address", Status = "Shipped", Total = 720.00 },
new OrderItem { OrderId = "#MB-2026-8712", ProductId = "mastering-css-isolation", ProductTitle = "Mastering CSS Isolation in Modern .NET Web Applications Architecture", OrderDate = new DateTime(2026, 4, 14), ShippingAddressName = "Midrand Books Warehouse", Status = "Delivered", Total = 890.00 }
};
private List<AddressItem> savedAddresses = new()
{
new AddressItem { Id = 1, Name = "Home Address", Street = "12 Main Road", City = "Midrand", PostalCode = "1685", IsBilling = true, IsShipping = true, IsPrimary = true },
new AddressItem { Id = 2, Name = "Corporate Office", Street = "45 Challink Street", City = "Halfway House", PostalCode = "1682", IsBilling = true, IsShipping = false, IsPrimary = false },
new AddressItem { Id = 3, Name = "Midrand Books Warehouse", Street = "Unit 8, Corporate Park North", City = "Randjespark", PostalCode = "1683", IsBilling = false, IsShipping = true, IsPrimary = false }
};
private void TriggerLogout() => Navigation.NavigateTo("/logout", forceLoad: true);
private void DownloadInvoice(string orderId)
{
// Handle invoice downloading logic here
}
private void OpenAddForm()
{
editingAddress = null;
showAddForm = true;
}
private void SaveAddress()
{
if (!string.IsNullOrWhiteSpace(newAddressName) && !string.IsNullOrWhiteSpace(newStreetAddress))
{
var nextId = savedAddresses.Any() ? savedAddresses.Max(a => a.Id) + 1 : 1;
var newItem = new AddressItem
{
Id = nextId,
Name = newAddressName,
Street = newStreetAddress,
City = newCity,
PostalCode = newPostalCode,
IsBilling = isBilling,
IsShipping = isShipping,
IsPrimary = !savedAddresses.Any()
};
savedAddresses.Add(newItem);
ResetAddForm();
}
}
private void ResetAddForm()
{
newAddressName = "";
newStreetAddress = "";
newCity = "";
newPostalCode = "";
isBilling = false;
isShipping = false;
showAddForm = false;
}
private void StartEditing(AddressItem addr)
{
showAddForm = false;
editingAddress = new AddressItem
{
Id = addr.Id,
Name = addr.Name,
Street = addr.Street,
City = addr.City,
PostalCode = addr.PostalCode,
IsBilling = addr.IsBilling,
IsShipping = addr.IsShipping,
IsPrimary = addr.IsPrimary
};
}
private void UpdateAddress()
{
if (editingAddress != null)
{
var target = savedAddresses.FirstOrDefault(a => a.Id == editingAddress.Id);
if (target != null)
{
target.Name = editingAddress.Name;
target.Street = editingAddress.Street;
target.City = editingAddress.City;
target.PostalCode = editingAddress.PostalCode;
target.IsBilling = editingAddress.IsBilling;
target.IsShipping = editingAddress.IsShipping;
}
editingAddress = null;
}
}
private void CancelEditing()
{
editingAddress = null;
}
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;
}
}
public class AddressItem
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
public bool IsBilling { get; set; }
public bool IsShipping { get; set; }
public bool IsPrimary { get; set; }
}
public class OrderItem
{
public string OrderId { get; set; } = "";
public string ProductId { get; set; } = "";
public string ProductTitle { get; set; } = "";
public DateTime OrderDate { get; set; }
public string ShippingAddressName { get; set; } = "";
public string Status { get; set; } = "";
public double Total { get; set; }
public string DisplayTitle
{
get
{
if (string.IsNullOrWhiteSpace(ProductTitle)) return "";
const int maxLength = 21; // Shifted slightly down from 25 to protect bounds against lower resolutions
return ProductTitle.Length <= maxLength
? ProductTitle
: $"{ProductTitle.Substring(0, maxLength)}...";
}
}
}
}
@@ -0,0 +1,204 @@
::deep .container {
max-width: 1100px;
}
/* Navigation Layout overrides */
.nav-pills .nav-link {
color: #6c757d;
border-radius: 0;
padding: 0.75rem 0;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-pills .nav-link.active {
background-color: transparent !important;
color: #1A1A1A;
border-bottom: 2px solid #1A1A1A;
}
.nav-pills .nav-link:hover:not(.active) {
color: #1A1A1A;
}
/* Cards layout rules */
.card {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
}
.address-card {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.address-card:hover {
border-color: rgba(0, 0, 0, 0.16);
}
/* Container Wrapper to Suppress the Scrollbar completely */
.table-container-fixed {
width: 100%;
overflow-x: hidden; /* Hard disables horizontal scroll bar activation */
}
/* Global Table Typography - Reduced uniformly to keep items on a single line */
.profile-table {
font-size: 0.78rem; /* Scaled down further to eliminate overflow bounds */
width: 100%;
table-layout: fixed; /* Fixes proportions to fit 100% parent container space */
}
.profile-table tbody td {
padding-top: 0.85rem;
padding-bottom: 0.85rem;
}
.profile-table thead th {
background-color: #F9F9F9;
font-size: 0.7rem;
letter-spacing: 0.04rem;
}
/* Compact Column Proportions */
.col-order-id {
width: 115px;
}
.col-date {
width: 100px;
}
.col-total {
width: 85px;
}
.col-status {
width: 105px;
}
.col-invoice {
width: 65px;
}
.col-title {
width: auto; /* Takes shared residual space smoothly */
}
.col-address {
width: auto; /* Takes shared residual space smoothly */
}
/* Product link handling */
.product-link {
color: #1A1A1A;
text-decoration: none;
border-bottom: 1px dashed transparent;
transition: border-color 0.2s ease;
display: inline-block;
}
.product-link:hover {
border-color: #1A1A1A;
}
/* Base Badge Settings */
.badge {
font-size: 0.62rem;
letter-spacing: 0.5px;
padding: 0.4em 0.8em;
border-radius: 4px;
font-weight: 600;
}
/* Status Badge Palette Colors */
.status-shipped {
background-color: #e3f2fd !important;
color: #0d6efd !important;
border: 1px solid rgba(13, 110, 253, 0.15);
}
.status-delivered {
background-color: #e8f5e9 !important;
color: #198754 !important;
border: 1px solid rgba(25, 135, 84, 0.15);
}
.badge-tag {
background-color: #f0f0f0 !important;
color: #4a4a4a !important;
border: 1px solid rgba(0, 0, 0, 0.05);
}
/* Form Buttons */
.btn-outline-dark {
border-radius: 50px;
border-width: 1px;
}
.btn-dark {
border-radius: 50px;
}
/* Action button configurations */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
text-decoration: none;
transition: background-color 0.15s ease, transform 0.1s ease;
}
.action-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.action-btn.text-danger:hover {
background-color: rgba(220, 53, 69, 0.1);
}
.action-btn:active {
transform: scale(0.95);
}
/* Compact SVG Icons sizing */
.svg-icon {
width: 15px;
height: 15px;
fill: currentColor;
display: inline-block;
vertical-align: middle;
}
.pointer-label {
cursor: pointer;
user-select: none;
}
/* Shared Card Styling Unification Rules */
.order-history-card {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
border-radius: 0; /* Matches your address card configuration */
}
.order-history-card:hover {
border-color: rgba(0, 0, 0, 0.16);
}
/* Micro Typography Alignment */
.xx-small {
font-size: 0.65rem;
}
.tracking-wider {
letter-spacing: 0.05rem;
}
/* Responsive adjustments across layout break-points */
@media (max-width: 575.98px) {
.border-sm-top-0 {
border-top: 1px solid rgba(0, 0, 0, 0.06) !important;
}
}
@@ -0,0 +1,10 @@
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri);
Navigation.NavigateTo($"/login?redirectUri={Uri.EscapeDataString(returnUrl)}", forceLoad: true);
}
}
@@ -1,12 +1,15 @@
@using Microsoft.AspNetCore.Components.Authorization
@using MidrandBooks.Components.Pages
@namespace MidrandBooks
@using MidrandBookshop.Components.Pages
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
@@ -6,7 +6,6 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MidrandBooks
@using MidrandBooks.Components
@using MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Authorization;@using Microsoft.AspNetCore.Components.Authorization
@using MidrandBookshop
@using MidrandBookshop.Components
@using MidrandBookshop.Components.Layout
@@ -11,11 +11,6 @@
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<!-- Tailwind compile target -->
<Target Name="BuildTailwind" BeforeTargets="Build">
<Exec Command="yarn tailwindcss -i ./Styles/app.css -o ./wwwroot/app.min.css" />
</Target>
<!-- file nesting -->
<ItemGroup>
<ProjectCapability Include="ConfigurableFileNesting" />
@@ -23,15 +18,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="LiteCharms.Features" Version="1.137.0" />
<PackageReference Include="LiteCharms.Features" Version="1.84.0" />
</ItemGroup>
<!-- UI -->
<ItemGroup>
<PackageReference Include="ANM.Blazored.Toast" Version="0.1.1" />
<PackageReference Include="LiteCharms.Features.MidrandBooks" Version="1.137.0" />
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="LiteCharms.Features.MidrandBooks" Version="1.84.0" />
<!-- Global Usings -->
<Using Include="Blazored.Toast.Services" />
@@ -58,18 +51,7 @@
<!-- Shared Global Usings -->
<ItemGroup>
<Using Include="Blazored.Toast" />
<Using Include="Microsoft.JSInterop" />
<Using Include="System.Globalization" />
<Using Include="System.Security.Claims" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.AspNetCore.HttpOverrides" />
<Using Include="Microsoft.AspNetCore.Components.Authorization" />
<Using Include="Microsoft.AspNetCore.Components.Routing" />
<Using Include="Microsoft.AspNetCore.Components.Web" />
<Using Include="Microsoft.AspNetCore.WebUtilities" />
<Using Include="Microsoft.AspNetCore.Components" />
<Using Include="System.Security.Cryptography.X509Certificates" />
</ItemGroup>
</Project>
+76
View File
@@ -0,0 +1,76 @@
using LiteCharms.Features.Extensions;
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using MidrandBookshop.Components;
using static LiteCharms.Features.Extensions.Quartz;
AppContext.SetSwitch("Microsoft.IdentityModel.DisableTelemetry", true);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.AddMonitoring();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddMediator();
builder.Services.AddAuthentikUiSecurity(builder.Configuration);
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzSchedulerClient(MidrandShopSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddEmailServiceBus();
builder.Services.AddHttpClient();
builder.Services.AddShopServices();
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddMidrandShopDatabase(builder.Configuration);
builder.Services.AddMidrandShopPostgresHealthCheck();
builder.Services.AddMidrandShopQuartzHealthCheck();
builder.Services.AddHealthChecksSupport(builder.Configuration);
builder.Services.AddCascadingAuthenticationState();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
app.AddSecurityEndpoints();
var schedulerFactory = app.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler(MidrandShopSchedulerName);
if (!scheduler!.IsStarted)
await scheduler.Start();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse
});
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -13,8 +13,8 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:8443;http://localhost:8080",
"launchBrowser": true,
"applicationUrl": "https://localhost:7021;http://localhost:5053",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -1,21 +1,8 @@
{
"PayfastSettings": {
"CheckoutUrl": "https://sandbox.payfast.co.za/eng/process",
"ValidHosts": [
"www.payfast.co.za",
"sandbox.payfast.co.za",
"ips.payfast.co.za",
"api.payfast.co.za",
"payment.payfast.io"
]
},
"LiteCharmsSettings": {
"Authority": "https://sts.security.khongisa.co.za"
},
"LiteCharmsClientSettings": {
"Authority": "https://sts.security.khongisa.co.za",
"GrantType": "client_credentials",
"Scope": "midrandbooks-api"
"AuthentikSettings": {
"Authority": "https://id.khongisa.co.za/application/o/midrand-books-uat/",
"MetadataEndpoint": "https://id.khongisa.co.za/application/o/midrand-books-uat/.well-known/openid-configuration",
"RevokationEndpoint": "https://id.khongisa.co.za/application/o/revoke/"
},
"HasherSettings": {
"MinHashLength": 11
+114
View File
@@ -0,0 +1,114 @@
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@300;400;500;600&display=swap');
:root {
/* Fonts */
--font-heading: 'Instrument Serif', serif;
--font-ui: 'Inter', sans-serif;
/* Color Palette */
--mb-bg: #F9F9F9;
--mb-card-bg: #FFFFFF;
--mb-text-dark: #1A1A1A;
--mb-text-muted: #666666;
--mb-accent-red: #E63946;
--mb-radius: 12px;
/* High-Visibility Machined White Metal Core Surface Definition */
/* Sharp linear reflection channels paired with micro-milled density grids */
--brushed-metal-bg: linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(240, 241, 245, 0.95) 25%, rgba(255, 255, 255, 1) 50%, rgba(238, 240, 244, 0.95) 75%, rgba(255, 255, 255, 1) 100% ), repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.012) 0px, rgba(0, 0, 0, 0.012) 1px, transparent 1px, transparent 2px ), repeating-linear-gradient( 90deg, rgba(255, 255, 255, 0.9) 0px, rgba(255, 255, 255, 0.9) 1px, transparent 1px, transparent 3px ), #EDEFF4; /* Definitive satin platinum breakout base fallback */
}
/* Global Reset & Core Variables Mapping */
body {
background-color: var(--mb-bg);
color: var(--mb-text-dark);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, .display-font {
font-family: var(--font-heading);
font-weight: 400;
}
/* Custom Navigation Pill Styling */
.nav-pill-wrapper {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
padding: 6px;
border-radius: 50px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.02);
}
.nav-pill-link {
font-size: 0.9rem;
font-weight: 500;
color: var(--mb-text-muted);
padding: 8px 20px;
border-radius: 50px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.nav-pill-link i {
width: 16px;
height: 16px;
stroke-width: 1.5;
}
/* Navigation Pill States */
.nav-pill-link:hover {
color: var(--mb-text-dark);
background-color: #F1F1F1;
}
.nav-pill-link.active {
color: #FFFFFF !important;
background-color: var(--mb-text-dark);
}
.nav-pill-link.active svg {
stroke: #FFFFFF;
}
/* Utilities & Component Support Layouts */
.cart-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: var(--mb-text-dark);
color: #FFFFFF;
font-size: 0.7rem;
font-weight: 600;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--mb-bg);
}
.transition-smooth {
transition: color 0.2s ease;
}
.transition-smooth:hover {
color: var(--mb-text-dark) !important;
}
.py-1-5 {
padding-top: 0.35rem !important;
padding-bottom: 0.35rem !important;
}
/* Global Focus Ring Corrections */
h1:focus, h2:focus, h3:focus, h4:focus, p:focus, div:focus, span:focus {
outline: none !important;
box-shadow: none !important;
}
[tabindex="-1"]:focus {
outline: none !important;
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More