Updated design
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
@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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@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 & 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 & 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>
|
||||
@@ -0,0 +1,308 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
@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 & 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
@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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
@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 & 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@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 & 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
@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 & 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
@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>
|
||||
@@ -0,0 +1,455 @@
|
||||
@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 & Moderation Panel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
@if (ActiveTab == "publish")
|
||||
{
|
||||
<!-- Publish Tab Content -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- Left Form Section (8 cols) -->
|
||||
<div class="lg:col-span-7 space-y-5">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-serif text-lg font-bold text-slate-800">Establish Direct SA Distribution</h3>
|
||||
<p class="text-[11px] text-slate-500 leading-relaxed">
|
||||
Upload your manuscripts directly. Your files are converted in the background into copy-protected, DRM-sandboxed reflowable epub formats ready for legal distribution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Title Input -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Manuscript Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Whispers of the Highveld"
|
||||
@bind="NewTitle"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category Selector with Custom Creation Options -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Genre Category</label>
|
||||
<button
|
||||
type="button"
|
||||
@onclick="ToggleCustomCategory"
|
||||
class="text-[10px] text-violet-600 hover:text-violet-500 font-bold font-mono flex items-center gap-1 cursor-pointer">
|
||||
@(ShowCustomCategoryInput ? "Existing" : "New Genre")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (ShowCustomCategoryInput)
|
||||
{
|
||||
<div class="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Afrofuturism"
|
||||
@bind="CustomCategoryName"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@onclick="SaveCustomCategory"
|
||||
class="px-3 rounded-xl bg-violet-600 hover:bg-violet-500 text-white text-xs font-bold cursor-pointer transition-all">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select
|
||||
@bind="SelectedCategory"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-800 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all">
|
||||
@foreach (var cat in Categories)
|
||||
{
|
||||
<option value="@cat">@cat</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">South African Pricing (ZAR)</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-3 flex items-center text-slate-500 font-mono text-xs font-bold">R</span>
|
||||
<input
|
||||
type="number"
|
||||
@bind="NewPrice"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 pl-7 pr-3 py-2.5 text-xs text-slate-800 font-mono outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag and Drop Cover Art Image Upload Area -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase block">Custom Cover Artwork Image (Optional)</label>
|
||||
@if (!string.IsNullOrEmpty(CoverImagePreview))
|
||||
{
|
||||
<button
|
||||
type="button"
|
||||
@onclick="ClearCoverImage"
|
||||
class="text-[9px] text-red-500 hover:text-red-400 font-mono cursor-pointer">
|
||||
Clear Image
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border border-dashed rounded-xl p-3 text-center transition-all cursor-pointer flex items-center justify-center gap-3 @(string.IsNullOrEmpty(CoverImagePreview) ? "border-slate-200 bg-slate-50/50 hover:bg-slate-50 text-slate-500" : "border-violet-300 bg-violet-50/10")"
|
||||
@onclick="TriggerCoverUpload">
|
||||
|
||||
@if (!string.IsNullOrEmpty(CoverImagePreview))
|
||||
{
|
||||
<div class="flex items-center gap-3 w-full text-left">
|
||||
<img src="@CoverImagePreview" alt="Cover preview" class="h-10 w-8 rounded object-cover shadow-sm flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block text-[11px] font-bold text-slate-700 truncate font-mono">@CoverImageName</span>
|
||||
<span class="block text-[9px] text-slate-400">Custom image set as primary book artwork</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center gap-2 text-slate-400 py-1">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span class="text-[11px] font-bold text-slate-600">Simulate Drag/Drop or click to upload Cover Art</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brief blurb description -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-mono text-slate-500 font-bold uppercase">Brief Book Description (Blurb)</label>
|
||||
<textarea
|
||||
placeholder="Summarize your story for prospective buyers..."
|
||||
@bind="NewDescription"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-800 placeholder-slate-400 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@onclick="PublishManuscript"
|
||||
class="w-full py-4 rounded-xl bg-violet-600 hover:bg-violet-500 text-white font-bold text-xs shadow-md cursor-pointer transition-all flex items-center justify-center gap-2">
|
||||
Publish Manuscript for Moderation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right Info / Guidelines Column (5 cols) -->
|
||||
<div class="lg:col-span-5 bg-slate-50 rounded-2xl p-5 border border-slate-100 space-y-4">
|
||||
<h4 class="font-serif text-sm font-bold text-slate-800 flex items-center gap-1.5">
|
||||
⚖ Publishing Laws & Moderation Mandates
|
||||
</h4>
|
||||
|
||||
<div class="space-y-3.5 text-slate-600 text-[11px] leading-relaxed">
|
||||
<p>
|
||||
Midrand Books operates in direct conformity with the **South African National Library Services Act and Copyright protection provisions**. Every submission undergoes rigorous verification:
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">1</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Immediate Background Conversion</strong>
|
||||
Files are dispatched to an asynchronous server node to establish reflowable reading templates, embedded copy-protection, and sandboxed audio DRM loops.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">2</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Editorial Moderation Curation</strong>
|
||||
All books must meet formatting criteria and pass legal compliance checks prior to public listing.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<span class="p-1 rounded bg-violet-100 text-violet-700 h-5 w-5 flex items-center justify-center text-[10px] font-bold">3</span>
|
||||
<div>
|
||||
<strong class="text-slate-800 block">Real-time Correction Logs</strong>
|
||||
If issues are found, the system records moderation logs and returns feedback so authors can easily submit updated artwork or texts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (ActiveTab == "catalog")
|
||||
{
|
||||
<!-- Catalog Tab Content with simulator capabilities -->
|
||||
<div class="space-y-6">
|
||||
@if (MyBooks.Any())
|
||||
{
|
||||
@foreach (var book in MyBooks)
|
||||
{
|
||||
<div class="border border-slate-200 bg-slate-50/50 rounded-2xl p-5 flex flex-col md:flex-row gap-5 transition-all hover:bg-white hover:shadow-md relative overflow-hidden">
|
||||
|
||||
<!-- Jacket Mockup -->
|
||||
<div class="h-36 w-24 rounded-xl bg-slate-900 flex-shrink-0 shadow-md relative overflow-hidden border border-slate-200/80 mx-auto md:mx-0">
|
||||
@if (!string.IsNullOrEmpty(book.CoverUrl))
|
||||
{
|
||||
<img src="@book.CoverUrl" alt="@book.Title" class="h-full w-full object-cover" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="h-full w-full bg-gradient-to-br @book.CoverColor p-2.5 flex flex-col justify-between text-white">
|
||||
<span class="text-[7px] font-mono font-bold tracking-widest uppercase text-white/50">@book.Category</span>
|
||||
<span class="text-[10px] font-serif font-extrabold line-clamp-3 leading-tight">@book.Title</span>
|
||||
<span class="text-[7px] font-sans text-white/85 truncate block">By @book.Author</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details and simulation buttons -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between space-y-4">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="font-serif text-base font-bold text-slate-800 leading-snug">@book.Title</h4>
|
||||
<p class="text-[10px] text-slate-500 font-mono mt-0.5">Category: @book.Category • Price: R @book.Price.ToString("F2")</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moderation Box -->
|
||||
<div class="p-3.5 rounded-xl border bg-white space-y-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[10px] font-mono font-bold uppercase text-slate-400">Moderation Status</span>
|
||||
|
||||
@if (book.Status == "published")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Live & Active
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "converting")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 animate-pulse">
|
||||
Converting PDF...
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "pending_review")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-blue-50 text-blue-700 border border-blue-100">
|
||||
Pending Human Review
|
||||
</span>
|
||||
}
|
||||
else if (book.Status == "rejected")
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-50 text-rose-700 border border-rose-100">
|
||||
Rejected / Revision Required
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-red-100 text-red-800 border border-red-200">
|
||||
Compliance Take Down
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-slate-600 leading-relaxed">
|
||||
<strong>Moderator Feedback:</strong> @(book.ModerationFeedback ?? "No feedback provided yet.")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Live Status Simulations (Allows testing reviewer outcomes instantly) -->
|
||||
<div class="border-t border-slate-100 pt-3 flex flex-wrap items-center justify-between gap-3 bg-violet-50/10 p-3 rounded-xl border border-violet-500/10">
|
||||
<span class="text-[10px] font-bold font-mono text-violet-700">Simulate Curator Actions:</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "published", "Curation review passed successfully. Compliant with legal deposit requirements. Published to public index.")'
|
||||
class="px-2.5 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "rejected", "Low resolution cover art detected on jacket preview. Please re-upload high contrast art.")'
|
||||
class="px-2.5 py-1 rounded bg-amber-600 hover:bg-amber-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
@onclick='() => SimulateAction(book.Id, "taken_down", "Copyright violation match detected. Digital Millenium Copyright Act claim registered.")'
|
||||
class="px-2.5 py-1 rounded bg-rose-600 hover:bg-rose-500 text-white text-[9px] font-bold cursor-pointer transition-colors">
|
||||
Take Down
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-12 text-center border border-dashed border-slate-200 bg-slate-50/50 rounded-3xl space-y-2">
|
||||
<svg class="mx-auto h-8 w-8 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<h4 class="font-serif text-sm font-bold text-slate-700">No self-published titles registered</h4>
|
||||
<p class="text-[10px] text-slate-500 font-sans max-w-sm mx-auto leading-relaxed">
|
||||
Create and publish your very first manuscript on the adjacent tab to load tracking, logs, and moderation tools!
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<string> Categories { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnAddCategory { get; set; }
|
||||
[Parameter] public EventCallback<BookModel> OnPublishBook { get; set; }
|
||||
|
||||
private string ActiveTab { get; set; } = "publish";
|
||||
private string NewTitle { get; set; } = string.Empty;
|
||||
private string SelectedCategory { get; set; } = "Fiction";
|
||||
private string CustomCategoryName { get; set; } = string.Empty;
|
||||
private bool ShowCustomCategoryInput { get; set; } = false;
|
||||
private double NewPrice { get; set; } = 120.00;
|
||||
private string NewDescription { get; set; } = string.Empty;
|
||||
|
||||
// Cover art simulation previews
|
||||
private string CoverImagePreview { get; set; } = string.Empty;
|
||||
private string CoverImageName { get; set; } = string.Empty;
|
||||
|
||||
private List<BookModel> MyBooks { get; set; } = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Categories.Any())
|
||||
{
|
||||
SelectedCategory = Categories.First();
|
||||
}
|
||||
|
||||
// Add pre-loaded simulated book for the user on portal initialization
|
||||
MyBooks.Add(new BookModel
|
||||
{
|
||||
Id = "pre-loaded-1",
|
||||
Title = "Highveld Sunset Stories",
|
||||
Author = "Author Khwezi",
|
||||
AuthorId = "khwezi-auth-1",
|
||||
Price = 145.00,
|
||||
Category = "African Literature",
|
||||
CoverColor = "from-rose-950 to-orange-900",
|
||||
Status = "pending_review",
|
||||
ModerationFeedback = "In queue for legal deposit assessment.",
|
||||
ModerationHistory = new List<ModerationHistoryEntry>
|
||||
{
|
||||
new ModerationHistoryEntry { Status = "converting", Date = "12/07/2026 10:14", Feedback = "Background compilation complete." }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SetActiveTab(string tab)
|
||||
{
|
||||
ActiveTab = tab;
|
||||
}
|
||||
|
||||
private void ToggleCustomCategory()
|
||||
{
|
||||
ShowCustomCategoryInput = !ShowCustomCategoryInput;
|
||||
}
|
||||
|
||||
private async Task SaveCustomCategory()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CustomCategoryName))
|
||||
{
|
||||
var cat = CustomCategoryName.Trim();
|
||||
await OnAddCategory.InvokeAsync(cat);
|
||||
SelectedCategory = cat;
|
||||
CustomCategoryName = string.Empty;
|
||||
ShowCustomCategoryInput = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerCoverUpload()
|
||||
{
|
||||
// Simulated cover file selection action
|
||||
CoverImagePreview = "https://images.unsplash.com/photo-1543002588-bfa74002ed7e?q=80&w=600&auto=format&fit=crop";
|
||||
CoverImageName = "sunset-artwork-preview-hires.jpg";
|
||||
}
|
||||
|
||||
private void ClearCoverImage()
|
||||
{
|
||||
CoverImagePreview = string.Empty;
|
||||
CoverImageName = string.Empty;
|
||||
}
|
||||
|
||||
private async Task PublishManuscript()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewTitle)) return;
|
||||
|
||||
var book = new BookModel
|
||||
{
|
||||
Id = "self-pub-" + Guid.NewGuid().ToString().Substring(0, 8),
|
||||
Title = NewTitle,
|
||||
Author = "Author Khwezi",
|
||||
Price = NewPrice,
|
||||
Category = SelectedCategory,
|
||||
CoverUrl = CoverImagePreview,
|
||||
CoverColor = "from-indigo-900 to-purple-950",
|
||||
Description = NewDescription,
|
||||
Status = "converting",
|
||||
ModerationFeedback = "Background ingestion process active. Converting raw PDF structure to secure reflowable ePub nodes...",
|
||||
ModerationHistory = new List<ModerationHistoryEntry>
|
||||
{
|
||||
new ModerationHistoryEntry
|
||||
{
|
||||
Status = "converting",
|
||||
Date = DateTime.Now.ToString("dd/MM/yyyy HH:mm"),
|
||||
Feedback = "Manuscript uploaded. Worker assigned."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
MyBooks.Add(book);
|
||||
await OnPublishBook.InvokeAsync(book);
|
||||
|
||||
// Reset inputs
|
||||
NewTitle = string.Empty;
|
||||
NewDescription = string.Empty;
|
||||
CoverImagePreview = string.Empty;
|
||||
CoverImageName = string.Empty;
|
||||
ActiveTab = "catalog";
|
||||
}
|
||||
|
||||
private void SimulateAction(string bookId, string targetStatus, string feedback)
|
||||
{
|
||||
var book = MyBooks.FirstOrDefault(b => b.Id == bookId);
|
||||
if (book != null)
|
||||
{
|
||||
book.Status = targetStatus;
|
||||
book.ModerationFeedback = feedback;
|
||||
book.ModerationHistory.Insert(0, new ModerationHistoryEntry
|
||||
{
|
||||
Status = targetStatus,
|
||||
Date = DateTime.Now.ToString("dd/MM/yyyy HH:mm"),
|
||||
Feedback = feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@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>
|
||||
@@ -0,0 +1,223 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@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 & 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 & 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 & 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' }});
|
||||
}}
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
@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 & 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 & VAT
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-logistics")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
3. Delivery & Risk
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-cooling")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
4. CPA Cooling-Off
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-liability")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4 text-rose-300 hover:text-rose-200">
|
||||
5. Liability Shield
|
||||
</button>
|
||||
<button @onclick='() => ScrollToSection("section-jurisdiction")' class="px-3 py-2.5 rounded-xl hover:bg-white/5 text-left transition-all hover:text-white cursor-pointer hover:pl-4">
|
||||
6. Jurisdiction
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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, & 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 & 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 & 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 & 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' }});
|
||||
}}
|
||||
");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using MidrandBooks.Components.Pages
|
||||
@namespace MidrandBooks
|
||||
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<NotFound />
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
@@ -0,0 +1,12 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@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
|
||||
Reference in New Issue
Block a user