Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
d8a7fc4
1
Parent(s): d3d3b46
add: authorized pages, firebase
Browse files- Backend/Features/Notification/NotificationService.cs +7 -7
- Backend/Program.cs +4 -4
- BlazorWebAssembly/Layout/MainLayout.razor +76 -12
- BlazorWebAssembly/Layout/NavMenu.razor +8 -0
- BlazorWebAssembly/Models/MemberDetailsViewModel.cs +12 -0
- BlazorWebAssembly/Models/PagedResult.cs +13 -0
- BlazorWebAssembly/Pages/About.razor +91 -14
- BlazorWebAssembly/Pages/Books.razor +644 -89
- BlazorWebAssembly/Pages/Borrowings.razor +43 -8
- BlazorWebAssembly/Pages/BorrowingsManage.razor +255 -0
- BlazorWebAssembly/Pages/Contact.razor +77 -25
- BlazorWebAssembly/Pages/FAQ.razor +78 -0
- BlazorWebAssembly/Pages/Features.razor +87 -0
- BlazorWebAssembly/Pages/Home.razor +281 -84
- BlazorWebAssembly/Pages/Members.razor +609 -0
- BlazorWebAssembly/Pages/Membership.razor +87 -44
- BlazorWebAssembly/Pages/Privacy.razor +42 -0
- BlazorWebAssembly/Pages/Rewards.razor +6 -1
- BlazorWebAssembly/Pages/RewardsManage.razor +308 -0
- BlazorWebAssembly/Pages/Terms.razor +42 -0
- BlazorWebAssembly/Pages/UserInsights.razor +230 -60
- BlazorWebAssembly/Pages/Wishlist.razor +25 -5
- BlazorWebAssembly/Program.cs +3 -0
- BlazorWebAssembly/Providers/JwtAuthenticationStateProvider.cs +21 -3
- BlazorWebAssembly/Services/LibraryApiClient.cs +288 -20
- BlazorWebAssembly/Services/WishlistService.cs +129 -0
- BlazorWebAssembly/wwwroot/firebase-init.js +50 -0
- BlazorWebAssembly/wwwroot/firebase-messaging-sw.js +37 -0
- BlazorWebAssembly/wwwroot/index.html +8 -0
Backend/Features/Notification/NotificationService.cs
CHANGED
|
@@ -32,16 +32,16 @@ public class NotificationService : INotificationService
|
|
| 32 |
Data = data
|
| 33 |
};
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
}
|
| 40 |
catch (Exception ex)
|
| 41 |
{
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
}
|
| 46 |
|
| 47 |
public async Task<bool> SendAndSaveNotificationAsync(Guid userId, string? token, string title, string body, string type = "Info", string? actionLink = null, string? actionText = null)
|
|
|
|
| 32 |
Data = data
|
| 33 |
};
|
| 34 |
|
| 35 |
+
try
|
| 36 |
+
|
| 37 |
+
{string response = await FirebaseMessaging.DefaultInstance.SendAsync(message);
|
| 38 |
+
return !string.IsNullOrEmpty(response);
|
| 39 |
}
|
| 40 |
catch (Exception ex)
|
| 41 |
{
|
| 42 |
+
Console.WriteLine($"Error sending FCM message: {ex.Message}");
|
| 43 |
+
return false;
|
| 44 |
+
}
|
| 45 |
}
|
| 46 |
|
| 47 |
public async Task<bool> SendAndSaveNotificationAsync(Guid userId, string? token, string title, string body, string type = "Info", string? actionLink = null, string? actionText = null)
|
Backend/Program.cs
CHANGED
|
@@ -20,10 +20,10 @@ using Google.Apis.Auth.OAuth2;
|
|
| 20 |
var builder = WebApplication.CreateBuilder(args);
|
| 21 |
|
| 22 |
// Firebase Admin SDK Initialization
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
|
| 28 |
builder.Services.AddCors(options =>
|
| 29 |
{
|
|
|
|
| 20 |
var builder = WebApplication.CreateBuilder(args);
|
| 21 |
|
| 22 |
// Firebase Admin SDK Initialization
|
| 23 |
+
FirebaseApp.Create(new AppOptions()
|
| 24 |
+
{
|
| 25 |
+
Credential = GoogleCredential.FromFile("LibraryFirebase.json")
|
| 26 |
+
});
|
| 27 |
|
| 28 |
builder.Services.AddCors(options =>
|
| 29 |
{
|
BlazorWebAssembly/Layout/MainLayout.razor
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
@using Microsoft.AspNetCore.Components.Authorization
|
| 4 |
@inject ThemeService ThemeService
|
| 5 |
@inject AuthenticationStateProvider AuthStateProvider
|
|
|
|
|
|
|
| 6 |
|
| 7 |
<AuthorizeView>
|
| 8 |
<Authorized>
|
|
@@ -54,7 +56,7 @@
|
|
| 54 |
</div>
|
| 55 |
</header>
|
| 56 |
<main class="flex-1 overflow-y-auto p-8 scrollbar-hide">
|
| 57 |
-
<div class="max-w-
|
| 58 |
@Body
|
| 59 |
</div>
|
| 60 |
</main>
|
|
@@ -74,7 +76,8 @@
|
|
| 74 |
</a>
|
| 75 |
<div class="hidden md:flex items-center gap-8">
|
| 76 |
<NavLink href="/" Match="NavLinkMatch.All" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Home</NavLink>
|
| 77 |
-
<NavLink href="/
|
|
|
|
| 78 |
<NavLink href="/about" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">About Us</NavLink>
|
| 79 |
<NavLink href="/contact" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Contact</NavLink>
|
| 80 |
</div>
|
|
@@ -91,21 +94,57 @@
|
|
| 91 |
<main class="flex-grow">
|
| 92 |
@Body
|
| 93 |
</main>
|
| 94 |
-
<footer class="py-
|
| 95 |
-
<div class="max-w-
|
| 96 |
-
<div class="
|
| 97 |
-
<div class="
|
| 98 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</div>
|
| 100 |
-
<span class="text-sm font-black tracking-tighter uppercase">LibraryLuxe</span>
|
| 101 |
</div>
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
<
|
| 105 |
-
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
</div>
|
| 108 |
</footer>
|
|
|
|
| 109 |
</div>
|
| 110 |
</NotAuthorized>
|
| 111 |
</AuthorizeView>
|
|
@@ -115,4 +154,29 @@
|
|
| 115 |
{
|
| 116 |
await ThemeService.InitializeAsync();
|
| 117 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
|
|
|
| 3 |
@using Microsoft.AspNetCore.Components.Authorization
|
| 4 |
@inject ThemeService ThemeService
|
| 5 |
@inject AuthenticationStateProvider AuthStateProvider
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@inject LibraryApiClient ApiClient
|
| 8 |
|
| 9 |
<AuthorizeView>
|
| 10 |
<Authorized>
|
|
|
|
| 56 |
</div>
|
| 57 |
</header>
|
| 58 |
<main class="flex-1 overflow-y-auto p-8 scrollbar-hide">
|
| 59 |
+
<div class="max-w-7xl mx-auto w-full">
|
| 60 |
@Body
|
| 61 |
</div>
|
| 62 |
</main>
|
|
|
|
| 76 |
</a>
|
| 77 |
<div class="hidden md:flex items-center gap-8">
|
| 78 |
<NavLink href="/" Match="NavLinkMatch.All" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Home</NavLink>
|
| 79 |
+
<NavLink href="/membership" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Membership</NavLink>
|
| 80 |
+
<NavLink href="/features" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Features</NavLink>
|
| 81 |
<NavLink href="/about" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">About Us</NavLink>
|
| 82 |
<NavLink href="/contact" class="text-xs font-black uppercase tracking-widest text-muted hover:text-primary transition-colors">Contact</NavLink>
|
| 83 |
</div>
|
|
|
|
| 94 |
<main class="flex-grow">
|
| 95 |
@Body
|
| 96 |
</main>
|
| 97 |
+
<footer class="py-20 border-t border-border bg-card">
|
| 98 |
+
<div class="max-w-7xl mx-auto px-8">
|
| 99 |
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
|
| 100 |
+
<div class="col-span-1 md:col-span-2">
|
| 101 |
+
<div class="flex items-center gap-3 mb-6">
|
| 102 |
+
<div class="bg-primary rounded-xl p-2 text-white">
|
| 103 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M4 12V4l4 4"/><path d="m12 6 4 4"/></svg>
|
| 104 |
+
</div>
|
| 105 |
+
<span class="text-xl font-black tracking-tighter uppercase">LibraryLuxe</span>
|
| 106 |
+
</div>
|
| 107 |
+
<p class="text-muted text-sm max-w-sm leading-relaxed mb-8">
|
| 108 |
+
Redefining the modern library experience with digital-first solutions and community-driven rewards.
|
| 109 |
+
</p>
|
| 110 |
+
<div class="flex gap-4">
|
| 111 |
+
<!-- Social Icons Placeholder -->
|
| 112 |
+
<div class="w-10 h-10 rounded-xl bg-body border border-border flex items-center justify-center text-muted hover:text-primary transition-colors cursor-pointer">
|
| 113 |
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/></svg>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="w-10 h-10 rounded-xl bg-body border border-border flex items-center justify-center text-muted hover:text-primary transition-colors cursor-pointer">
|
| 116 |
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
<div>
|
| 121 |
+
<h4 class="text-xs font-black uppercase tracking-[0.2em] text-main mb-6">Navigation</h4>
|
| 122 |
+
<ul class="space-y-4">
|
| 123 |
+
<li><a href="/" class="text-sm text-muted hover:text-primary transition-colors">Home</a></li>
|
| 124 |
+
<li><a href="/membership" class="text-sm text-muted hover:text-primary transition-colors">Membership</a></li>
|
| 125 |
+
<li><a href="/features" class="text-sm text-muted hover:text-primary transition-colors">Features</a></li>
|
| 126 |
+
<li><a href="/faq" class="text-sm text-muted hover:text-primary transition-colors">FAQ</a></li>
|
| 127 |
+
</ul>
|
| 128 |
+
</div>
|
| 129 |
+
<div>
|
| 130 |
+
<h4 class="text-xs font-black uppercase tracking-[0.2em] text-main mb-6">Legal</h4>
|
| 131 |
+
<ul class="space-y-4">
|
| 132 |
+
<li><a href="/privacy" class="text-sm text-muted hover:text-primary transition-colors">Privacy Policy</a></li>
|
| 133 |
+
<li><a href="/terms" class="text-sm text-muted hover:text-primary transition-colors">Terms of Service</a></li>
|
| 134 |
+
<li><a href="/contact" class="text-sm text-muted hover:text-primary transition-colors">Contact Support</a></li>
|
| 135 |
+
</ul>
|
| 136 |
</div>
|
|
|
|
| 137 |
</div>
|
| 138 |
+
<div class="pt-8 border-t border-border flex flex-col md:row justify-between items-center gap-4">
|
| 139 |
+
<p class="text-[10px] font-bold text-muted uppercase tracking-[0.2em]">© 2026 LibraryLuxe Ecosystem. Built with passion for readers.</p>
|
| 140 |
+
<div class="flex items-center gap-2">
|
| 141 |
+
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
| 142 |
+
<span class="text-[10px] font-black uppercase tracking-widest text-muted">System Operational</span>
|
| 143 |
+
</div>
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
</footer>
|
| 147 |
+
|
| 148 |
</div>
|
| 149 |
</NotAuthorized>
|
| 150 |
</AuthorizeView>
|
|
|
|
| 154 |
{
|
| 155 |
await ThemeService.InitializeAsync();
|
| 156 |
}
|
| 157 |
+
|
| 158 |
+
protected override async Task OnAfterRenderAsync(bool firstRender)
|
| 159 |
+
{
|
| 160 |
+
if (firstRender)
|
| 161 |
+
{
|
| 162 |
+
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 163 |
+
var user = authState.User;
|
| 164 |
+
|
| 165 |
+
if (user.Identity?.IsAuthenticated == true)
|
| 166 |
+
{
|
| 167 |
+
try
|
| 168 |
+
{
|
| 169 |
+
var token = await JS.InvokeAsync<string>("getFcmToken");
|
| 170 |
+
if (!string.IsNullOrEmpty(token))
|
| 171 |
+
{
|
| 172 |
+
await ApiClient.UpdateFcmTokenAsync(token);
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
catch (Exception ex)
|
| 176 |
+
{
|
| 177 |
+
Console.WriteLine($"FCM Token Error: {ex.Message}");
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
}
|
BlazorWebAssembly/Layout/NavMenu.razor
CHANGED
|
@@ -37,6 +37,14 @@
|
|
| 37 |
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
| 38 |
Members
|
| 39 |
</NavLink>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</AuthorizeView>
|
| 41 |
</nav>
|
| 42 |
|
|
|
|
| 37 |
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
| 38 |
Members
|
| 39 |
</NavLink>
|
| 40 |
+
<NavLink class="nav-link-v" href="/borrowings/manage">
|
| 41 |
+
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg>
|
| 42 |
+
Manage Borrowings
|
| 43 |
+
</NavLink>
|
| 44 |
+
<NavLink class="nav-link-v" href="/rewards/manage">
|
| 45 |
+
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"></path></svg>
|
| 46 |
+
Manage Rewards
|
| 47 |
+
</NavLink>
|
| 48 |
</AuthorizeView>
|
| 49 |
</nav>
|
| 50 |
|
BlazorWebAssembly/Models/MemberDetailsViewModel.cs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using LibraryManagement.Shared.Models;
|
| 2 |
+
using System.Collections.Generic;
|
| 3 |
+
|
| 4 |
+
namespace BlazorWebAssembly.Models
|
| 5 |
+
{
|
| 6 |
+
public class MemberDetailsViewModel
|
| 7 |
+
{
|
| 8 |
+
public UserDto User { get; set; } = new();
|
| 9 |
+
public SubscriptionDto? CurrentSubscription { get; set; }
|
| 10 |
+
public List<MembershipDto> AvailableMemberships { get; set; } = new();
|
| 11 |
+
}
|
| 12 |
+
}
|
BlazorWebAssembly/Models/PagedResult.cs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
namespace BlazorWebAssembly.Models
|
| 2 |
+
{
|
| 3 |
+
public class PagedResult<T>
|
| 4 |
+
{
|
| 5 |
+
public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
|
| 6 |
+
public int TotalCount { get; set; }
|
| 7 |
+
public int PageSize { get; set; }
|
| 8 |
+
public int CurrentPage { get; set; }
|
| 9 |
+
public int TotalPages { get; set; }
|
| 10 |
+
public bool HasPreviousPage => CurrentPage > 1;
|
| 11 |
+
public bool HasNextPage => CurrentPage < TotalPages;
|
| 12 |
+
}
|
| 13 |
+
}
|
BlazorWebAssembly/Pages/About.razor
CHANGED
|
@@ -1,24 +1,101 @@
|
|
| 1 |
@page "/about"
|
| 2 |
|
| 3 |
-
<div class="max-w-
|
| 4 |
-
<
|
| 5 |
-
|
| 6 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
</div>
|
| 8 |
|
| 9 |
-
<
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
<
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
</div>
|
| 17 |
</div>
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
| 24 |
</div>
|
|
|
|
|
|
| 1 |
@page "/about"
|
| 2 |
|
| 3 |
+
<div class="max-w-7xl mx-auto py-24 px-8 animate-fade space-y-32">
|
| 4 |
+
<!-- Hero / Story -->
|
| 5 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
|
| 6 |
+
<div>
|
| 7 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Our Origin</h2>
|
| 8 |
+
<h1 class="text-7xl font-black tracking-tighter text-main mb-8 uppercase leading-[0.9]">A Library <br /> <span class="text-primary italic">Without Borders.</span></h1>
|
| 9 |
+
<p class="text-xl text-muted leading-relaxed mb-10">
|
| 10 |
+
Founded in 2024, LibraryLuxe was born from a simple idea: that books should be accessible, organized, and rewarding. We've built a system that doesn't just manage inventory, but fosters a culture of reading.
|
| 11 |
+
</p>
|
| 12 |
+
<div class="bg-card p-8 rounded-3xl border border-border border-l-4 border-l-primary shadow-sm">
|
| 13 |
+
<p class="italic text-main font-medium text-lg leading-relaxed">
|
| 14 |
+
"Reading is the ultimate superpower. We just provide the cape. Our mission is to bridge the gap between classic literature and modern convenience."
|
| 15 |
+
</p>
|
| 16 |
+
<div class="mt-4 flex items-center gap-3">
|
| 17 |
+
<div class="w-8 h-8 rounded-full bg-primary/20"></div>
|
| 18 |
+
<span class="text-xs font-bold uppercase tracking-widest text-muted">Founding Vision</span>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="relative">
|
| 23 |
+
<div class="bg-card aspect-square rounded-[4rem] border border-border shadow-2xl flex items-center justify-center p-12 relative z-10 overflow-hidden">
|
| 24 |
+
<div class="absolute inset-0 bg-gradient-to-br from-primary/10 to-emerald-500/10"></div>
|
| 25 |
+
<svg class="w-48 h-48 text-primary opacity-20 relative z-20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" 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 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="absolute -bottom-10 -right-10 w-64 h-64 bg-primary/5 rounded-full blur-3xl"></div>
|
| 28 |
+
</div>
|
| 29 |
</div>
|
| 30 |
|
| 31 |
+
<!-- How it Works -->
|
| 32 |
+
<section>
|
| 33 |
+
<div class="text-center mb-20">
|
| 34 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">The Process</h2>
|
| 35 |
+
<h3 class="text-5xl font-black text-main tracking-tight">How LibraryLuxe Works</h3>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-12 relative">
|
| 38 |
+
<div class="hidden md:block absolute top-1/2 left-0 w-full h-px bg-border -z-10"></div>
|
| 39 |
+
<!-- Step 1 -->
|
| 40 |
+
<div class="bg-body p-8 relative">
|
| 41 |
+
<div class="w-16 h-16 bg-card border border-border rounded-2xl flex items-center justify-center text-2xl font-black text-primary mb-8 shadow-xl">1</div>
|
| 42 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase">Create Account</h4>
|
| 43 |
+
<p class="text-muted text-sm leading-relaxed">Sign up in seconds and choose a membership plan that fits your reading habits.</p>
|
| 44 |
+
</div>
|
| 45 |
+
<!-- Step 2 -->
|
| 46 |
+
<div class="bg-body p-8 relative">
|
| 47 |
+
<div class="w-16 h-16 bg-card border border-border rounded-2xl flex items-center justify-center text-2xl font-black text-primary mb-8 shadow-xl">2</div>
|
| 48 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase">Borrow & Read</h4>
|
| 49 |
+
<p class="text-muted text-sm leading-relaxed">Browse thousands of titles and borrow them instantly. Physical books or digital access, your choice.</p>
|
| 50 |
+
</div>
|
| 51 |
+
<!-- Step 3 -->
|
| 52 |
+
<div class="bg-body p-8 relative">
|
| 53 |
+
<div class="w-16 h-16 bg-card border border-border rounded-2xl flex items-center justify-center text-2xl font-black text-primary mb-8 shadow-xl">3</div>
|
| 54 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase">Earn Rewards</h4>
|
| 55 |
+
<p class="text-muted text-sm leading-relaxed">Return books on time to earn loyalty points. Use points to upgrade your membership or unlock perks.</p>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
<!-- Why Choose Us -->
|
| 61 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
| 62 |
+
<div class="bg-card p-12 rounded-[3rem] border border-border">
|
| 63 |
+
<h4 class="text-2xl font-black text-main mb-8 uppercase tracking-tight">Why Choose Us?</h4>
|
| 64 |
+
<div class="space-y-6">
|
| 65 |
+
<div class="flex gap-4">
|
| 66 |
+
<div class="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center text-green-500 mt-1">
|
| 67 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 68 |
+
</div>
|
| 69 |
+
<div>
|
| 70 |
+
<h5 class="font-bold text-main">Eco-Friendly Reading</h5>
|
| 71 |
+
<p class="text-sm text-muted">Our digital-first approach reduces paper waste and logistics footprint.</p>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="flex gap-4">
|
| 75 |
+
<div class="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center text-green-500 mt-1">
|
| 76 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 77 |
+
</div>
|
| 78 |
+
<div>
|
| 79 |
+
<h5 class="font-bold text-main">Community Driven</h5>
|
| 80 |
+
<p class="text-sm text-muted">Join reading clubs and community events hosted right in our ecosystem.</p>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="flex gap-4">
|
| 84 |
+
<div class="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center text-green-500 mt-1">
|
| 85 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 86 |
+
</div>
|
| 87 |
+
<div>
|
| 88 |
+
<h5 class="font-bold text-main">Smart Notifications</h5>
|
| 89 |
+
<p class="text-sm text-muted">Advanced Firebase integration keeps you informed about your library activity.</p>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
</div>
|
| 93 |
</div>
|
| 94 |
+
<div class="bg-primary p-12 rounded-[3rem] text-white flex flex-col justify-center">
|
| 95 |
+
<h4 class="text-4xl font-black tracking-tighter mb-6 uppercase">Join the <br/> Revolution.</h4>
|
| 96 |
+
<p class="text-white/80 mb-8 leading-relaxed font-medium">We're not just a library; we're a community of life-long learners. Be part of our growing family today.</p>
|
| 97 |
+
<a href="/register" class="bg-white text-primary px-8 py-4 rounded-2xl font-black text-center hover:bg-white/90 transition-all uppercase tracking-widest text-sm">Join LibraryLuxe</a>
|
| 98 |
+
</div>
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
+
|
BlazorWebAssembly/Pages/Books.razor
CHANGED
|
@@ -1,143 +1,698 @@
|
|
| 1 |
@page "/books"
|
| 2 |
@using LibraryManagement.Shared.Models
|
| 3 |
@using BlazorWebAssembly.Services
|
|
|
|
| 4 |
@inject LibraryApiClient ApiClient
|
|
|
|
|
|
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
|
| 7 |
-
<div class="max-w-7xl mx-auto py-10 px-6">
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
<
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<button
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
-
</div>
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
{
|
| 42 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
-
|
| 45 |
-
}
|
| 46 |
-
else if (!FilteredBooks.Any())
|
| 47 |
-
{
|
| 48 |
-
<div class="text-center py-20 bg-card rounded-2xl border border-dashed border-border">
|
| 49 |
-
<h3 class="text-xl font-bold text-main mb-2">No results found</h3>
|
| 50 |
-
<p class="text-muted">Try adjusting your search or filters.</p>
|
| 51 |
-
</div>
|
| 52 |
}
|
| 53 |
-
else
|
| 54 |
{
|
| 55 |
-
<
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
{
|
| 65 |
-
<img src="@
|
| 66 |
}
|
| 67 |
else
|
| 68 |
{
|
| 69 |
-
<
|
| 70 |
-
<span class="font-bold uppercase tracking-widest text-xs">No Cover</span>
|
| 71 |
-
</div>
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
@if (book.AvailableCopies <= 0)
|
| 75 |
-
{
|
| 76 |
-
<div class="absolute inset-0 bg-black/40 backdrop-blur-[2px] flex items-center justify-center">
|
| 77 |
-
<span class="bg-white text-black px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest">Unavailable</span>
|
| 78 |
-
</div>
|
| 79 |
}
|
| 80 |
</div>
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
-
<div
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
-
<
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
<
|
| 95 |
-
<span class="text-xs font-bold @(book.AvailableCopies > 0 ? "text-green-500" : "text-red-400")">
|
| 96 |
-
@book.AvailableCopies / @book.TotalCopies
|
| 97 |
-
</span>
|
| 98 |
</div>
|
| 99 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</div>
|
| 103 |
}
|
| 104 |
</div>
|
| 105 |
|
| 106 |
@code {
|
|
|
|
|
|
|
| 107 |
private bool _isLoading = true;
|
|
|
|
| 108 |
private string _searchQuery = "";
|
| 109 |
private int? _selectedCategoryId;
|
| 110 |
-
private
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
protected override async Task OnInitializedAsync()
|
| 114 |
{
|
| 115 |
-
await
|
| 116 |
}
|
| 117 |
|
| 118 |
-
private async Task
|
| 119 |
{
|
| 120 |
_isLoading = true;
|
| 121 |
try
|
| 122 |
{
|
| 123 |
-
_books =
|
| 124 |
-
_categories =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
-
catch { }
|
| 127 |
-
finally { _isLoading = false; }
|
| 128 |
}
|
| 129 |
|
| 130 |
-
private IEnumerable<BookDto>
|
| 131 |
-
.
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
private void FilterByCategory(int? id)
|
| 135 |
{
|
| 136 |
_selectedCategoryId = id;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
-
private
|
| 140 |
{
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}
|
| 143 |
}
|
|
|
|
| 1 |
@page "/books"
|
| 2 |
@using LibraryManagement.Shared.Models
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject WishlistService WishlistService
|
| 7 |
+
@inject IJSRuntime JS
|
| 8 |
@inject NavigationManager Navigation
|
| 9 |
|
| 10 |
+
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 11 |
+
@if (_viewMode == ViewMode.List)
|
| 12 |
+
{
|
| 13 |
+
<!-- Header -->
|
| 14 |
+
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-12 gap-6">
|
| 15 |
+
<div>
|
| 16 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Book Collection</h1>
|
| 17 |
+
<p class="text-muted text-lg">Discover your next favorite story among our curated selection. <span class="text-xs">(@FilteredBooks.Count() total)</span></p>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="flex items-center gap-4 w-full md:w-auto">
|
| 20 |
+
<div class="relative w-full md:w-96">
|
| 21 |
+
<input type="text" @bind="_searchQuery" @bind:event="oninput"
|
| 22 |
+
placeholder="Search by title, author, or ISBN..."
|
| 23 |
+
class="w-full bg-card border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all shadow-sm text-main" />
|
| 24 |
+
</div>
|
| 25 |
+
<AuthorizeView Roles="Librarian">
|
| 26 |
+
<button @onclick="ShowCreate" class="bg-primary text-white px-6 py-3 rounded-xl font-bold hover:bg-primary-hover whitespace-nowrap">
|
| 27 |
+
Add Book
|
| 28 |
+
</button>
|
| 29 |
+
</AuthorizeView>
|
| 30 |
+
</div>
|
| 31 |
</div>
|
| 32 |
+
|
| 33 |
+
<!-- Quick Filters -->
|
| 34 |
+
<div class="flex flex-wrap items-center gap-3 mb-6">
|
| 35 |
+
<button @onclick='() => SetQuickFilter("All")'
|
| 36 |
+
class='px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "All" ? "bg-main text-body border-main shadow-md" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 37 |
+
All Books
|
| 38 |
+
</button>
|
| 39 |
+
<button @onclick='() => SetQuickFilter("New Arrivals")'
|
| 40 |
+
class='px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "New Arrivals" ? "bg-main text-body border-main shadow-md" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 41 |
+
New Arrivals
|
| 42 |
+
</button>
|
| 43 |
+
<button @onclick='() => SetQuickFilter("Trending")'
|
| 44 |
+
class='px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "Trending" ? "bg-main text-body border-main shadow-md" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 45 |
+
Trending
|
| 46 |
</button>
|
| 47 |
+
<button @onclick='() => SetQuickFilter("Recommended")'
|
| 48 |
+
class='px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "Recommended" ? "bg-main text-body border-main shadow-md" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 49 |
+
Recommended for You
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Category Filter Dropdown equivalent -->
|
| 54 |
+
@if (_categories.Any())
|
| 55 |
+
{
|
| 56 |
+
<div class="flex flex-wrap gap-2 mb-10">
|
| 57 |
+
<button @onclick="() => FilterByCategory(null)"
|
| 58 |
+
class='px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == null ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 59 |
+
All Categories
|
| 60 |
+
</button>
|
| 61 |
+
@foreach (var cat in _categories)
|
| 62 |
+
{
|
| 63 |
+
<button @onclick="() => FilterByCategory(cat.Id)"
|
| 64 |
+
class='px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == cat.Id ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
|
| 65 |
+
@cat.Name
|
| 66 |
+
</button>
|
| 67 |
+
}
|
| 68 |
+
</div>
|
| 69 |
}
|
|
|
|
| 70 |
|
| 71 |
+
<!-- Loading State -->
|
| 72 |
+
@if (_isLoading)
|
| 73 |
+
{
|
| 74 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
| 75 |
+
@for (int i = 0; i < 8; i++)
|
| 76 |
+
{
|
| 77 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-96"></div>
|
| 78 |
+
}
|
| 79 |
+
</div>
|
| 80 |
+
}
|
| 81 |
+
else if (!FilteredBooks.Any())
|
| 82 |
+
{
|
| 83 |
+
<div class="text-center py-20 bg-card rounded-2xl border border-dashed border-border">
|
| 84 |
+
<h3 class="text-xl font-bold text-main mb-2">No results found</h3>
|
| 85 |
+
<p class="text-muted">Try adjusting your search or filters.</p>
|
| 86 |
+
</div>
|
| 87 |
+
}
|
| 88 |
+
else
|
| 89 |
+
{
|
| 90 |
+
<!-- Books Grid -->
|
| 91 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
| 92 |
+
@foreach (var book in PaginatedBooks)
|
| 93 |
+
{
|
| 94 |
+
<div class="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
| 95 |
+
<!-- Cover -->
|
| 96 |
+
<div class="aspect-[2/3] bg-body relative overflow-hidden cursor-pointer" @onclick="() => ShowDetails(book.Id)">
|
| 97 |
+
@if (!string.IsNullOrEmpty(book.CoverUrl))
|
| 98 |
+
{
|
| 99 |
+
<img src="@book.CoverUrl" alt="@book.Title" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
| 100 |
+
}
|
| 101 |
+
else
|
| 102 |
+
{
|
| 103 |
+
<div class="w-full h-full flex items-center justify-center text-muted opacity-20">
|
| 104 |
+
<span class="font-bold uppercase tracking-widest text-xs">No Cover</span>
|
| 105 |
+
</div>
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
@if (book.AvailableCopies <= 0)
|
| 109 |
+
{
|
| 110 |
+
<div class="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center">
|
| 111 |
+
<span class="bg-white text-black px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest">Out of Stock</span>
|
| 112 |
+
</div>
|
| 113 |
+
}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- Content -->
|
| 117 |
+
<div class="p-5 flex-grow flex flex-col">
|
| 118 |
+
<h3 class="font-bold text-main line-clamp-1 mb-1 group-hover:text-primary transition-colors cursor-pointer" @onclick="() => ShowDetails(book.Id)">@book.Title</h3>
|
| 119 |
+
<p class="text-xs text-muted mb-3 font-medium">@book.Author</p>
|
| 120 |
+
|
| 121 |
+
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
| 122 |
+
@foreach (var cat in book.Categories.Take(2))
|
| 123 |
+
{
|
| 124 |
+
<span class="text-[9px] font-bold uppercase tracking-wider text-primary bg-primary/10 px-2 py-0.5 rounded-full">@cat.Name</span>
|
| 125 |
+
}
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div class="mt-auto flex flex-col gap-4">
|
| 129 |
+
<div class="flex items-center justify-between">
|
| 130 |
+
<span class="text-[10px] font-bold text-muted bg-body px-2 py-1 rounded border border-border">@book.Status</span>
|
| 131 |
+
<span class="text-xs font-bold @(book.AvailableCopies > 0 ? "text-green-500" : "text-red-400")">
|
| 132 |
+
@book.AvailableCopies / @book.TotalCopies
|
| 133 |
+
</span>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<div class="flex items-center gap-2">
|
| 137 |
+
<button @onclick="() => ShowDetails(book.Id)" class="flex-1 bg-body text-main border border-border py-1.5 rounded-lg text-[11px] font-bold hover:bg-card">Details</button>
|
| 138 |
+
|
| 139 |
+
<AuthorizeView Roles="Member">
|
| 140 |
+
<div class="flex gap-1">
|
| 141 |
+
<button @onclick="() => ToggleFavourite(book)"
|
| 142 |
+
class='p-1.5 rounded-lg border transition-all @(IsFavourite(book.Id) ? "border-red-200 bg-red-500/10 text-red-500" : "border-border text-muted hover:bg-red-500/10 hover:text-red-500")'
|
| 143 |
+
title='@(IsFavourite(book.Id) ? "Remove from Favourites" : "Add to Favourites")'>
|
| 144 |
+
<svg class="w-4 h-4" fill='@(IsFavourite(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 145 |
+
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
|
| 146 |
+
</svg>
|
| 147 |
+
</button>
|
| 148 |
+
@if (book.AvailableCopies == 0)
|
| 149 |
+
{
|
| 150 |
+
<button @onclick="() => ToggleWishlist(book)"
|
| 151 |
+
class='p-1.5 rounded-lg border transition-all @(IsInWishlist(book.Id) ? "border-amber-200 bg-amber-500/10 text-amber-500" : "border-border text-muted hover:bg-amber-500/10 hover:text-amber-500")'
|
| 152 |
+
title='@(IsInWishlist(book.Id) ? "Remove from Wishlist" : "Add to Wishlist")'>
|
| 153 |
+
<svg class="w-4 h-4" fill='@(IsInWishlist(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 154 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
| 155 |
+
</svg>
|
| 156 |
+
</button>
|
| 157 |
+
}
|
| 158 |
+
</div>
|
| 159 |
+
</AuthorizeView>
|
| 160 |
+
<AuthorizeView Roles="Librarian">
|
| 161 |
+
<button @onclick="() => ShowEdit(book.Id)" class="p-1.5 rounded-lg border border-border hover:bg-body text-muted">
|
| 162 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
| 163 |
+
</button>
|
| 164 |
+
</AuthorizeView>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
}
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<!-- Pagination -->
|
| 173 |
+
@if (TotalPages > 1)
|
| 174 |
{
|
| 175 |
+
<div class="mt-12 flex items-center justify-center gap-2">
|
| 176 |
+
<button disabled="@(_currentPage == 1)" @onclick="() => _currentPage--"
|
| 177 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-body disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 178 |
+
<svg class="w-5 h-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"></path></svg>
|
| 179 |
+
</button>
|
| 180 |
+
|
| 181 |
+
@for (int i = 1; i <= TotalPages; i++)
|
| 182 |
+
{
|
| 183 |
+
var pageNumber = i;
|
| 184 |
+
<button @onclick="() => _currentPage = pageNumber"
|
| 185 |
+
class='w-10 h-10 rounded-lg border text-sm font-bold transition-all @(_currentPage == pageNumber ? "bg-main border-main text-body shadow-md" : "bg-card border-border text-muted hover:border-primary hover:text-primary")'>
|
| 186 |
+
@pageNumber
|
| 187 |
+
</button>
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
<button disabled="@(_currentPage == TotalPages)" @onclick="() => _currentPage++"
|
| 191 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-body disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 192 |
+
<svg class="w-5 h-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"></path></svg>
|
| 193 |
+
</button>
|
| 194 |
+
</div>
|
| 195 |
}
|
| 196 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
+
else if (_viewMode == ViewMode.Details && _selectedBook != null)
|
| 199 |
{
|
| 200 |
+
<div class="mb-8 animate-fade">
|
| 201 |
+
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 202 |
+
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" 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"></path></svg>
|
| 203 |
+
Back to Catalog
|
| 204 |
+
</button>
|
| 205 |
+
|
| 206 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 207 |
+
<div class="lg:col-span-1">
|
| 208 |
+
<div class="aspect-[2/3] w-full rounded-3xl bg-body border border-border shadow-sm flex items-center justify-center text-muted overflow-hidden">
|
| 209 |
+
@if (!string.IsNullOrWhiteSpace(_selectedBook.CoverUrl))
|
| 210 |
{
|
| 211 |
+
<img src="@_selectedBook.CoverUrl" alt="@_selectedBook.Title" class="w-full h-full object-cover" />
|
| 212 |
}
|
| 213 |
else
|
| 214 |
{
|
| 215 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
</div>
|
| 218 |
+
</div>
|
| 219 |
|
| 220 |
+
<div class="lg:col-span-2 space-y-8">
|
| 221 |
+
<div>
|
| 222 |
+
<span class="inline-flex items-center rounded-full bg-body border border-border px-3 py-1 text-xs font-semibold text-muted mb-4 tracking-wide uppercase">@_selectedBook.Status</span>
|
| 223 |
+
<h1 class="text-4xl font-extrabold text-main tracking-tight">@_selectedBook.Title</h1>
|
| 224 |
+
<p class="text-xl text-muted mt-2 font-medium">by @_selectedBook.Author</p>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<div class="prose max-w-none">
|
| 228 |
+
<h3 class="text-lg font-bold text-main border-b border-border pb-2 mb-4 uppercase tracking-wider text-sm">Description</h3>
|
| 229 |
+
<p class="text-muted leading-relaxed">
|
| 230 |
+
@(string.IsNullOrEmpty(_selectedBook.Description) ? "No description available for this book." : _selectedBook.Description)
|
| 231 |
+
</p>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<div class="grid grid-cols-2 gap-8 border-y border-border py-6">
|
| 235 |
+
<div>
|
| 236 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-1">ISBN</h4>
|
| 237 |
+
<p class="text-sm font-semibold text-main">@_selectedBook.Isbn</p>
|
| 238 |
</div>
|
| 239 |
+
<div>
|
| 240 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-1">Availability</h4>
|
| 241 |
+
<p class="text-sm font-semibold @(_selectedBook.AvailableCopies > 0 ? "text-green-500" : "text-red-500")">
|
| 242 |
+
@(_selectedBook.AvailableCopies > 0 ? $"{_selectedBook.AvailableCopies} / {_selectedBook.TotalCopies} units in stock" : "Currently occupied")
|
| 243 |
+
</p>
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
</div>
|
| 246 |
+
|
| 247 |
+
<div class="flex flex-wrap items-center gap-4 pt-4">
|
| 248 |
+
<AuthorizeView Roles="Member">
|
| 249 |
+
<div class="flex items-center gap-3">
|
| 250 |
+
@if (_selectedBook.AvailableCopies > 0)
|
| 251 |
+
{
|
| 252 |
+
<button @onclick="() => BorrowAsync(_selectedBook.Id)" class="bg-primary text-white py-3 px-8 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Borrow This Book</button>
|
| 253 |
+
}
|
| 254 |
+
else
|
| 255 |
+
{
|
| 256 |
+
<div class="flex flex-col gap-2">
|
| 257 |
+
<button disabled class="bg-body border border-border text-muted py-3 px-8 rounded-xl font-bold opacity-50 cursor-not-allowed">Out of Stock</button>
|
| 258 |
+
<button @onclick="() => ToggleWishlist(_selectedBook)"
|
| 259 |
+
class='text-xs font-bold flex items-center gap-1.5 transition-colors @(IsInWishlist(_selectedBook.Id) ? "text-amber-500 underline" : "text-muted hover:text-amber-500 hover:underline")'>
|
| 260 |
+
<svg class="w-4 h-4" fill='@(IsInWishlist(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 261 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
| 262 |
+
</svg>
|
| 263 |
+
@(IsInWishlist(_selectedBook.Id) ? "In Wishlist (Notifying)" : "Add to Wishlist for Notification")
|
| 264 |
+
</button>
|
| 265 |
+
</div>
|
| 266 |
+
}
|
| 267 |
+
<button @onclick="() => ToggleFavourite(_selectedBook)"
|
| 268 |
+
class='p-3 rounded-xl border transition-all shadow-sm active:scale-95 @(IsFavourite(_selectedBook.Id) ? "border-red-200 bg-red-500/10 text-red-500" : "border-border text-muted hover:bg-red-500/10 hover:text-red-500")'
|
| 269 |
+
title='@(IsFavourite(_selectedBook.Id) ? "Remove from Favourites" : "Add to Favourites")'>
|
| 270 |
+
<svg class="w-6 h-6" fill='@(IsFavourite(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 271 |
+
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
|
| 272 |
+
</svg>
|
| 273 |
+
</button>
|
| 274 |
+
</div>
|
| 275 |
+
</AuthorizeView>
|
| 276 |
+
|
| 277 |
+
<AuthorizeView Roles="Librarian">
|
| 278 |
+
<button @onclick="() => ShowEdit(_selectedBook.Id)" class="bg-body border border-border text-main py-3 px-8 rounded-xl font-bold hover:bg-card">Edit Details</button>
|
| 279 |
+
<button @onclick="() => DeleteAsync(_selectedBook.Id)" class="p-3 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded-xl transition-colors">
|
| 280 |
+
<svg class="w-6 h-6" 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"></path></svg>
|
| 281 |
+
</button>
|
| 282 |
+
</AuthorizeView>
|
| 283 |
+
|
| 284 |
+
<AuthorizeView>
|
| 285 |
+
<NotAuthorized>
|
| 286 |
+
<a href="/login" class="bg-primary text-white py-3 px-8 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20">Sign In to Borrow</a>
|
| 287 |
+
</NotAuthorized>
|
| 288 |
+
</AuthorizeView>
|
| 289 |
+
</div>
|
| 290 |
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
}
|
| 294 |
+
else if (_viewMode == ViewMode.Create || _viewMode == ViewMode.Edit)
|
| 295 |
+
{
|
| 296 |
+
<div class="mb-8 animate-fade">
|
| 297 |
+
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 298 |
+
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" 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"></path></svg>
|
| 299 |
+
Back to Catalog
|
| 300 |
+
</button>
|
| 301 |
+
|
| 302 |
+
<div class="max-w-3xl mx-auto">
|
| 303 |
+
<div class="mb-8">
|
| 304 |
+
<h1 class="text-3xl font-bold tracking-tight text-main">@(_viewMode == ViewMode.Create ? "Add New Book" : "Edit Book")</h1>
|
| 305 |
+
<p class="mt-2 text-muted">Fill in the details below to @(_viewMode == ViewMode.Create ? "add a new book to" : "update the") library catalog.</p>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<div class="bg-card border border-border p-6 sm:p-8 rounded-3xl shadow-sm">
|
| 309 |
+
<EditForm Model="@_formModel" OnValidSubmit="HandleSubmit" class="space-y-6">
|
| 310 |
+
<DataAnnotationsValidator />
|
| 311 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 312 |
+
<div class="sm:col-span-2">
|
| 313 |
+
<label class="block text-sm font-medium text-main mb-1">Book Title <span class="text-red-500">*</span></label>
|
| 314 |
+
<InputText @bind-Value="_formModel.Title" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="e.g. The Great Gatsby" required />
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<div>
|
| 318 |
+
<label class="block text-sm font-medium text-main mb-1">Author <span class="text-red-500">*</span></label>
|
| 319 |
+
<InputText @bind-Value="_formModel.Author" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="e.g. F. Scott Fitzgerald" required />
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div>
|
| 323 |
+
<label class="block text-sm font-medium text-main mb-1">ISBN <span class="text-red-500">*</span></label>
|
| 324 |
+
<InputText @bind-Value="_formModel.Isbn" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="e.g. 978-0743273565" required />
|
| 325 |
+
</div>
|
| 326 |
+
|
| 327 |
+
<div>
|
| 328 |
+
<label class="block text-sm font-medium text-main mb-1">Total Copies <span class="text-red-500">*</span></label>
|
| 329 |
+
<InputNumber @bind-Value="_formModel.TotalCopies" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="1" required />
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<div>
|
| 333 |
+
<label class="block text-sm font-medium text-main mb-1">Cover Image URL</label>
|
| 334 |
+
<InputText @bind-Value="_formModel.CoverUrl" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="https://example.com/cover.jpg" />
|
| 335 |
+
</div>
|
| 336 |
+
|
| 337 |
+
<div class="hidden sm:block"></div>
|
| 338 |
+
|
| 339 |
+
<div class="sm:col-span-2">
|
| 340 |
+
<label class="block text-sm font-medium text-main mb-1">Description</label>
|
| 341 |
+
<InputTextArea @bind-Value="_formModel.Description" rows="4" class="w-full bg-body border border-border rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main" placeholder="Brief summary of the book..."></InputTextArea>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
<div class="sm:col-span-2">
|
| 345 |
+
<label class="block text-sm font-medium text-main mb-2">Categories</label>
|
| 346 |
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
| 347 |
+
@foreach (var cat in _categories)
|
| 348 |
+
{
|
| 349 |
+
<label class="flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-body hover:bg-card cursor-pointer text-sm text-main transition-colors">
|
| 350 |
+
<input type="checkbox" checked="@(_formModel.CategoryIds.Contains(cat.Id))"
|
| 351 |
+
@onchange='(e) => ToggleCategory(cat.Id, (bool)e.Value!)'
|
| 352 |
+
class="h-4 w-4 rounded border-border text-primary focus:ring-primary" />
|
| 353 |
+
@cat.Name
|
| 354 |
+
</label>
|
| 355 |
+
}
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
+
<div class="pt-6 border-t border-border flex justify-end gap-3 mt-8">
|
| 361 |
+
<button type="button" @onclick="ShowList" class="bg-body border border-border text-main px-6 py-2.5 rounded-xl font-bold hover:bg-card">Cancel</button>
|
| 362 |
+
<button type="submit" class="bg-primary text-white px-6 py-2.5 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20" disabled="@_isSubmitting">
|
| 363 |
+
@(_isSubmitting ? "Saving..." : (_viewMode == ViewMode.Create ? "Add Book" : "Update Book"))
|
| 364 |
+
</button>
|
| 365 |
+
</div>
|
| 366 |
+
</EditForm>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
</div>
|
| 370 |
}
|
| 371 |
</div>
|
| 372 |
|
| 373 |
@code {
|
| 374 |
+
private enum ViewMode { List, Details, Create, Edit }
|
| 375 |
+
private ViewMode _viewMode = ViewMode.List;
|
| 376 |
private bool _isLoading = true;
|
| 377 |
+
private bool _isSubmitting = false;
|
| 378 |
private string _searchQuery = "";
|
| 379 |
private int? _selectedCategoryId;
|
| 380 |
+
private string _activeQuickFilter = "All";
|
| 381 |
+
|
| 382 |
+
// Pagination
|
| 383 |
+
private int _currentPage = 1;
|
| 384 |
+
private int _pageSize = 12;
|
| 385 |
+
|
| 386 |
+
private IEnumerable<BookDto> _books = Enumerable.Empty<BookDto>();
|
| 387 |
+
private IEnumerable<CategoryDto> _categories = Enumerable.Empty<CategoryDto>();
|
| 388 |
+
private IEnumerable<BorrowingDto> _myBorrowings = Enumerable.Empty<BorrowingDto>();
|
| 389 |
+
private IEnumerable<BorrowingDto> _allBorrowings = Enumerable.Empty<BorrowingDto>();
|
| 390 |
+
private BookDto? _selectedBook;
|
| 391 |
+
|
| 392 |
+
// Form Model
|
| 393 |
+
private BookFormModel _formModel = new();
|
| 394 |
|
| 395 |
protected override async Task OnInitializedAsync()
|
| 396 |
{
|
| 397 |
+
await LoadDataAsync();
|
| 398 |
}
|
| 399 |
|
| 400 |
+
private async Task LoadDataAsync()
|
| 401 |
{
|
| 402 |
_isLoading = true;
|
| 403 |
try
|
| 404 |
{
|
| 405 |
+
_books = await ApiClient.GetBooksAsync();
|
| 406 |
+
_categories = await ApiClient.GetCategoriesAsync();
|
| 407 |
+
|
| 408 |
+
// Fetch borrowings for Trending and Recommended
|
| 409 |
+
_myBorrowings = await ApiClient.GetMyBorrowingsAsync();
|
| 410 |
+
try {
|
| 411 |
+
_allBorrowings = await ApiClient.GetAllBorrowingsAsync();
|
| 412 |
+
} catch { }
|
| 413 |
+
|
| 414 |
+
_currentPage = 1;
|
| 415 |
+
}
|
| 416 |
+
catch (Exception ex)
|
| 417 |
+
{
|
| 418 |
+
Console.WriteLine($"Error: {ex.Message}");
|
| 419 |
+
}
|
| 420 |
+
finally
|
| 421 |
+
{
|
| 422 |
+
_isLoading = false;
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
private IEnumerable<BookDto> FilteredBooks
|
| 427 |
+
{
|
| 428 |
+
get
|
| 429 |
+
{
|
| 430 |
+
var books = _books;
|
| 431 |
+
|
| 432 |
+
// Apply Search
|
| 433 |
+
if (!string.IsNullOrWhiteSpace(_searchQuery))
|
| 434 |
+
{
|
| 435 |
+
books = books.Where(b => b.Title.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 436 |
+
b.Author.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 437 |
+
b.Isbn.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// Apply Category Filter
|
| 441 |
+
if (_selectedCategoryId != null)
|
| 442 |
+
{
|
| 443 |
+
books = books.Where(b => b.Categories.Any(c => c.Id == _selectedCategoryId));
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// Apply Quick Filter
|
| 447 |
+
switch (_activeQuickFilter)
|
| 448 |
+
{
|
| 449 |
+
case "New Arrivals":
|
| 450 |
+
books = books.Where(b => b.CreatedAt >= DateTime.UtcNow.AddDays(-7));
|
| 451 |
+
break;
|
| 452 |
+
case "Trending":
|
| 453 |
+
var trendingIds = _allBorrowings
|
| 454 |
+
.Where(br => br.BorrowDate >= DateTime.UtcNow.AddDays(-7))
|
| 455 |
+
.GroupBy(br => br.BookId)
|
| 456 |
+
.OrderByDescending(g => g.Count())
|
| 457 |
+
.Select(g => g.Key)
|
| 458 |
+
.Take(12)
|
| 459 |
+
.ToList();
|
| 460 |
+
|
| 461 |
+
if (trendingIds.Any())
|
| 462 |
+
books = books.Where(b => trendingIds.Contains(b.Id));
|
| 463 |
+
else
|
| 464 |
+
books = Enumerable.Empty<BookDto>(); // Or show nothing if no data
|
| 465 |
+
break;
|
| 466 |
+
case "Recommended":
|
| 467 |
+
var myCategoryIds = _myBorrowings.SelectMany(br => _books.FirstOrDefault(b => b.Id == br.BookId)?.Categories.Select(c => c.Id) ?? Enumerable.Empty<int>())
|
| 468 |
+
.Concat(_favourites.SelectMany(b => b.Categories.Select(c => c.Id)))
|
| 469 |
+
.Concat(_wishlist.SelectMany(b => b.Categories.Select(c => c.Id)))
|
| 470 |
+
.Distinct()
|
| 471 |
+
.ToList();
|
| 472 |
+
|
| 473 |
+
if (myCategoryIds.Any())
|
| 474 |
+
books = books.Where(b => b.Categories.Any(c => myCategoryIds.Contains(c.Id))).Take(8);
|
| 475 |
+
break;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
return books;
|
| 479 |
}
|
|
|
|
|
|
|
| 480 |
}
|
| 481 |
|
| 482 |
+
private IEnumerable<BookDto> PaginatedBooks => FilteredBooks
|
| 483 |
+
.Skip((_currentPage - 1) * _pageSize)
|
| 484 |
+
.Take(_pageSize);
|
| 485 |
+
|
| 486 |
+
private int TotalPages => (int)Math.Ceiling(FilteredBooks.Count() / (double)_pageSize);
|
| 487 |
+
|
| 488 |
+
private void SetQuickFilter(string filter)
|
| 489 |
+
{
|
| 490 |
+
_activeQuickFilter = filter;
|
| 491 |
+
_selectedCategoryId = null; // Clear category when switching quick filters
|
| 492 |
+
_currentPage = 1;
|
| 493 |
+
}
|
| 494 |
|
| 495 |
private void FilterByCategory(int? id)
|
| 496 |
{
|
| 497 |
_selectedCategoryId = id;
|
| 498 |
+
_currentPage = 1;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
private void ShowList()
|
| 502 |
+
{
|
| 503 |
+
_viewMode = ViewMode.List;
|
| 504 |
+
_selectedBook = null;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
private async Task ShowDetails(int id)
|
| 508 |
+
{
|
| 509 |
+
_isLoading = true;
|
| 510 |
+
_selectedBook = await ApiClient.GetBookAsync(id);
|
| 511 |
+
_viewMode = ViewMode.Details;
|
| 512 |
+
_isLoading = false;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
private void ShowCreate()
|
| 516 |
+
{
|
| 517 |
+
_formModel = new BookFormModel();
|
| 518 |
+
_viewMode = ViewMode.Create;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
private async Task ShowEdit(int id)
|
| 522 |
+
{
|
| 523 |
+
_isLoading = true;
|
| 524 |
+
var book = await ApiClient.GetBookAsync(id);
|
| 525 |
+
if (book != null)
|
| 526 |
+
{
|
| 527 |
+
_selectedBook = book;
|
| 528 |
+
_formModel = new BookFormModel
|
| 529 |
+
{
|
| 530 |
+
Id = book.Id,
|
| 531 |
+
Title = book.Title,
|
| 532 |
+
Author = book.Author,
|
| 533 |
+
Isbn = book.Isbn,
|
| 534 |
+
Description = book.Description,
|
| 535 |
+
TotalCopies = book.TotalCopies,
|
| 536 |
+
CoverUrl = book.CoverUrl,
|
| 537 |
+
CategoryIds = book.Categories.Select(c => c.Id).ToList()
|
| 538 |
+
};
|
| 539 |
+
_viewMode = ViewMode.Edit;
|
| 540 |
+
}
|
| 541 |
+
_isLoading = false;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
private void ToggleCategory(int id, bool isChecked)
|
| 545 |
+
{
|
| 546 |
+
if (isChecked && !_formModel.CategoryIds.Contains(id))
|
| 547 |
+
_formModel.CategoryIds.Add(id);
|
| 548 |
+
else if (!isChecked)
|
| 549 |
+
_formModel.CategoryIds.Remove(id);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
private List<BookDto> _wishlist = new();
|
| 553 |
+
private List<BookDto> _favourites = new();
|
| 554 |
+
|
| 555 |
+
protected override async Task OnAfterRenderAsync(bool firstRender)
|
| 556 |
+
{
|
| 557 |
+
if (firstRender)
|
| 558 |
+
{
|
| 559 |
+
await LoadCollectionsAsync();
|
| 560 |
+
StateHasChanged();
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
private async Task LoadCollectionsAsync()
|
| 565 |
+
{
|
| 566 |
+
_wishlist = await WishlistService.GetWishlistAsync();
|
| 567 |
+
_favourites = await WishlistService.GetFavouritesAsync();
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
private bool IsFavourite(int id) => _favourites.Any(b => b.Id == id);
|
| 571 |
+
private bool IsInWishlist(int id) => _wishlist.Any(b => b.Id == id);
|
| 572 |
+
|
| 573 |
+
private async Task ToggleFavourite(BookDto book)
|
| 574 |
+
{
|
| 575 |
+
if (IsFavourite(book.Id))
|
| 576 |
+
{
|
| 577 |
+
await WishlistService.RemoveFromFavouriteAsync(book.Id);
|
| 578 |
+
await JS.InvokeVoidAsync("alert", $"Removed \"{book.Title}\" from favourites");
|
| 579 |
+
}
|
| 580 |
+
else
|
| 581 |
+
{
|
| 582 |
+
await WishlistService.AddToFavouritesAsync(book);
|
| 583 |
+
await JS.InvokeVoidAsync("alert", $"Added \"{book.Title}\" to favourites!");
|
| 584 |
+
}
|
| 585 |
+
await LoadCollectionsAsync();
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
private async Task ToggleWishlist(BookDto book)
|
| 589 |
+
{
|
| 590 |
+
if (IsInWishlist(book.Id))
|
| 591 |
+
{
|
| 592 |
+
await WishlistService.RemoveFromWishlistAsync(book.Id);
|
| 593 |
+
await JS.InvokeVoidAsync("alert", $"Removed \"{book.Title}\" from wishlist");
|
| 594 |
+
}
|
| 595 |
+
else
|
| 596 |
+
{
|
| 597 |
+
await WishlistService.AddToWishlistAsync(book);
|
| 598 |
+
await JS.InvokeVoidAsync("alert", $"Added \"{book.Title}\" to wishlist!");
|
| 599 |
+
}
|
| 600 |
+
await LoadCollectionsAsync();
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
private async Task HandleSubmit()
|
| 604 |
+
{
|
| 605 |
+
_isSubmitting = true;
|
| 606 |
+
try
|
| 607 |
+
{
|
| 608 |
+
if (_viewMode == ViewMode.Create)
|
| 609 |
+
{
|
| 610 |
+
var request = new BookCreateRequest
|
| 611 |
+
{
|
| 612 |
+
Title = _formModel.Title,
|
| 613 |
+
Author = _formModel.Author,
|
| 614 |
+
Isbn = _formModel.Isbn,
|
| 615 |
+
Description = _formModel.Description,
|
| 616 |
+
TotalCopies = _formModel.TotalCopies,
|
| 617 |
+
CoverUrl = _formModel.CoverUrl,
|
| 618 |
+
CategoryIds = _formModel.CategoryIds
|
| 619 |
+
};
|
| 620 |
+
var result = await ApiClient.CreateBookAsync(request);
|
| 621 |
+
if (result != null)
|
| 622 |
+
{
|
| 623 |
+
await JS.InvokeVoidAsync("alert", "Book added successfully!");
|
| 624 |
+
await LoadDataAsync();
|
| 625 |
+
ShowList();
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
else
|
| 629 |
+
{
|
| 630 |
+
var request = new BookUpdateRequest
|
| 631 |
+
{
|
| 632 |
+
Title = _formModel.Title,
|
| 633 |
+
Author = _formModel.Author,
|
| 634 |
+
Description = _formModel.Description,
|
| 635 |
+
TotalCopies = _formModel.TotalCopies,
|
| 636 |
+
CoverUrl = _formModel.CoverUrl,
|
| 637 |
+
CategoryIds = _formModel.CategoryIds
|
| 638 |
+
};
|
| 639 |
+
var result = await ApiClient.UpdateBookAsync(_formModel.Id, request);
|
| 640 |
+
if (result != null)
|
| 641 |
+
{
|
| 642 |
+
await JS.InvokeVoidAsync("alert", "Book updated successfully!");
|
| 643 |
+
await LoadDataAsync();
|
| 644 |
+
ShowList();
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
catch (Exception ex)
|
| 649 |
+
{
|
| 650 |
+
await JS.InvokeVoidAsync("alert", $"Error: {ex.Message}");
|
| 651 |
+
}
|
| 652 |
+
finally
|
| 653 |
+
{
|
| 654 |
+
_isSubmitting = false;
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
private async Task DeleteAsync(int id)
|
| 659 |
+
{
|
| 660 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to remove this book from the catalog?");
|
| 661 |
+
if (!confirmed) return;
|
| 662 |
+
|
| 663 |
+
var success = await ApiClient.DeleteBookAsync(id);
|
| 664 |
+
if (success)
|
| 665 |
+
{
|
| 666 |
+
await JS.InvokeVoidAsync("alert", "Book deleted successfully");
|
| 667 |
+
await LoadDataAsync();
|
| 668 |
+
ShowList();
|
| 669 |
+
}
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
private async Task BorrowAsync(int id)
|
| 673 |
+
{
|
| 674 |
+
var result = await ApiClient.BorrowBookAsync(new BorrowRequest { BookId = id });
|
| 675 |
+
if (result != null)
|
| 676 |
+
{
|
| 677 |
+
await JS.InvokeVoidAsync("alert", "Book borrowed successfully! Check 'My Loans' for details.");
|
| 678 |
+
await LoadDataAsync();
|
| 679 |
+
await ShowDetails(id);
|
| 680 |
+
}
|
| 681 |
+
else
|
| 682 |
+
{
|
| 683 |
+
await JS.InvokeVoidAsync("alert", "Failed to borrow book. Check your loan limit.");
|
| 684 |
+
}
|
| 685 |
}
|
| 686 |
|
| 687 |
+
private class BookFormModel
|
| 688 |
{
|
| 689 |
+
public int Id { get; set; }
|
| 690 |
+
public string Title { get; set; } = "";
|
| 691 |
+
public string Author { get; set; } = "";
|
| 692 |
+
public string Isbn { get; set; } = "";
|
| 693 |
+
public string? Description { get; set; }
|
| 694 |
+
public int TotalCopies { get; set; } = 1;
|
| 695 |
+
public string? CoverUrl { get; set; }
|
| 696 |
+
public List<int> CategoryIds { get; set; } = new();
|
| 697 |
}
|
| 698 |
}
|
BlazorWebAssembly/Pages/Borrowings.razor
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
|
|
|
| 6 |
|
| 7 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 8 |
<!-- Header -->
|
|
@@ -54,28 +55,51 @@
|
|
| 54 |
<p class="text-sm text-muted font-medium mb-4 md:mb-0">ID: @loan.BookId</p>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
-
<!-- Dates -->
|
| 58 |
-
<div class="grid grid-cols-2 gap-8 px-8 border-x border-border hidden lg:grid">
|
| 59 |
<div>
|
| 60 |
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Borrowed</p>
|
| 61 |
<p class="text-sm font-bold text-main">@loan.BorrowDate.ToString("MMM dd, yyyy")</p>
|
| 62 |
</div>
|
| 63 |
<div>
|
| 64 |
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Due Date</p>
|
| 65 |
-
<p class="text-sm font-bold @(loan.DueDate < DateTime.UtcNow ? "text-red-500" : "text-main")">
|
| 66 |
@loan.DueDate.ToString("MMM dd, yyyy")
|
| 67 |
</p>
|
| 68 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
|
| 71 |
<!-- Status & Action -->
|
| 72 |
<div class="flex flex-col md:items-end gap-3 min-w-[150px]">
|
| 73 |
<span class='px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest text-center @GetStatusColor(loan.Status)'>
|
| 74 |
-
@loan.Status
|
| 75 |
</span>
|
| 76 |
@if (loan.Status == "Borrowed")
|
| 77 |
{
|
| 78 |
-
<button @onclick="() => ReturnRequestAsync(loan
|
| 79 |
Request Return
|
| 80 |
</button>
|
| 81 |
}
|
|
@@ -106,10 +130,21 @@
|
|
| 106 |
finally { _isLoading = false; }
|
| 107 |
}
|
| 108 |
|
| 109 |
-
private async Task ReturnRequestAsync(
|
| 110 |
{
|
| 111 |
-
|
| 112 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
private string GetStatusColor(string status) => status switch
|
|
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
|
| 8 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 9 |
<!-- Header -->
|
|
|
|
| 55 |
<p class="text-sm text-muted font-medium mb-4 md:mb-0">ID: @loan.BookId</p>
|
| 56 |
</div>
|
| 57 |
|
| 58 |
+
<!-- Dates & Fines -->
|
| 59 |
+
<div class="grid grid-cols-2 md:grid-cols-3 gap-8 px-8 border-x border-border hidden lg:grid flex-grow">
|
| 60 |
<div>
|
| 61 |
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Borrowed</p>
|
| 62 |
<p class="text-sm font-bold text-main">@loan.BorrowDate.ToString("MMM dd, yyyy")</p>
|
| 63 |
</div>
|
| 64 |
<div>
|
| 65 |
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Due Date</p>
|
| 66 |
+
<p class="text-sm font-bold @(loan.DueDate < DateTime.UtcNow && loan.Status == "Borrowed" ? "text-red-500" : "text-main")">
|
| 67 |
@loan.DueDate.ToString("MMM dd, yyyy")
|
| 68 |
</p>
|
| 69 |
</div>
|
| 70 |
+
<div class="hidden md:block">
|
| 71 |
+
@if (loan.ReturnDate.HasValue)
|
| 72 |
+
{
|
| 73 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Returned</p>
|
| 74 |
+
<p class="text-sm font-bold text-green-500">@loan.ReturnDate.Value.ToString("MMM dd, yyyy")</p>
|
| 75 |
+
}
|
| 76 |
+
else if (loan.FineAmount > 0)
|
| 77 |
+
{
|
| 78 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Fine</p>
|
| 79 |
+
<p class="text-sm font-bold @(loan.IsFinePaid ? "text-muted line-through" : "text-red-500")">
|
| 80 |
+
MMK @loan.FineAmount.ToString("N0")
|
| 81 |
+
</p>
|
| 82 |
+
@if (loan.IsFinePaid)
|
| 83 |
+
{
|
| 84 |
+
<span class="text-[10px] text-green-500 font-black uppercase">Paid</span>
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
else
|
| 88 |
+
{
|
| 89 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-1">Fine</p>
|
| 90 |
+
<p class="text-sm font-bold text-muted">No Fines</p>
|
| 91 |
+
}
|
| 92 |
+
</div>
|
| 93 |
</div>
|
| 94 |
|
| 95 |
<!-- Status & Action -->
|
| 96 |
<div class="flex flex-col md:items-end gap-3 min-w-[150px]">
|
| 97 |
<span class='px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest text-center @GetStatusColor(loan.Status)'>
|
| 98 |
+
@(loan.Status == "PendingReturn" ? "Pending" : loan.Status)
|
| 99 |
</span>
|
| 100 |
@if (loan.Status == "Borrowed")
|
| 101 |
{
|
| 102 |
+
<button @onclick="() => ReturnRequestAsync(loan)" class="text-xs font-bold text-primary hover:underline">
|
| 103 |
Request Return
|
| 104 |
</button>
|
| 105 |
}
|
|
|
|
| 130 |
finally { _isLoading = false; }
|
| 131 |
}
|
| 132 |
|
| 133 |
+
private async Task ReturnRequestAsync(BorrowingDto loan)
|
| 134 |
{
|
| 135 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Request return for \"{loan.BookTitle}\"? It will be marked as pending until approved by a librarian.");
|
| 136 |
+
if (!confirmed) return;
|
| 137 |
+
|
| 138 |
+
var success = await ApiClient.RequestReturnAsync(loan.Id);
|
| 139 |
+
if (success)
|
| 140 |
+
{
|
| 141 |
+
await JS.InvokeVoidAsync("alert", "Return request submitted successfully!");
|
| 142 |
+
await LoadData();
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
await JS.InvokeVoidAsync("alert", "Failed to submit return request.");
|
| 147 |
+
}
|
| 148 |
}
|
| 149 |
|
| 150 |
private string GetStatusColor(string status) => status switch
|
BlazorWebAssembly/Pages/BorrowingsManage.razor
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/borrowings/manage"
|
| 2 |
+
@using BlazorWebAssembly.Services
|
| 3 |
+
@using LibraryManagement.Shared.Models
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
+
|
| 9 |
+
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 10 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-6">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Manage Borrowings</h1>
|
| 13 |
+
<p class="text-muted text-lg font-medium">Monitor and process library loans across all members.</p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="flex gap-3">
|
| 16 |
+
<div class="bg-card px-6 py-3 flex items-center gap-3 border border-border rounded-xl shadow-sm">
|
| 17 |
+
<span class="text-xs font-black text-muted uppercase tracking-widest">Total Active:</span>
|
| 18 |
+
<span class="text-2xl font-black text-primary">@_borrowings.Count(m => m.Status == "Borrowed")</span>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
@if (_isLoading)
|
| 24 |
+
{
|
| 25 |
+
<div class="space-y-4">
|
| 26 |
+
@for (int i = 0; i < 5; i++)
|
| 27 |
+
{
|
| 28 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div>
|
| 29 |
+
}
|
| 30 |
+
</div>
|
| 31 |
+
}
|
| 32 |
+
else
|
| 33 |
+
{
|
| 34 |
+
<div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
|
| 35 |
+
<div class="overflow-x-auto">
|
| 36 |
+
<table class="w-full text-left border-collapse min-w-max whitespace-nowrap">
|
| 37 |
+
<thead class="bg-body border-b border-border">
|
| 38 |
+
<tr>
|
| 39 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Book & Loan Info</th>
|
| 40 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member Details</th>
|
| 41 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Loan Dates</th>
|
| 42 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-center">Status</th>
|
| 43 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Fines</th>
|
| 44 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-right">Actions</th>
|
| 45 |
+
</tr>
|
| 46 |
+
</thead>
|
| 47 |
+
<tbody class="divide-y divide-border">
|
| 48 |
+
@if (!_borrowings.Any())
|
| 49 |
+
{
|
| 50 |
+
<tr>
|
| 51 |
+
<td colspan="6" class="px-6 py-16 text-center text-muted italic bg-body">
|
| 52 |
+
No borrowing records found in the system.
|
| 53 |
+
</td>
|
| 54 |
+
</tr>
|
| 55 |
+
}
|
| 56 |
+
@foreach (var loan in PaginatedBorrowings)
|
| 57 |
+
{
|
| 58 |
+
<tr class="hover:bg-body transition-colors group">
|
| 59 |
+
<td class="px-6 py-5">
|
| 60 |
+
<div class="flex items-center gap-4">
|
| 61 |
+
<div class="w-10 h-14 bg-primary/10 rounded-lg flex items-center justify-center text-primary border border-primary/20">
|
| 62 |
+
<svg class="w-5 h-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 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="min-w-0">
|
| 65 |
+
<p class="text-sm font-bold text-main truncate mb-0.5">@loan.BookTitle</p>
|
| 66 |
+
<p class="text-[10px] font-bold text-muted uppercase tracking-tight">ID: #@loan.Id</p>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</td>
|
| 70 |
+
<td class="px-6 py-5">
|
| 71 |
+
<div class="flex flex-col gap-1">
|
| 72 |
+
<p class="text-sm font-semibold text-main">@loan.UserEmail</p>
|
| 73 |
+
<p class="text-[10px] text-muted font-bold tracking-widest uppercase">UID: #@loan.UserId</p>
|
| 74 |
+
</div>
|
| 75 |
+
</td>
|
| 76 |
+
<td class="px-6 py-5">
|
| 77 |
+
<div class="grid gap-1">
|
| 78 |
+
<div class="flex items-center gap-2 text-[11px]">
|
| 79 |
+
<span class="text-muted font-bold uppercase tracking-widest w-12">Out:</span>
|
| 80 |
+
<span class="font-bold text-main">@loan.BorrowDate.ToString("MMM dd, yyyy")</span>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="flex items-center gap-2 text-[11px]">
|
| 83 |
+
<span class="text-muted font-bold uppercase tracking-widest w-12">Due:</span>
|
| 84 |
+
@{
|
| 85 |
+
var isOverdue = loan.DueDate < DateTime.Now && loan.Status == "Borrowed";
|
| 86 |
+
}
|
| 87 |
+
<span class="font-bold @(isOverdue ? "text-red-500 animate-pulse" : "text-amber-500")">@loan.DueDate.ToString("MMM dd, yyyy")</span>
|
| 88 |
+
</div>
|
| 89 |
+
@if (loan.ReturnDate.HasValue)
|
| 90 |
+
{
|
| 91 |
+
<div class="flex items-center gap-2 text-[11px]">
|
| 92 |
+
<span class="text-muted font-bold uppercase tracking-widest w-12">Back:</span>
|
| 93 |
+
<span class="font-bold text-green-500">@loan.ReturnDate.Value.ToString("MMM dd, yyyy")</span>
|
| 94 |
+
</div>
|
| 95 |
+
}
|
| 96 |
+
</div>
|
| 97 |
+
</td>
|
| 98 |
+
<td class="px-6 py-5 text-center">
|
| 99 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest
|
| 100 |
+
@(loan.Status == "Borrowed" ? "bg-amber-500/10 text-amber-500" :
|
| 101 |
+
loan.Status == "PendingReturn" ? "bg-blue-500/10 text-blue-500 shadow-sm" :
|
| 102 |
+
"bg-green-500/10 text-green-500")">
|
| 103 |
+
@(loan.Status == "PendingReturn" ? "Pending Approval" : loan.Status)
|
| 104 |
+
</span>
|
| 105 |
+
</td>
|
| 106 |
+
<td class="px-6 py-5">
|
| 107 |
+
@if (loan.FineAmount > 0)
|
| 108 |
+
{
|
| 109 |
+
<div class="flex flex-col">
|
| 110 |
+
<span class="text-sm font-extrabold @(loan.IsFinePaid ? "text-muted line-through" : "text-red-500")">
|
| 111 |
+
MMK @loan.FineAmount.ToString("N0")
|
| 112 |
+
</span>
|
| 113 |
+
@if (loan.IsFinePaid)
|
| 114 |
+
{
|
| 115 |
+
<span class="text-[9px] text-green-500 font-black uppercase mt-0.5 tracking-tighter">Settled</span>
|
| 116 |
+
}
|
| 117 |
+
else
|
| 118 |
+
{
|
| 119 |
+
<span class="text-[9px] text-red-400 font-black uppercase mt-0.5 tracking-tighter">Pending</span>
|
| 120 |
+
}
|
| 121 |
+
</div>
|
| 122 |
+
}
|
| 123 |
+
else
|
| 124 |
+
{
|
| 125 |
+
<span class="text-[10px] text-muted font-bold italic">No Fines</span>
|
| 126 |
+
}
|
| 127 |
+
</td>
|
| 128 |
+
<td class="px-6 py-5 text-right">
|
| 129 |
+
@if (loan.Status == "PendingReturn")
|
| 130 |
+
{
|
| 131 |
+
<button @onclick="() => ApproveReturnAsync(loan)"
|
| 132 |
+
class="inline-flex items-center gap-2 bg-green-500/10 text-green-500 border border-green-500/20 px-4 py-2 rounded-lg text-xs font-bold hover:bg-green-500 hover:text-white transition-all shadow-sm active:scale-95">
|
| 133 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 134 |
+
Approve Return
|
| 135 |
+
</button>
|
| 136 |
+
}
|
| 137 |
+
else if (loan.Status == "Borrowed")
|
| 138 |
+
{
|
| 139 |
+
<button @onclick="() => ForceReturnAsync(loan)"
|
| 140 |
+
class="inline-flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-primary-hover transition-all shadow-sm active:scale-95">
|
| 141 |
+
Process Return
|
| 142 |
+
</button>
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
<div class="text-green-500 flex justify-end">
|
| 147 |
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 148 |
+
</div>
|
| 149 |
+
}
|
| 150 |
+
</td>
|
| 151 |
+
</tr>
|
| 152 |
+
}
|
| 153 |
+
</tbody>
|
| 154 |
+
</table>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
@if (TotalPages > 1)
|
| 158 |
+
{
|
| 159 |
+
<div class="px-6 py-4 border-t border-border flex items-center justify-between bg-body">
|
| 160 |
+
<p class="text-xs text-muted">
|
| 161 |
+
Showing <span class="font-bold text-main">@((_currentPage - 1) * _pageSize + 1)</span> to
|
| 162 |
+
<span class="font-bold text-main">@(Math.Min(_currentPage * _pageSize, _borrowings.Count()))</span> of
|
| 163 |
+
<span class="font-bold text-main">@_borrowings.Count()</span> records
|
| 164 |
+
</p>
|
| 165 |
+
<div class="flex items-center gap-2">
|
| 166 |
+
<button disabled="@(_currentPage == 1)" @onclick="() => _currentPage--"
|
| 167 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 168 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 169 |
+
</button>
|
| 170 |
+
|
| 171 |
+
<span class="text-xs font-bold text-main px-2 tracking-tight">Page @_currentPage of @TotalPages</span>
|
| 172 |
+
|
| 173 |
+
<button disabled="@(_currentPage == TotalPages)" @onclick="() => _currentPage++"
|
| 174 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 175 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
}
|
| 180 |
+
</div>
|
| 181 |
+
}
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
@code {
|
| 185 |
+
private bool _isLoading = true;
|
| 186 |
+
private IEnumerable<BorrowingDto> _borrowings = Enumerable.Empty<BorrowingDto>();
|
| 187 |
+
|
| 188 |
+
// Pagination
|
| 189 |
+
private int _currentPage = 1;
|
| 190 |
+
private int _pageSize = 10;
|
| 191 |
+
|
| 192 |
+
private IEnumerable<BorrowingDto> PaginatedBorrowings => _borrowings
|
| 193 |
+
.OrderByDescending(l => l.BorrowDate)
|
| 194 |
+
.Skip((_currentPage - 1) * _pageSize)
|
| 195 |
+
.Take(_pageSize);
|
| 196 |
+
|
| 197 |
+
private int TotalPages => (int)Math.Ceiling(_borrowings.Count() / (double)_pageSize);
|
| 198 |
+
|
| 199 |
+
protected override async Task OnInitializedAsync()
|
| 200 |
+
{
|
| 201 |
+
await LoadDataAsync();
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
private async Task LoadDataAsync()
|
| 205 |
+
{
|
| 206 |
+
_isLoading = true;
|
| 207 |
+
try
|
| 208 |
+
{
|
| 209 |
+
_borrowings = await ApiClient.GetAllBorrowingsAsync();
|
| 210 |
+
_currentPage = 1;
|
| 211 |
+
}
|
| 212 |
+
catch (Exception ex)
|
| 213 |
+
{
|
| 214 |
+
await JS.InvokeVoidAsync("alert", $"Error loading all borrowings: {ex.Message}");
|
| 215 |
+
}
|
| 216 |
+
finally
|
| 217 |
+
{
|
| 218 |
+
_isLoading = false;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
private async Task ApproveReturnAsync(BorrowingDto loan)
|
| 223 |
+
{
|
| 224 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Approve return for \"{loan.BookTitle}\" borrowed by {loan.UserEmail}?");
|
| 225 |
+
if (!confirmed) return;
|
| 226 |
+
|
| 227 |
+
var result = await ApiClient.ReturnBookAsync(loan.Id);
|
| 228 |
+
if (result != null)
|
| 229 |
+
{
|
| 230 |
+
await JS.InvokeVoidAsync("alert", "Return approved successfully!");
|
| 231 |
+
await LoadDataAsync();
|
| 232 |
+
}
|
| 233 |
+
else
|
| 234 |
+
{
|
| 235 |
+
await JS.InvokeVoidAsync("alert", "Failed to approve return.");
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
private async Task ForceReturnAsync(BorrowingDto loan)
|
| 240 |
+
{
|
| 241 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Force process return for \"{loan.BookTitle}\" (Member: {loan.UserEmail})? Use this if the member returned the book in person.");
|
| 242 |
+
if (!confirmed) return;
|
| 243 |
+
|
| 244 |
+
var result = await ApiClient.ReturnBookAsync(loan.Id);
|
| 245 |
+
if (result != null)
|
| 246 |
+
{
|
| 247 |
+
await JS.InvokeVoidAsync("alert", "Book marked as returned!");
|
| 248 |
+
await LoadDataAsync();
|
| 249 |
+
}
|
| 250 |
+
else
|
| 251 |
+
{
|
| 252 |
+
await JS.InvokeVoidAsync("alert", "Failed to process return.");
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
}
|
BlazorWebAssembly/Pages/Contact.razor
CHANGED
|
@@ -1,49 +1,101 @@
|
|
| 1 |
@page "/contact"
|
| 2 |
|
| 3 |
-
<div class="max-w-
|
| 4 |
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24">
|
| 5 |
<div>
|
| 6 |
-
<
|
| 7 |
-
<
|
|
|
|
| 8 |
|
| 9 |
-
<div class="
|
| 10 |
-
<div class="
|
| 11 |
-
<div class="w-
|
| 12 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
| 13 |
</div>
|
| 14 |
-
<
|
| 15 |
-
|
| 16 |
-
<p class="text-lg font-bold text-main">hello@libraryluxe.com</p>
|
| 17 |
-
</div>
|
| 18 |
</div>
|
| 19 |
-
<div class="
|
| 20 |
-
<div class="w-
|
| 21 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
| 22 |
</div>
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
<p class="text-lg font-bold text-main">123 Literature Lane, Booktown</p>
|
| 26 |
-
</div>
|
| 27 |
</div>
|
| 28 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</div>
|
| 30 |
|
| 31 |
-
<div class="bg-card p-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
<form class="space-y-6">
|
| 33 |
-
<div>
|
| 34 |
-
<
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
<div>
|
| 38 |
-
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">
|
| 39 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
<div>
|
| 42 |
-
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">Message</label>
|
| 43 |
-
<textarea rows="
|
| 44 |
</div>
|
| 45 |
-
<button type="button" class="w-full py-
|
| 46 |
</form>
|
| 47 |
</div>
|
| 48 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</div>
|
|
|
|
|
|
| 1 |
@page "/contact"
|
| 2 |
|
| 3 |
+
<div class="max-w-7xl mx-auto py-24 px-8 animate-fade space-y-32">
|
| 4 |
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24">
|
| 5 |
<div>
|
| 6 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Get in Touch</h2>
|
| 7 |
+
<h1 class="text-7xl font-black tracking-tighter text-main mb-8 uppercase leading-[0.9]">Let's <br /><span class="text-primary italic">Connect.</span></h1>
|
| 8 |
+
<p class="text-xl text-muted leading-relaxed mb-12">Have questions about our membership plans or need help with your account? Our librarians are here to help you 24/7.</p>
|
| 9 |
|
| 10 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8 mb-12">
|
| 11 |
+
<div class="p-8 bg-card rounded-3xl border border-border hover:border-primary/30 transition-colors">
|
| 12 |
+
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center text-primary mb-6">
|
| 13 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
| 14 |
</div>
|
| 15 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest mb-1">Email Us</p>
|
| 16 |
+
<p class="text-sm font-bold text-main">hello@libraryluxe.com</p>
|
|
|
|
|
|
|
| 17 |
</div>
|
| 18 |
+
<div class="p-8 bg-card rounded-3xl border border-border hover:border-primary/30 transition-colors">
|
| 19 |
+
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center text-primary mb-6">
|
| 20 |
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
| 21 |
</div>
|
| 22 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest mb-1">Visit Us</p>
|
| 23 |
+
<p class="text-sm font-bold text-main">123 Literature Lane, NY</p>
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
</div>
|
| 26 |
+
|
| 27 |
+
<div class="bg-primary/5 p-8 rounded-3xl border border-primary/10">
|
| 28 |
+
<h4 class="text-xs font-black uppercase tracking-widest text-primary mb-4">Response Time</h4>
|
| 29 |
+
<p class="text-sm text-main font-medium leading-relaxed">We typically respond to all inquiries within 2 business hours. For urgent account issues, please use our internal support ticket system after signing in.</p>
|
| 30 |
+
</div>
|
| 31 |
</div>
|
| 32 |
|
| 33 |
+
<div class="bg-card p-12 rounded-[3.5rem] border border-border shadow-2xl relative">
|
| 34 |
+
<div class="absolute top-0 right-0 p-8">
|
| 35 |
+
<svg class="w-12 h-12 text-primary opacity-10" fill="currentColor" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z"/></svg>
|
| 36 |
+
</div>
|
| 37 |
+
<h3 class="text-2xl font-black text-main mb-10 uppercase tracking-tight">Send a Message</h3>
|
| 38 |
<form class="space-y-6">
|
| 39 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 40 |
+
<div>
|
| 41 |
+
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">First Name</label>
|
| 42 |
+
<input type="text" class="w-full bg-body border border-border rounded-2xl px-6 py-5 focus:ring-2 focus:ring-primary outline-none transition-all" placeholder="John" />
|
| 43 |
+
</div>
|
| 44 |
+
<div>
|
| 45 |
+
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">Email Address</label>
|
| 46 |
+
<input type="email" class="w-full bg-body border border-border rounded-2xl px-6 py-5 focus:ring-2 focus:ring-primary outline-none transition-all" placeholder="john@example.com" />
|
| 47 |
+
</div>
|
| 48 |
</div>
|
| 49 |
<div>
|
| 50 |
+
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">Subject</label>
|
| 51 |
+
<select class="w-full bg-body border border-border rounded-2xl px-6 py-5 focus:ring-2 focus:ring-primary outline-none transition-all appearance-none">
|
| 52 |
+
<option>General Inquiry</option>
|
| 53 |
+
<option>Membership Issues</option>
|
| 54 |
+
<option>Technical Support</option>
|
| 55 |
+
<option>Partnership</option>
|
| 56 |
+
</select>
|
| 57 |
</div>
|
| 58 |
<div>
|
| 59 |
+
<label class="block text-[10px] font-black text-muted uppercase tracking-[0.2em] mb-3">Your Message</label>
|
| 60 |
+
<textarea rows="5" class="w-full bg-body border border-border rounded-2xl px-6 py-5 focus:ring-2 focus:ring-primary outline-none transition-all" placeholder="Tell us more about your inquiry..."></textarea>
|
| 61 |
</div>
|
| 62 |
+
<button type="button" class="w-full py-6 bg-primary text-white rounded-2xl font-black uppercase tracking-widest hover:bg-primary-hover shadow-2xl shadow-primary/30 transition-all transform hover:scale-[1.02] active:scale-95">Dispatch Message</button>
|
| 63 |
</form>
|
| 64 |
</div>
|
| 65 |
</div>
|
| 66 |
+
|
| 67 |
+
<!-- FAQ Snippet -->
|
| 68 |
+
<section>
|
| 69 |
+
<div class="text-center mb-16">
|
| 70 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Quick Answers</h2>
|
| 71 |
+
<h3 class="text-5xl font-black text-main tracking-tight">Frequently Asked Questions</h3>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="max-w-3xl mx-auto space-y-4">
|
| 74 |
+
<div class="bg-card border border-border rounded-2xl p-6 hover:border-primary/30 transition-colors cursor-pointer group">
|
| 75 |
+
<div class="flex items-center justify-between">
|
| 76 |
+
<h4 class="font-bold text-main">How do I upgrade my membership?</h4>
|
| 77 |
+
<svg class="w-5 h-5 text-muted group-hover:text-primary transition-colors" 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"></path></svg>
|
| 78 |
+
</div>
|
| 79 |
+
<p class="mt-4 text-sm text-muted hidden group-hover:block animate-fade">You can upgrade your membership at any time from your account dashboard. The price will be prorated based on your current cycle.</p>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="bg-card border border-border rounded-2xl p-6 hover:border-primary/30 transition-colors cursor-pointer group">
|
| 82 |
+
<div class="flex items-center justify-between">
|
| 83 |
+
<h4 class="font-bold text-main">What happens if I return a book late?</h4>
|
| 84 |
+
<svg class="w-5 h-5 text-muted group-hover:text-primary transition-colors" 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"></path></svg>
|
| 85 |
+
</div>
|
| 86 |
+
<p class="mt-4 text-sm text-muted hidden group-hover:block animate-fade">Late returns may incur a small fee and deduction of loyalty points. We recommend using our renewal feature to avoid penalties.</p>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="bg-card border border-border rounded-2xl p-6 hover:border-primary/30 transition-colors cursor-pointer group">
|
| 89 |
+
<div class="flex items-center justify-between">
|
| 90 |
+
<h4 class="font-bold text-main">Can I access digital books offline?</h4>
|
| 91 |
+
<svg class="w-5 h-5 text-muted group-hover:text-primary transition-colors" 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"></path></svg>
|
| 92 |
+
</div>
|
| 93 |
+
<p class="mt-4 text-sm text-muted hidden group-hover:block animate-fade">Yes, our mobile app supports offline reading for all digital titles borrowed under your plan.</p>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="text-center mt-12">
|
| 97 |
+
<a href="/faq" class="text-xs font-black text-primary uppercase tracking-widest hover:underline">View All FAQs</a>
|
| 98 |
+
</div>
|
| 99 |
+
</section>
|
| 100 |
</div>
|
| 101 |
+
|
BlazorWebAssembly/Pages/FAQ.razor
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/faq"
|
| 2 |
+
|
| 3 |
+
<div class="max-w-4xl mx-auto py-24 px-8 animate-fade">
|
| 4 |
+
<div class="text-center mb-20">
|
| 5 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Support Center</h2>
|
| 6 |
+
<h1 class="text-6xl font-black tracking-tighter text-main mb-8 uppercase">Common Questions</h1>
|
| 7 |
+
<p class="text-xl text-muted max-w-2xl mx-auto">
|
| 8 |
+
Everything you need to know about LibraryLuxe, our membership plans, and our community.
|
| 9 |
+
</p>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="space-y-6">
|
| 13 |
+
<!-- Category: General -->
|
| 14 |
+
<div class="mb-12">
|
| 15 |
+
<h3 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-8 border-b border-border pb-4">General Information</h3>
|
| 16 |
+
<div class="space-y-4">
|
| 17 |
+
<details class="group bg-card border border-border rounded-3xl overflow-hidden transition-all hover:border-primary/30">
|
| 18 |
+
<summary class="flex items-center justify-between p-8 cursor-pointer list-none">
|
| 19 |
+
<span class="text-lg font-bold text-main">What is LibraryLuxe?</span>
|
| 20 |
+
<div class="w-8 h-8 rounded-full bg-body border border-border flex items-center justify-center group-open:rotate-180 transition-transform">
|
| 21 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 22 |
+
</div>
|
| 23 |
+
</summary>
|
| 24 |
+
<div class="px-8 pb-8 text-muted leading-relaxed">
|
| 25 |
+
LibraryLuxe is a next-generation library management ecosystem designed to bridge the gap between physical books and digital convenience. We offer a reward-based reading system, smart notifications, and a global community for book lovers.
|
| 26 |
+
</div>
|
| 27 |
+
</details>
|
| 28 |
+
<details class="group bg-card border border-border rounded-3xl overflow-hidden transition-all hover:border-primary/30">
|
| 29 |
+
<summary class="flex items-center justify-between p-8 cursor-pointer list-none">
|
| 30 |
+
<span class="text-lg font-bold text-main">Is there a mobile app?</span>
|
| 31 |
+
<div class="w-8 h-8 rounded-full bg-body border border-border flex items-center justify-center group-open:rotate-180 transition-transform">
|
| 32 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 33 |
+
</div>
|
| 34 |
+
</summary>
|
| 35 |
+
<div class="px-8 pb-8 text-muted leading-relaxed">
|
| 36 |
+
Yes! Our mobile app is available for both iOS and Android. It features offline reading, instant barcode scanning for borrowing, and real-time alerts.
|
| 37 |
+
</div>
|
| 38 |
+
</details>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- Category: Membership -->
|
| 43 |
+
<div class="mb-12">
|
| 44 |
+
<h3 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-8 border-b border-border pb-4">Membership & Billing</h3>
|
| 45 |
+
<div class="space-y-4">
|
| 46 |
+
<details class="group bg-card border border-border rounded-3xl overflow-hidden transition-all hover:border-primary/30">
|
| 47 |
+
<summary class="flex items-center justify-between p-8 cursor-pointer list-none">
|
| 48 |
+
<span class="text-lg font-bold text-main">Can I cancel my membership anytime?</span>
|
| 49 |
+
<div class="w-8 h-8 rounded-full bg-body border border-border flex items-center justify-center group-open:rotate-180 transition-transform">
|
| 50 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 51 |
+
</div>
|
| 52 |
+
</summary>
|
| 53 |
+
<div class="px-8 pb-8 text-muted leading-relaxed">
|
| 54 |
+
Absolutely. We don't believe in locking you down. You can cancel your subscription from your account settings at any time. You'll retain access until the end of your current billing period.
|
| 55 |
+
</div>
|
| 56 |
+
</details>
|
| 57 |
+
<details class="group bg-card border border-border rounded-3xl overflow-hidden transition-all hover:border-primary/30">
|
| 58 |
+
<summary class="flex items-center justify-between p-8 cursor-pointer list-none">
|
| 59 |
+
<span class="text-lg font-bold text-main">How do loyalty points work?</span>
|
| 60 |
+
<div class="w-8 h-8 rounded-full bg-body border border-border flex items-center justify-center group-open:rotate-180 transition-transform">
|
| 61 |
+
<svg class="w-4 h-4" 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"></path></svg>
|
| 62 |
+
</div>
|
| 63 |
+
</summary>
|
| 64 |
+
<div class="px-8 pb-8 text-muted leading-relaxed">
|
| 65 |
+
You earn points for every book you return on time, every review you write, and every milestone you hit. These points can be used to pay for your membership, enter exclusive giveaways, or buy digital gift cards.
|
| 66 |
+
</div>
|
| 67 |
+
</details>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<!-- Contact CTA -->
|
| 73 |
+
<div class="mt-20 p-12 bg-primary/5 rounded-[3rem] border border-primary/10 text-center">
|
| 74 |
+
<h4 class="text-2xl font-black text-main mb-4 uppercase tracking-tight">Still have questions?</h4>
|
| 75 |
+
<p class="text-muted mb-8">If you couldn't find what you were looking for, our team is ready to help.</p>
|
| 76 |
+
<a href="/contact" class="inline-block bg-primary text-white px-10 py-4 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Contact Support</a>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
BlazorWebAssembly/Pages/Features.razor
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/features"
|
| 2 |
+
|
| 3 |
+
<div class="max-w-7xl mx-auto py-24 px-8 animate-fade space-y-32">
|
| 4 |
+
<div class="text-center">
|
| 5 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Deep Dive</h2>
|
| 6 |
+
<h1 class="text-7xl font-black tracking-tighter text-main mb-8 uppercase leading-[0.9]">Advanced <br /> <span class="text-primary italic">Capabilities.</span></h1>
|
| 7 |
+
<p class="text-xl text-muted max-w-2xl mx-auto leading-relaxed">
|
| 8 |
+
LibraryLuxe is built on a foundation of modern technology to provide you with a seamless and rewarding experience.
|
| 9 |
+
</p>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<!-- Feature Block 1: Loyalty -->
|
| 13 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
|
| 14 |
+
<div class="order-2 lg:order-1">
|
| 15 |
+
<div class="bg-card aspect-video rounded-[3rem] border border-border shadow-2xl flex items-center justify-center relative overflow-hidden">
|
| 16 |
+
<div class="absolute inset-0 bg-primary opacity-5"></div>
|
| 17 |
+
<div class="relative z-10 flex flex-col items-center gap-6">
|
| 18 |
+
<div class="w-24 h-24 bg-primary rounded-3xl shadow-2xl shadow-primary/30 flex items-center justify-center text-white text-4xl font-black">L</div>
|
| 19 |
+
<span class="text-[10px] font-black text-muted uppercase tracking-[0.4em]">Loyalty Dashboard</span>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="order-1 lg:order-2">
|
| 24 |
+
<h3 class="text-4xl font-black text-main mb-6 uppercase tracking-tight">Smart Rewards <br/> Ecosystem</h3>
|
| 25 |
+
<p class="text-lg text-muted leading-relaxed mb-8">
|
| 26 |
+
Our proprietary loyalty system rewards positive reading habits. By gamifying the library experience, we've increased on-time returns by 40% and overall community engagement by 60%.
|
| 27 |
+
</p>
|
| 28 |
+
<ul class="space-y-4">
|
| 29 |
+
<li class="flex items-center gap-4">
|
| 30 |
+
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
| 31 |
+
<span class="font-bold text-main">Point Multipliers for Gold Members</span>
|
| 32 |
+
</li>
|
| 33 |
+
<li class="flex items-center gap-4">
|
| 34 |
+
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
| 35 |
+
<span class="font-bold text-main">Milestone Badges & Achievements</span>
|
| 36 |
+
</li>
|
| 37 |
+
<li class="flex items-center gap-4">
|
| 38 |
+
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
| 39 |
+
<span class="font-bold text-main">Internal Rewards Marketplace</span>
|
| 40 |
+
</li>
|
| 41 |
+
</ul>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<!-- Feature Block 2: Notifications -->
|
| 46 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
|
| 47 |
+
<div>
|
| 48 |
+
<h3 class="text-4xl font-black text-main mb-6 uppercase tracking-tight">Instant Alerts <br/> via Firebase</h3>
|
| 49 |
+
<p class="text-lg text-muted leading-relaxed mb-8">
|
| 50 |
+
Stay connected with real-time push notifications. Whether it's a book you've wishlisted becoming available or a reminder about a due date, we ensure you never miss a beat.
|
| 51 |
+
</p>
|
| 52 |
+
<div class="grid grid-cols-2 gap-6">
|
| 53 |
+
<div class="p-6 bg-card rounded-2xl border border-border">
|
| 54 |
+
<h5 class="font-black text-primary text-xs uppercase mb-2">Back in Stock</h5>
|
| 55 |
+
<p class="text-xs text-muted">Get alerted when high-demand titles return.</p>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="p-6 bg-card rounded-2xl border border-border">
|
| 58 |
+
<h5 class="font-black text-primary text-xs uppercase mb-2">Due Soon</h5>
|
| 59 |
+
<p class="text-xs text-muted">Gentle reminders 48h before expiry.</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="bg-card aspect-video rounded-[3rem] border border-border shadow-2xl flex items-center justify-center relative overflow-hidden">
|
| 64 |
+
<div class="absolute inset-0 bg-emerald-500 opacity-5"></div>
|
| 65 |
+
<div class="relative z-10 flex flex-col items-center gap-6">
|
| 66 |
+
<div class="w-16 h-16 bg-emerald-500 rounded-2xl shadow-xl shadow-emerald-500/20 flex items-center justify-center text-white">
|
| 67 |
+
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
| 68 |
+
</div>
|
| 69 |
+
<span class="text-[10px] font-black text-muted uppercase tracking-[0.4em]">Real-time Infrastructure</span>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<!-- Recommendation System -->
|
| 75 |
+
<section class="bg-card p-20 rounded-[4rem] border border-border text-center">
|
| 76 |
+
<h3 class="text-4xl font-black text-main mb-8 uppercase tracking-tight italic">Personalized Recommendations</h3>
|
| 77 |
+
<p class="text-xl text-muted max-w-3xl mx-auto mb-12">
|
| 78 |
+
Our intelligent algorithms analyze your reading history and preferences to suggest your next favorite book. The more you read, the smarter it gets.
|
| 79 |
+
</p>
|
| 80 |
+
<div class="flex flex-wrap justify-center gap-8 opacity-40 grayscale">
|
| 81 |
+
<div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
|
| 82 |
+
<div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
|
| 83 |
+
<div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
|
| 84 |
+
<div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
|
| 85 |
+
</div>
|
| 86 |
+
</section>
|
| 87 |
+
</div>
|
BlazorWebAssembly/Pages/Home.razor
CHANGED
|
@@ -17,133 +17,330 @@
|
|
| 17 |
<p class="text-lg text-muted">Here is what's happening with your library account today.</p>
|
| 18 |
</section>
|
| 19 |
|
| 20 |
-
<
|
| 21 |
-
<
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<div class="bg-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
-
</
|
|
|
|
| 32 |
|
| 33 |
-
|
| 34 |
-
<
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
<section>
|
| 52 |
-
<h2 class="text-xl font-bold mb-6 text-main">
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
</section>
|
| 60 |
</div>
|
| 61 |
</Authorized>
|
| 62 |
<NotAuthorized>
|
| 63 |
<!-- Guest Landing Page -->
|
| 64 |
-
<div class="space-y-
|
| 65 |
<!-- Hero Section -->
|
| 66 |
-
<section class="
|
| 67 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
</section>
|
| 82 |
|
| 83 |
-
<!-- Stats
|
| 84 |
-
<div class="
|
| 85 |
-
<div class="
|
| 86 |
-
<
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
</div>
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
-
<div class="
|
| 94 |
-
<
|
| 95 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
</div>
|
| 97 |
-
</
|
| 98 |
|
| 99 |
-
<!--
|
| 100 |
-
<section class="
|
| 101 |
-
<div class="
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
<
|
| 109 |
-
|
| 110 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
-
<div class="
|
| 114 |
-
<div class="
|
| 115 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
-
<div>
|
| 118 |
-
<
|
| 119 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</div>
|
| 128 |
</section>
|
| 129 |
</div>
|
| 130 |
</NotAuthorized>
|
|
|
|
| 131 |
</AuthorizeView>
|
| 132 |
|
| 133 |
|
| 134 |
@code {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
private LoyaltyAccountDto? _loyaltyAccount;
|
|
|
|
|
|
|
| 136 |
|
| 137 |
protected override async Task OnInitializedAsync()
|
| 138 |
{
|
| 139 |
try
|
| 140 |
{
|
| 141 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
{
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
catch { }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
}
|
|
|
|
| 17 |
<p class="text-lg text-muted">Here is what's happening with your library account today.</p>
|
| 18 |
</section>
|
| 19 |
|
| 20 |
+
<AuthorizeView Roles="Librarian" Context="LibrarianContext">
|
| 21 |
+
<Authorized>
|
| 22 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
| 23 |
+
<div class="bg-card p-6 flex flex-col gap-3 group border border-border rounded-3xl transition-all shadow-sm">
|
| 24 |
+
<div class="flex items-center justify-between">
|
| 25 |
+
<span class="text-[10px] font-black text-muted uppercase tracking-widest">Total Books</span>
|
| 26 |
+
</div>
|
| 27 |
+
<div class="text-3xl font-black text-main">@_totalBooks</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="bg-card p-6 flex flex-col gap-3 group border border-border rounded-3xl transition-all shadow-sm">
|
| 30 |
+
<div class="flex items-center justify-between">
|
| 31 |
+
<span class="text-[10px] font-black text-muted uppercase tracking-widest">Active Loans</span>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="text-3xl font-black text-main">@_activeLoans</div>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="bg-card p-6 flex flex-col gap-3 group border border-border rounded-3xl transition-all shadow-sm border-l-4 border-l-red-500">
|
| 36 |
+
<div class="flex items-center justify-between">
|
| 37 |
+
<span class="text-[10px] font-black text-red-500 uppercase tracking-widest">Overdue</span>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="text-3xl font-black text-red-600">@_overdueLoans</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="bg-card p-6 flex flex-col gap-3 group border border-border rounded-3xl transition-all shadow-sm">
|
| 42 |
+
<div class="flex items-center justify-between">
|
| 43 |
+
<span class="text-[10px] font-black text-muted uppercase tracking-widest">Active Members</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="text-3xl font-black text-main">@_totalMembers</div>
|
| 46 |
+
</div>
|
| 47 |
</div>
|
| 48 |
+
</Authorized>
|
| 49 |
+
</AuthorizeView>
|
| 50 |
|
| 51 |
+
<AuthorizeView Roles="Member" Context="MemberContext">
|
| 52 |
+
<Authorized>
|
| 53 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 54 |
+
<!-- Loyalty Points -->
|
| 55 |
+
<div class="bg-card p-8 rounded-3xl border border-border shadow-sm hover:shadow-md transition-all">
|
| 56 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest mb-4">Loyalty Balance</p>
|
| 57 |
+
<div class="flex items-baseline gap-2 mb-4">
|
| 58 |
+
<h3 class="text-5xl font-black text-main">@(_loyaltyAccount?.CurrentBalance.ToString("N0") ?? "0")</h3>
|
| 59 |
+
<span class="text-xs font-bold text-primary">POINTS</span>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="flex items-center justify-between">
|
| 62 |
+
<span class="text-xs font-bold text-muted uppercase">Tier: @(_loyaltyAccount?.Tier ?? "Member")</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
|
| 66 |
+
<!-- Active Loans -->
|
| 67 |
+
<div class="bg-card p-8 rounded-3xl border border-border shadow-sm hover:shadow-md transition-all">
|
| 68 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest mb-4">Current Loans</p>
|
| 69 |
+
<h3 class="text-5xl font-black text-main mb-4">@_activeLoans</h3>
|
| 70 |
+
<div class="flex gap-2">
|
| 71 |
+
@if (_overdueLoans > 0)
|
| 72 |
+
{
|
| 73 |
+
<span class="px-3 py-1 bg-red-500/10 text-red-600 text-[10px] font-black uppercase rounded-lg">@_overdueLoans Overdue</span>
|
| 74 |
+
}
|
| 75 |
+
else
|
| 76 |
+
{
|
| 77 |
+
<span class="px-3 py-1 bg-green-500/10 text-green-600 text-[10px] font-black uppercase rounded-lg">Healthy</span>
|
| 78 |
+
}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<!-- Subscription -->
|
| 83 |
+
<div class="bg-card p-8 rounded-3xl border border-border shadow-sm hover:shadow-md transition-all">
|
| 84 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest mb-4">Membership</p>
|
| 85 |
+
<h3 class="text-2xl font-black text-main mb-1 uppercase">@(_currentSub?.MembershipType ?? "Free")</h3>
|
| 86 |
+
<p class="text-xs text-muted mb-6">@( _currentSub != null ? "Active Status" : "No Active Plan" )</p>
|
| 87 |
+
<a href="/membership" class="text-xs font-black text-primary uppercase tracking-widest hover:underline">Manage Plan</a>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</Authorized>
|
| 91 |
+
</AuthorizeView>
|
| 92 |
|
| 93 |
<section>
|
| 94 |
+
<h2 class="text-xl font-bold mb-6 text-main">Recent Arrivals</h2>
|
| 95 |
+
@if (_recentBooks.Any())
|
| 96 |
+
{
|
| 97 |
+
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
| 98 |
+
@foreach (var book in _recentBooks)
|
| 99 |
+
{
|
| 100 |
+
<a href="/books" class="group cursor-pointer">
|
| 101 |
+
<div class="bg-card aspect-[2/3] rounded-2xl border border-border overflow-hidden relative mb-2 group-hover:border-primary/50 transition-colors">
|
| 102 |
+
<div class="absolute inset-0 flex items-center justify-center text-primary/20 group-hover:text-primary/40 transition-colors">
|
| 103 |
+
<svg class="w-12 h-12" 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 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
<h3 class="text-xs font-bold text-main truncate">@book.Title</h3>
|
| 107 |
+
<p class="text-[10px] text-muted truncate">@book.Author</p>
|
| 108 |
+
</a>
|
| 109 |
+
}
|
| 110 |
+
</div>
|
| 111 |
+
}
|
| 112 |
+
else
|
| 113 |
+
{
|
| 114 |
+
<div class="text-muted text-sm italic">Loading recent arrivals...</div>
|
| 115 |
+
}
|
| 116 |
</section>
|
| 117 |
</div>
|
| 118 |
</Authorized>
|
| 119 |
<NotAuthorized>
|
| 120 |
<!-- Guest Landing Page -->
|
| 121 |
+
<div class="space-y-32 animate-fade pb-24">
|
| 122 |
<!-- Hero Section -->
|
| 123 |
+
<section class="relative pt-20 overflow-hidden">
|
| 124 |
+
<div class="max-w-7xl mx-auto px-8 relative z-10">
|
| 125 |
+
<div class="text-center">
|
| 126 |
+
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest mb-8 animate-bounce">
|
| 127 |
+
<span class="relative flex h-2 w-2">
|
| 128 |
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
| 129 |
+
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
| 130 |
+
</span>
|
| 131 |
+
New: Audiobooks Collection Now Live
|
| 132 |
+
</div>
|
| 133 |
+
<h1 class="text-8xl font-black tracking-tighter text-main mb-8 leading-[0.9]">
|
| 134 |
+
Where Every Page <br />
|
| 135 |
+
<span class="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-500 italic">Finds a Purpose.</span>
|
| 136 |
+
</h1>
|
| 137 |
+
<p class="text-xl text-muted max-w-2xl mx-auto leading-relaxed mb-12">
|
| 138 |
+
Join the next generation of readers in a digital-first library experience. Access thousands of titles, earn loyalty rewards, and track your progress seamlessly.
|
| 139 |
+
</p>
|
| 140 |
+
<div class="flex flex-col sm:flex-row items-center justify-center gap-6">
|
| 141 |
+
<a href="/register" class="w-full sm:w-auto bg-primary text-white px-10 py-5 rounded-2xl font-black text-lg hover:bg-primary-hover shadow-2xl shadow-primary/30 transition-all transform hover:scale-105 active:scale-95 inline-block text-center">
|
| 142 |
+
Start Reading Now
|
| 143 |
+
</a>
|
| 144 |
+
<a href="/membership" class="w-full sm:w-auto bg-card border border-border text-main px-10 py-5 rounded-2xl font-black text-lg hover:bg-body transition-all inline-block text-center">
|
| 145 |
+
View Memberships
|
| 146 |
+
</a>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
<!-- Background Decorative Elements -->
|
| 151 |
+
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full -z-10 pointer-events-none opacity-20">
|
| 152 |
+
<div class="absolute top-20 left-1/4 w-96 h-96 bg-primary/30 rounded-full blur-[120px]"></div>
|
| 153 |
+
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-emerald-500/20 rounded-full blur-[120px]"></div>
|
| 154 |
</div>
|
| 155 |
</section>
|
| 156 |
|
| 157 |
+
<!-- Stats Grid -->
|
| 158 |
+
<div class="max-w-7xl mx-auto px-8">
|
| 159 |
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 bg-card border border-border p-12 rounded-[3rem] shadow-xl">
|
| 160 |
+
<div class="text-center">
|
| 161 |
+
<h3 class="text-5xl font-black text-main mb-2 tracking-tighter">12k+</h3>
|
| 162 |
+
<p class="text-muted font-bold uppercase tracking-widest text-[10px]">Verified Books</p>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="text-center border-l border-border">
|
| 165 |
+
<h3 class="text-5xl font-black text-main mb-2 tracking-tighter">5.2k</h3>
|
| 166 |
+
<p class="text-muted font-bold uppercase tracking-widest text-[10px]">Active Readers</p>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="text-center border-l border-border">
|
| 169 |
+
<h3 class="text-5xl font-black text-main mb-2 tracking-tighter">98%</h3>
|
| 170 |
+
<p class="text-muted font-bold uppercase tracking-widest text-[10px]">Satisfaction</p>
|
| 171 |
+
</div>
|
| 172 |
+
<div class="text-center border-l border-border">
|
| 173 |
+
<h3 class="text-5xl font-black text-main mb-2 tracking-tighter">24/7</h3>
|
| 174 |
+
<p class="text-muted font-bold uppercase tracking-widest text-[10px]">Digital Access</p>
|
| 175 |
+
</div>
|
| 176 |
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<!-- Features Section -->
|
| 180 |
+
<section class="max-w-7xl mx-auto px-8">
|
| 181 |
+
<div class="text-center mb-20">
|
| 182 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Core Ecosystem</h2>
|
| 183 |
+
<h3 class="text-5xl font-black text-main tracking-tight">Everything you need to <br/> master your reading journey.</h3>
|
| 184 |
</div>
|
| 185 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
| 186 |
+
<!-- Feature 1 -->
|
| 187 |
+
<div class="group bg-card p-10 rounded-[2.5rem] border border-border hover:border-primary/50 transition-all hover:shadow-2xl hover:shadow-primary/5">
|
| 188 |
+
<div class="w-14 h-14 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-8 group-hover:scale-110 transition-transform">
|
| 189 |
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.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 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 190 |
+
</div>
|
| 191 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Borrow Thousands</h4>
|
| 192 |
+
<p class="text-muted text-sm leading-relaxed">Access our vast physical and digital catalog with a single click. No more waiting in lines.</p>
|
| 193 |
+
</div>
|
| 194 |
+
<!-- Feature 2 -->
|
| 195 |
+
<div class="group bg-card p-10 rounded-[2.5rem] border border-border hover:border-primary/50 transition-all hover:shadow-2xl hover:shadow-primary/5">
|
| 196 |
+
<div class="w-14 h-14 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-8 group-hover:scale-110 transition-transform">
|
| 197 |
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
| 198 |
+
</div>
|
| 199 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Track Reading</h4>
|
| 200 |
+
<p class="text-muted text-sm leading-relaxed">Visualize your reading habits with detailed analytics and personal dashboards.</p>
|
| 201 |
+
</div>
|
| 202 |
+
<!-- Feature 3 -->
|
| 203 |
+
<div class="group bg-card p-10 rounded-[2.5rem] border border-border hover:border-primary/50 transition-all hover:shadow-2xl hover:shadow-primary/5">
|
| 204 |
+
<div class="w-14 h-14 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-8 group-hover:scale-110 transition-transform">
|
| 205 |
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
| 206 |
+
</div>
|
| 207 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Stay Notified</h4>
|
| 208 |
+
<p class="text-muted text-sm leading-relaxed">Never miss a due date with Firebase-powered instant renewal notifications.</p>
|
| 209 |
+
</div>
|
| 210 |
+
<!-- Feature 4 -->
|
| 211 |
+
<div class="group bg-card p-10 rounded-[2.5rem] border border-border hover:border-primary/50 transition-all hover:shadow-2xl hover:shadow-primary/5">
|
| 212 |
+
<div class="w-14 h-14 bg-primary/10 rounded-2xl flex items-center justify-center text-primary mb-8 group-hover:scale-110 transition-transform">
|
| 213 |
+
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"></path></svg>
|
| 214 |
+
</div>
|
| 215 |
+
<h4 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Earn Loyalty</h4>
|
| 216 |
+
<p class="text-muted text-sm leading-relaxed">Collect points for every interaction and redeem them for premium membership tiers.</p>
|
| 217 |
+
</div>
|
| 218 |
</div>
|
| 219 |
+
</section>
|
| 220 |
|
| 221 |
+
<!-- Testimonials -->
|
| 222 |
+
<section class="bg-body border-y border-border py-32 relative overflow-hidden">
|
| 223 |
+
<div class="max-w-7xl mx-auto px-8 relative z-10">
|
| 224 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
|
| 225 |
+
<div>
|
| 226 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Wall of Praise</h2>
|
| 227 |
+
<h3 class="text-6xl font-black text-main tracking-tighter mb-8 leading-tight">Loved by <br/> modern readers.</h3>
|
| 228 |
+
<p class="text-lg text-muted leading-relaxed mb-12">
|
| 229 |
+
Don't just take our word for it. Join thousands of satisfied members who have transformed their reading habits.
|
| 230 |
+
</p>
|
| 231 |
+
<div class="flex gap-4">
|
| 232 |
+
<div class="flex -space-x-4">
|
| 233 |
+
@for(int i=1; i<=4; i++) {
|
| 234 |
+
<div class="w-12 h-12 rounded-full border-4 border-card bg-primary/20 flex items-center justify-center text-xs font-bold">U@i</div>
|
| 235 |
+
}
|
| 236 |
+
</div>
|
| 237 |
+
<div class="flex flex-col justify-center">
|
| 238 |
+
<span class="text-sm font-black text-main uppercase">5,000+ Active Members</span>
|
| 239 |
+
<span class="text-[10px] text-muted font-bold uppercase tracking-widest">Global Community</span>
|
| 240 |
+
</div>
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
+
<div class="space-y-6">
|
| 244 |
+
<div class="bg-card p-10 rounded-[2.5rem] border border-border shadow-xl transform hover:-rotate-1 transition-transform">
|
| 245 |
+
<div class="flex gap-1 text-primary mb-6">
|
| 246 |
+
@for(int i=0; i<5; i++) { <svg class="w-4 h-4 fill-current" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg> }
|
| 247 |
+
</div>
|
| 248 |
+
<p class="text-main font-medium italic mb-6 leading-relaxed">"The loyalty system is a game changer. I've earned three months of free premium just by returning my books on time!"</p>
|
| 249 |
+
<div class="flex items-center gap-4">
|
| 250 |
+
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-black text-xs text-primary">SC</div>
|
| 251 |
+
<div>
|
| 252 |
+
<h5 class="text-sm font-bold text-main">Sarah Chen</h5>
|
| 253 |
+
<p class="text-[10px] text-muted font-bold uppercase">Gold Member</p>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
</div>
|
| 257 |
+
<div class="bg-card p-10 rounded-[2.5rem] border border-border shadow-xl transform translate-x-8 hover:rotate-1 transition-transform">
|
| 258 |
+
<p class="text-main font-medium italic mb-6 leading-relaxed">"Finally a library system that feels like it belongs in 2026. The interface is stunning and the notifications are so helpful."</p>
|
| 259 |
+
<div class="flex items-center gap-4">
|
| 260 |
+
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-black text-xs text-primary">JM</div>
|
| 261 |
+
<div>
|
| 262 |
+
<h5 class="text-sm font-bold text-main">James Miller</h5>
|
| 263 |
+
<p class="text-[10px] text-muted font-bold uppercase">Active Reader</p>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
+
</section>
|
| 271 |
+
|
| 272 |
+
<!-- Final CTA -->
|
| 273 |
+
<section class="max-w-7xl mx-auto px-8">
|
| 274 |
+
<div class="bg-primary p-16 md:p-24 rounded-[4rem] text-center text-white relative overflow-hidden shadow-2xl shadow-primary/40">
|
| 275 |
+
<div class="relative z-10">
|
| 276 |
+
<h2 class="text-5xl md:text-7xl font-black tracking-tighter mb-8 leading-tight">Ready to start your <br/> next chapter?</h2>
|
| 277 |
+
<p class="text-xl text-white/80 max-w-2xl mx-auto mb-12 font-medium">Join thousands of readers today and get your first month of Premium for free.</p>
|
| 278 |
+
<div class="flex flex-col sm:flex-row items-center justify-center gap-6">
|
| 279 |
+
<a href="/register" class="w-full sm:w-auto bg-white text-primary px-12 py-6 rounded-2xl font-black text-xl hover:bg-white/90 transition-all transform hover:scale-105 shadow-xl">Create Free Account</a>
|
| 280 |
+
<a href="/contact" class="w-full sm:w-auto border-2 border-white/30 text-white px-12 py-6 rounded-2xl font-black text-xl hover:bg-white/10 transition-all">Talk to Librarian</a>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
<!-- CTA Decoration -->
|
| 284 |
+
<div class="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[600px] h-[600px] bg-white/10 rounded-full blur-[100px]"></div>
|
| 285 |
</div>
|
| 286 |
</section>
|
| 287 |
</div>
|
| 288 |
</NotAuthorized>
|
| 289 |
+
|
| 290 |
</AuthorizeView>
|
| 291 |
|
| 292 |
|
| 293 |
@code {
|
| 294 |
+
private int _totalBooks = 0;
|
| 295 |
+
private int _activeLoans = 0;
|
| 296 |
+
private int _overdueLoans = 0;
|
| 297 |
+
private int _totalMembers = 0;
|
| 298 |
+
private IEnumerable<BookDto> _recentBooks = Enumerable.Empty<BookDto>();
|
| 299 |
private LoyaltyAccountDto? _loyaltyAccount;
|
| 300 |
+
private SubscriptionDto? _currentSub;
|
| 301 |
+
private bool _isLoading = true;
|
| 302 |
|
| 303 |
protected override async Task OnInitializedAsync()
|
| 304 |
{
|
| 305 |
try
|
| 306 |
{
|
| 307 |
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 308 |
+
var user = authState.User;
|
| 309 |
+
|
| 310 |
+
if (user.Identity?.IsAuthenticated == true)
|
| 311 |
{
|
| 312 |
+
var books = await ApiClient.GetBooksAsync();
|
| 313 |
+
_totalBooks = books.Count();
|
| 314 |
+
_recentBooks = books.Take(6);
|
| 315 |
+
|
| 316 |
+
if (user.IsInRole("Member"))
|
| 317 |
+
{
|
| 318 |
+
try {
|
| 319 |
+
var borrowings = await ApiClient.GetMyBorrowingsAsync();
|
| 320 |
+
_activeLoans = borrowings.Count(b => b.Status == "Borrowed");
|
| 321 |
+
_overdueLoans = borrowings.Count(b => b.Status == "Overdue" || (b.Status == "Borrowed" && b.DueDate < DateTime.UtcNow));
|
| 322 |
+
_loyaltyAccount = await ApiClient.GetMyLoyaltyAccountAsync();
|
| 323 |
+
_currentSub = await ApiClient.GetMySubscriptionAsync();
|
| 324 |
+
} catch {}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
if (user.IsInRole("Librarian"))
|
| 328 |
+
{
|
| 329 |
+
try {
|
| 330 |
+
var allBorrowings = await ApiClient.GetAllBorrowingsAsync();
|
| 331 |
+
_activeLoans = allBorrowings.Count(b => b.Status == "Borrowed");
|
| 332 |
+
_overdueLoans = allBorrowings.Count(b => b.Status == "Overdue" || (b.Status == "Borrowed" && b.DueDate < DateTime.UtcNow));
|
| 333 |
+
|
| 334 |
+
var members = await ApiClient.GetUsersAsync();
|
| 335 |
+
_totalMembers = members.Count();
|
| 336 |
+
} catch {}
|
| 337 |
+
}
|
| 338 |
}
|
| 339 |
}
|
| 340 |
catch { }
|
| 341 |
+
finally
|
| 342 |
+
{
|
| 343 |
+
_isLoading = false;
|
| 344 |
+
}
|
| 345 |
}
|
| 346 |
}
|
BlazorWebAssembly/Pages/Members.razor
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/members"
|
| 2 |
+
@using BlazorWebAssembly.Services
|
| 3 |
+
@using LibraryManagement.Shared.Models
|
| 4 |
+
@using BlazorWebAssembly.Models
|
| 5 |
+
@using Microsoft.AspNetCore.Authorization
|
| 6 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 7 |
+
@inject LibraryApiClient ApiClient
|
| 8 |
+
@inject IJSRuntime JS
|
| 9 |
+
@inject NavigationManager Navigation
|
| 10 |
+
|
| 11 |
+
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 12 |
+
@if (_currentView == ViewMode.List)
|
| 13 |
+
{
|
| 14 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
| 15 |
+
<div>
|
| 16 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Members Directory</h1>
|
| 17 |
+
<p class="text-muted text-lg">
|
| 18 |
+
Manage library members and administrator accounts.
|
| 19 |
+
<span class="ml-2 text-xs text-muted font-bold">(@(_allUsers?.Count() ?? 0) total)</span>
|
| 20 |
+
</p>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="flex items-center gap-3">
|
| 23 |
+
<button @onclick="ShowCreate" class="bg-primary text-white px-6 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all whitespace-nowrap flex items-center">
|
| 24 |
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
| 25 |
+
Add Member
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
@if (_isLoading)
|
| 31 |
+
{
|
| 32 |
+
<div class="space-y-4">
|
| 33 |
+
@for (int i = 0; i < 5; i++)
|
| 34 |
+
{
|
| 35 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-24 w-full"></div>
|
| 36 |
+
}
|
| 37 |
+
</div>
|
| 38 |
+
}
|
| 39 |
+
else if (_pagedResult != null)
|
| 40 |
+
{
|
| 41 |
+
<div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
|
| 42 |
+
<div class="overflow-x-auto">
|
| 43 |
+
<table class="w-full text-left border-collapse min-w-max whitespace-nowrap">
|
| 44 |
+
<thead class="bg-body border-b border-border">
|
| 45 |
+
<tr>
|
| 46 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
|
| 47 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Contact</th>
|
| 48 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Role</th>
|
| 49 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th>
|
| 50 |
+
<th class="px-6 py-4 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Actions</th>
|
| 51 |
+
</tr>
|
| 52 |
+
</thead>
|
| 53 |
+
<tbody class="divide-y divide-border">
|
| 54 |
+
@foreach (var user in _pagedResult.Items)
|
| 55 |
+
{
|
| 56 |
+
<tr class="hover:bg-body transition-colors group">
|
| 57 |
+
<td class="px-6 py-4">
|
| 58 |
+
<div class="flex items-center gap-4">
|
| 59 |
+
<div class="h-10 w-10 flex-shrink-0 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-sm">
|
| 60 |
+
@(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U")
|
| 61 |
+
</div>
|
| 62 |
+
<div>
|
| 63 |
+
<button @onclick="() => ShowDetails(user.Id)" class="text-sm font-bold text-main hover:text-primary transition-colors text-left">@user.FullName</button>
|
| 64 |
+
@if (!string.IsNullOrEmpty(user.StudentId))
|
| 65 |
+
{
|
| 66 |
+
<div class="text-xs text-muted font-medium mt-0.5">ID: @user.StudentId</div>
|
| 67 |
+
}
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</td>
|
| 71 |
+
<td class="px-6 py-4">
|
| 72 |
+
<div class="text-sm text-main font-medium">@user.Email</div>
|
| 73 |
+
<div class="text-xs text-muted">@(string.IsNullOrEmpty(user.PhoneNumber) ? "No phone" : user.PhoneNumber)</div>
|
| 74 |
+
</td>
|
| 75 |
+
<td class="px-6 py-4">
|
| 76 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase @(user.Role == "Admin" ? "bg-amber-500/10 text-amber-500" : "bg-blue-500/10 text-blue-500")">
|
| 77 |
+
@user.Role
|
| 78 |
+
</span>
|
| 79 |
+
</td>
|
| 80 |
+
<td class="px-6 py-4">
|
| 81 |
+
@if (user.BanStatus == true)
|
| 82 |
+
{
|
| 83 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-red-500/10 text-red-500">
|
| 84 |
+
Suspended
|
| 85 |
+
</span>
|
| 86 |
+
}
|
| 87 |
+
else if (user.IsActive)
|
| 88 |
+
{
|
| 89 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-green-500/10 text-green-500">
|
| 90 |
+
Active
|
| 91 |
+
</span>
|
| 92 |
+
}
|
| 93 |
+
else
|
| 94 |
+
{
|
| 95 |
+
<span class="inline-flex rounded-full px-3 py-1 text-[10px] font-black tracking-widest uppercase bg-body text-muted border border-border">
|
| 96 |
+
Inactive
|
| 97 |
+
</span>
|
| 98 |
+
}
|
| 99 |
+
</td>
|
| 100 |
+
<td class="px-6 py-4 text-right">
|
| 101 |
+
<div class="flex items-center justify-end gap-2">
|
| 102 |
+
<button @onclick="() => ShowDetails(user.Id)" class="p-2 rounded-lg border border-border text-muted hover:bg-primary hover:text-white hover:border-primary transition-all">
|
| 103 |
+
<svg class="w-4 h-4" 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><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"></path></svg>
|
| 104 |
+
</button>
|
| 105 |
+
<button @onclick="() => ShowEdit(user.Id)" class="p-2 rounded-lg border border-border text-muted hover:bg-primary hover:text-white hover:border-primary transition-all">
|
| 106 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
</td>
|
| 110 |
+
</tr>
|
| 111 |
+
}
|
| 112 |
+
</tbody>
|
| 113 |
+
</table>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
@if (_pagedResult.TotalPages > 1)
|
| 117 |
+
{
|
| 118 |
+
<div class="px-6 py-4 border-t border-border flex items-center justify-between bg-body">
|
| 119 |
+
<p class="text-xs text-muted">
|
| 120 |
+
Showing <span class="font-bold text-main">@((_pagedResult.CurrentPage - 1) * _pagedResult.PageSize + 1)</span>
|
| 121 |
+
to
|
| 122 |
+
<span class="font-bold text-main">@(Math.Min(_pagedResult.CurrentPage * _pagedResult.PageSize, _pagedResult.TotalCount))</span>
|
| 123 |
+
of <span class="font-bold text-main">@_pagedResult.TotalCount</span> members
|
| 124 |
+
</p>
|
| 125 |
+
<div class="flex items-center gap-2">
|
| 126 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage - 1)" disabled="@(!_pagedResult.HasPreviousPage)"
|
| 127 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 128 |
+
<svg class="w-4 h-4" 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>
|
| 129 |
+
</button>
|
| 130 |
+
|
| 131 |
+
@for (int p = 1; p <= _pagedResult.TotalPages; p++)
|
| 132 |
+
{
|
| 133 |
+
var pageNumber = p;
|
| 134 |
+
if (p == _pagedResult.CurrentPage)
|
| 135 |
+
{
|
| 136 |
+
<span class="w-8 h-8 flex items-center justify-center rounded-lg bg-main text-body text-xs font-bold">@p</span>
|
| 137 |
+
}
|
| 138 |
+
else if (p == 1 || p == _pagedResult.TotalPages || (p >= _pagedResult.CurrentPage - 1 && p <= _pagedResult.CurrentPage + 1))
|
| 139 |
+
{
|
| 140 |
+
<button @onclick="() => ChangePage(pageNumber)"
|
| 141 |
+
class="w-8 h-8 flex items-center justify-center rounded-lg border border-border text-xs font-bold text-muted hover:text-primary hover:border-primary transition-colors">@p</button>
|
| 142 |
+
}
|
| 143 |
+
else if (p == _pagedResult.CurrentPage - 2 || p == _pagedResult.CurrentPage + 2)
|
| 144 |
+
{
|
| 145 |
+
<span class="w-8 h-8 flex items-center justify-center text-xs text-muted">...</span>
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage + 1)" disabled="@(!_pagedResult.HasNextPage)"
|
| 150 |
+
class="p-2 rounded-lg border border-border text-muted hover:bg-card disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
| 151 |
+
<svg class="w-4 h-4" 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>
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
}
|
| 156 |
+
</div>
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
else if (_currentView == ViewMode.Details && _selectedUser != null)
|
| 160 |
+
{
|
| 161 |
+
<div class="animate-fade">
|
| 162 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 163 |
+
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" 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"></path></svg>
|
| 164 |
+
Back to Directory
|
| 165 |
+
</button>
|
| 166 |
+
|
| 167 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 168 |
+
<div class="lg:col-span-1">
|
| 169 |
+
<div class="aspect-square w-full rounded-3xl bg-card border border-border flex flex-col items-center justify-center text-main p-6 shadow-sm relative overflow-hidden">
|
| 170 |
+
<div class="absolute inset-0 bg-primary/5"></div>
|
| 171 |
+
<div class="relative z-10 flex flex-col items-center">
|
| 172 |
+
<div class="w-32 h-32 rounded-full bg-main text-body flex items-center justify-center text-5xl font-black mb-6 shadow-xl">
|
| 173 |
+
@(_selectedUser.User.FullName?.Substring(0, Math.Min(2, _selectedUser.User.FullName.Length)).ToUpper() ?? "U")
|
| 174 |
+
</div>
|
| 175 |
+
<h2 class="text-2xl font-bold text-center mb-2">@_selectedUser.User.FullName</h2>
|
| 176 |
+
<div class="inline-flex rounded-full px-4 py-1.5 text-[10px] font-black uppercase tracking-widest @(_selectedUser.User.Role == "Admin" ? "bg-amber-500/10 text-amber-500 border border-amber-500/20" : "bg-blue-500/10 text-blue-500 border border-blue-500/20")">
|
| 177 |
+
@_selectedUser.User.Role
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div class="lg:col-span-2 space-y-8">
|
| 184 |
+
<div>
|
| 185 |
+
<h1 class="text-4xl font-extrabold text-main tracking-tight mb-4">Member Profile</h1>
|
| 186 |
+
<div class="flex items-center gap-3">
|
| 187 |
+
@if (_selectedUser.User.BanStatus == true)
|
| 188 |
+
{
|
| 189 |
+
<span class="inline-flex items-center rounded-full bg-red-500/10 border border-red-500/20 px-4 py-1.5 text-[10px] font-black text-red-500 tracking-widest uppercase">Suspended</span>
|
| 190 |
+
}
|
| 191 |
+
else if (_selectedUser.User.IsActive)
|
| 192 |
+
{
|
| 193 |
+
<span class="inline-flex items-center rounded-full bg-green-500/10 border border-green-500/20 px-4 py-1.5 text-[10px] font-black text-green-500 tracking-widest uppercase">Active</span>
|
| 194 |
+
}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8 border-y border-border py-8">
|
| 199 |
+
<div>
|
| 200 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Email Address</h4>
|
| 201 |
+
<p class="text-sm font-semibold text-main">@_selectedUser.User.Email</p>
|
| 202 |
+
</div>
|
| 203 |
+
<div>
|
| 204 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Phone Number</h4>
|
| 205 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.PhoneNumber) ? "-" : _selectedUser.User.PhoneNumber)</p>
|
| 206 |
+
</div>
|
| 207 |
+
<div>
|
| 208 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Student ID</h4>
|
| 209 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.StudentId) ? "-" : _selectedUser.User.StudentId)</p>
|
| 210 |
+
</div>
|
| 211 |
+
<div>
|
| 212 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Joined Date</h4>
|
| 213 |
+
<p class="text-sm font-semibold text-main">@_selectedUser.User.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="sm:col-span-2">
|
| 216 |
+
<h4 class="text-[10px] font-bold text-muted uppercase tracking-widest mb-2">Physical Address</h4>
|
| 217 |
+
<p class="text-sm font-semibold text-main">@(string.IsNullOrEmpty(_selectedUser.User.Address) ? "-" : _selectedUser.User.Address)</p>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<div class="flex flex-wrap items-center gap-4 pt-2">
|
| 222 |
+
<button @onclick="() => ShowEdit(_selectedUser.User.Id)" class="bg-primary text-white py-3 px-8 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Edit Details</button>
|
| 223 |
+
|
| 224 |
+
<button @onclick="UpdateRole" class="bg-body border border-border text-main py-3 px-8 rounded-xl font-bold hover:bg-card transition-colors">
|
| 225 |
+
Make @(_selectedUser.User.Role == "Admin" ? "Member" : "Admin")
|
| 226 |
+
</button>
|
| 227 |
+
|
| 228 |
+
<button @onclick="DeleteUser" class="p-3 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded-xl transition-colors ml-auto border border-transparent hover:border-red-500/20">
|
| 229 |
+
<svg class="w-6 h-6" 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"></path></svg>
|
| 230 |
+
</button>
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
<!-- Subscription Management -->
|
| 234 |
+
<div class="mt-12 pt-12 border-t border-border">
|
| 235 |
+
<h3 class="text-2xl font-bold text-main mb-8">Manage Subscription</h3>
|
| 236 |
+
|
| 237 |
+
@if (_selectedUser.CurrentSubscription != null && _selectedUser.CurrentSubscription.IsActive)
|
| 238 |
+
{
|
| 239 |
+
<div class="bg-green-500/5 border border-green-500/20 rounded-2xl p-6 mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 240 |
+
<div>
|
| 241 |
+
<span class="text-[10px] text-green-600 font-black uppercase tracking-widest mb-1 block">Active Plan</span>
|
| 242 |
+
<h4 class="text-2xl font-black text-green-700">@_selectedUser.CurrentSubscription.MembershipType</h4>
|
| 243 |
+
</div>
|
| 244 |
+
<div class="sm:text-right">
|
| 245 |
+
<div class="text-[10px] text-green-600/70 font-black uppercase tracking-widest mb-1">Expires On</div>
|
| 246 |
+
<div class="font-bold text-green-700">@_selectedUser.CurrentSubscription.ExpiryDate.ToString("MMMM dd, yyyy")</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
}
|
| 250 |
+
else
|
| 251 |
+
{
|
| 252 |
+
<div class="bg-card border border-dashed border-border rounded-2xl p-6 mb-8 text-center">
|
| 253 |
+
<p class="text-muted font-medium">No active subscription for this member.</p>
|
| 254 |
+
</div>
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
<h4 class="text-xs font-black text-muted uppercase tracking-[0.2em] mb-6">Assign New Plan</h4>
|
| 258 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 259 |
+
@foreach (var plan in _selectedUser.AvailableMemberships)
|
| 260 |
+
{
|
| 261 |
+
var isCurrent = _selectedUser.CurrentSubscription?.IsActive == true && _selectedUser.CurrentSubscription.MembershipId == plan.Id;
|
| 262 |
+
<div class="bg-card rounded-2xl border p-6 flex flex-col transition-all relative @(isCurrent ? "border-primary shadow-lg shadow-primary/10" : "border-border hover:border-primary/50")">
|
| 263 |
+
@if (isCurrent)
|
| 264 |
+
{
|
| 265 |
+
<div class="absolute -top-3 right-6 bg-primary text-white text-[9px] font-black uppercase tracking-widest px-3 py-1 rounded-full shadow-sm">Current</div>
|
| 266 |
+
}
|
| 267 |
+
<h5 class="font-bold text-main text-xl mb-1">@plan.Type</h5>
|
| 268 |
+
<p class="text-muted font-medium text-sm mb-6 flex-grow">@plan.Price.ToString("N0") MMK / @plan.DurationMonths mo</p>
|
| 269 |
+
|
| 270 |
+
@if (!isCurrent)
|
| 271 |
+
{
|
| 272 |
+
<button @onclick="() => AssignSubscription(plan.Id, plan.Type)" class="w-full bg-body border border-border text-main py-2.5 rounded-xl text-sm font-bold hover:bg-primary hover:text-white hover:border-primary transition-colors">Assign Plan</button>
|
| 273 |
+
}
|
| 274 |
+
else
|
| 275 |
+
{
|
| 276 |
+
<button disabled class="w-full bg-body border border-border text-muted py-2.5 rounded-xl text-sm font-bold opacity-50 cursor-not-allowed">Current Plan</button>
|
| 277 |
+
}
|
| 278 |
+
</div>
|
| 279 |
+
}
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
}
|
| 286 |
+
else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit)
|
| 287 |
+
{
|
| 288 |
+
<div class="animate-fade">
|
| 289 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-muted hover:text-main mb-6 group">
|
| 290 |
+
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" 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"></path></svg>
|
| 291 |
+
Back to Directory
|
| 292 |
+
</button>
|
| 293 |
+
|
| 294 |
+
<div class="max-w-3xl mx-auto">
|
| 295 |
+
<div class="mb-10 text-center">
|
| 296 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-3">@(_currentView == ViewMode.Create ? "Register New Member" : "Edit Member Details")</h1>
|
| 297 |
+
<p class="text-lg text-muted">@(_currentView == ViewMode.Create ? "Add a new reader to the library system." : $"Update profile details for {_editUser?.FullName}.")</p>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
<div class="bg-card border border-border p-8 sm:p-10 rounded-3xl shadow-sm">
|
| 301 |
+
@if (_currentView == ViewMode.Create)
|
| 302 |
+
{
|
| 303 |
+
<EditForm Model="@_createRequest" OnValidSubmit="HandleCreate" class="space-y-6">
|
| 304 |
+
<DataAnnotationsValidator />
|
| 305 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 306 |
+
<div class="sm:col-span-2">
|
| 307 |
+
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 308 |
+
<InputText @bind-Value="_createRequest.FullName" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="John Doe" />
|
| 309 |
+
<ValidationMessage For="@(() => _createRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 310 |
+
</div>
|
| 311 |
+
<div>
|
| 312 |
+
<label class="block text-sm font-bold text-main mb-2">Email Address <span class="text-red-500">*</span></label>
|
| 313 |
+
<InputText @bind-Value="_createRequest.Email" type="email" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="you@domain.com" />
|
| 314 |
+
<ValidationMessage For="@(() => _createRequest.Email)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 315 |
+
</div>
|
| 316 |
+
<div>
|
| 317 |
+
<label class="block text-sm font-bold text-main mb-2">Password <span class="text-red-500">*</span></label>
|
| 318 |
+
<InputText @bind-Value="_createRequest.Password" type="password" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="••••••••" />
|
| 319 |
+
<ValidationMessage For="@(() => _createRequest.Password)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 320 |
+
</div>
|
| 321 |
+
<div>
|
| 322 |
+
<label class="block text-sm font-bold text-main mb-2">Phone Number</label>
|
| 323 |
+
<InputText @bind-Value="_createRequest.PhoneNumber" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="+123456789" />
|
| 324 |
+
</div>
|
| 325 |
+
<div>
|
| 326 |
+
<label class="block text-sm font-bold text-main mb-2">Student ID</label>
|
| 327 |
+
<InputText @bind-Value="_createRequest.StudentId" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="Optional" />
|
| 328 |
+
</div>
|
| 329 |
+
<div class="sm:col-span-2">
|
| 330 |
+
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 331 |
+
<InputTextArea @bind-Value="_createRequest.Address" rows="3" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" placeholder="Full physical address" />
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
<div class="pt-8 border-t border-border flex justify-end gap-4 mt-8">
|
| 335 |
+
<button type="button" @onclick="BackToList" class="bg-body border border-border text-main px-8 py-3 rounded-xl font-bold hover:bg-card transition-colors">Cancel</button>
|
| 336 |
+
<button type="submit" class="bg-primary text-white px-8 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Create Member</button>
|
| 337 |
+
</div>
|
| 338 |
+
</EditForm>
|
| 339 |
+
}
|
| 340 |
+
else if (_currentView == ViewMode.Edit)
|
| 341 |
+
{
|
| 342 |
+
<EditForm Model="@_updateRequest" OnValidSubmit="HandleUpdate" class="space-y-6">
|
| 343 |
+
<DataAnnotationsValidator />
|
| 344 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 345 |
+
<div class="sm:col-span-2">
|
| 346 |
+
<label class="block text-sm font-bold text-main mb-2">Full Name <span class="text-red-500">*</span></label>
|
| 347 |
+
<InputText @bind-Value="_updateRequest.FullName" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" />
|
| 348 |
+
<ValidationMessage For="@(() => _updateRequest.FullName)" class="mt-1 text-xs text-red-500 font-medium" />
|
| 349 |
+
</div>
|
| 350 |
+
<div>
|
| 351 |
+
<label class="block text-sm font-bold text-main mb-2">Phone Number</label>
|
| 352 |
+
<InputText @bind-Value="_updateRequest.PhoneNumber" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" />
|
| 353 |
+
</div>
|
| 354 |
+
<div>
|
| 355 |
+
<label class="block text-sm font-bold text-main mb-2">Student ID</label>
|
| 356 |
+
<InputText @bind-Value="_updateRequest.StudentId" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" />
|
| 357 |
+
</div>
|
| 358 |
+
<div class="sm:col-span-2">
|
| 359 |
+
<label class="block text-sm font-bold text-main mb-2">Address</label>
|
| 360 |
+
<InputTextArea @bind-Value="_updateRequest.Address" rows="3" class="w-full bg-body border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main transition-all" />
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
<div class="pt-8 border-t border-border flex justify-end gap-4 mt-8">
|
| 364 |
+
<button type="button" @onclick="BackToList" class="bg-body border border-border text-main px-8 py-3 rounded-xl font-bold hover:bg-card transition-colors">Cancel</button>
|
| 365 |
+
<button type="submit" class="bg-primary text-white px-8 py-3 rounded-xl font-bold hover:bg-primary-hover shadow-lg shadow-primary/20 transition-all">Update Member</button>
|
| 366 |
+
</div>
|
| 367 |
+
</EditForm>
|
| 368 |
+
}
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
}
|
| 373 |
+
</div>
|
| 374 |
+
|
| 375 |
+
@code {
|
| 376 |
+
private enum ViewMode { List, Details, Create, Edit }
|
| 377 |
+
private ViewMode _currentView = ViewMode.List;
|
| 378 |
+
|
| 379 |
+
private IEnumerable<UserDto>? _allUsers;
|
| 380 |
+
private PagedResult<UserDto>? _pagedResult;
|
| 381 |
+
private bool _isLoading = true;
|
| 382 |
+
private const int PageSize = 10;
|
| 383 |
+
|
| 384 |
+
private MemberDetailsViewModel? _selectedUser;
|
| 385 |
+
private UserCreateRequest _createRequest = new();
|
| 386 |
+
private UserUpdateRequest _updateRequest = new();
|
| 387 |
+
private UserDto? _editUser;
|
| 388 |
+
|
| 389 |
+
protected override async Task OnInitializedAsync()
|
| 390 |
+
{
|
| 391 |
+
await LoadUsers();
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
private async Task LoadUsers()
|
| 395 |
+
{
|
| 396 |
+
_isLoading = true;
|
| 397 |
+
try
|
| 398 |
+
{
|
| 399 |
+
_allUsers = await ApiClient.GetUsersAsync();
|
| 400 |
+
ApplyPagination(1);
|
| 401 |
+
}
|
| 402 |
+
catch (Exception ex)
|
| 403 |
+
{
|
| 404 |
+
await JS.InvokeVoidAsync("alert", "Error loading users: " + ex.Message);
|
| 405 |
+
}
|
| 406 |
+
finally
|
| 407 |
+
{
|
| 408 |
+
_isLoading = false;
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
private void ApplyPagination(int page)
|
| 413 |
+
{
|
| 414 |
+
if (_allUsers == null) return;
|
| 415 |
+
|
| 416 |
+
var totalCount = _allUsers.Count();
|
| 417 |
+
var totalPages = (int)Math.Ceiling(totalCount / (double)PageSize);
|
| 418 |
+
page = Math.Max(1, Math.Min(page, Math.Max(1, totalPages)));
|
| 419 |
+
|
| 420 |
+
_pagedResult = new PagedResult<UserDto>
|
| 421 |
+
{
|
| 422 |
+
Items = _allUsers.Skip((page - 1) * PageSize).Take(PageSize),
|
| 423 |
+
CurrentPage = page,
|
| 424 |
+
TotalPages = totalPages,
|
| 425 |
+
TotalCount = totalCount,
|
| 426 |
+
PageSize = PageSize
|
| 427 |
+
};
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
private void ChangePage(int page)
|
| 431 |
+
{
|
| 432 |
+
ApplyPagination(page);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
private async Task ShowDetails(Guid userId)
|
| 436 |
+
{
|
| 437 |
+
_isLoading = true;
|
| 438 |
+
try
|
| 439 |
+
{
|
| 440 |
+
var user = await ApiClient.GetUserAsync(userId);
|
| 441 |
+
if (user == null) return;
|
| 442 |
+
|
| 443 |
+
var memberships = await ApiClient.GetMembershipsAsync();
|
| 444 |
+
var subscription = await ApiClient.GetUserSubscriptionAsync(userId);
|
| 445 |
+
|
| 446 |
+
_selectedUser = new MemberDetailsViewModel
|
| 447 |
+
{
|
| 448 |
+
User = user,
|
| 449 |
+
AvailableMemberships = memberships.ToList(),
|
| 450 |
+
CurrentSubscription = subscription
|
| 451 |
+
};
|
| 452 |
+
_currentView = ViewMode.Details;
|
| 453 |
+
}
|
| 454 |
+
catch (Exception ex)
|
| 455 |
+
{
|
| 456 |
+
await JS.InvokeVoidAsync("alert", "Error loading member details: " + ex.Message);
|
| 457 |
+
}
|
| 458 |
+
finally
|
| 459 |
+
{
|
| 460 |
+
_isLoading = false;
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
private void ShowCreate()
|
| 465 |
+
{
|
| 466 |
+
_createRequest = new();
|
| 467 |
+
_currentView = ViewMode.Create;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
private async Task ShowEdit(Guid userId)
|
| 471 |
+
{
|
| 472 |
+
_isLoading = true;
|
| 473 |
+
try
|
| 474 |
+
{
|
| 475 |
+
_editUser = _allUsers?.FirstOrDefault(u => u.Id == userId);
|
| 476 |
+
if (_editUser == null)
|
| 477 |
+
{
|
| 478 |
+
_editUser = await ApiClient.GetUserAsync(userId);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
if (_editUser != null)
|
| 482 |
+
{
|
| 483 |
+
_updateRequest = new UserUpdateRequest
|
| 484 |
+
{
|
| 485 |
+
FullName = _editUser.FullName,
|
| 486 |
+
PhoneNumber = _editUser.PhoneNumber,
|
| 487 |
+
StudentId = _editUser.StudentId,
|
| 488 |
+
Address = _editUser.Address
|
| 489 |
+
};
|
| 490 |
+
_currentView = ViewMode.Edit;
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
finally
|
| 494 |
+
{
|
| 495 |
+
_isLoading = false;
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
private void BackToList()
|
| 500 |
+
{
|
| 501 |
+
_currentView = ViewMode.List;
|
| 502 |
+
_selectedUser = null;
|
| 503 |
+
_editUser = null;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
private async Task HandleCreate()
|
| 507 |
+
{
|
| 508 |
+
try
|
| 509 |
+
{
|
| 510 |
+
var result = await ApiClient.CreateUserAsync(_createRequest);
|
| 511 |
+
if (result != null)
|
| 512 |
+
{
|
| 513 |
+
await JS.InvokeVoidAsync("alert", "Member created successfully!");
|
| 514 |
+
await LoadUsers();
|
| 515 |
+
BackToList();
|
| 516 |
+
}
|
| 517 |
+
else
|
| 518 |
+
{
|
| 519 |
+
await JS.InvokeVoidAsync("alert", "Failed to create member.");
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
catch (Exception ex)
|
| 523 |
+
{
|
| 524 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
private async Task HandleUpdate()
|
| 529 |
+
{
|
| 530 |
+
if (_editUser == null) return;
|
| 531 |
+
try
|
| 532 |
+
{
|
| 533 |
+
var result = await ApiClient.UpdateUserAsync(_editUser.Id, _updateRequest);
|
| 534 |
+
if (result != null)
|
| 535 |
+
{
|
| 536 |
+
await JS.InvokeVoidAsync("alert", "Member updated successfully!");
|
| 537 |
+
await LoadUsers();
|
| 538 |
+
BackToList();
|
| 539 |
+
}
|
| 540 |
+
}
|
| 541 |
+
catch (Exception ex)
|
| 542 |
+
{
|
| 543 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
private async Task DeleteUser()
|
| 548 |
+
{
|
| 549 |
+
if (_selectedUser == null) return;
|
| 550 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to completely remove this member? This action cannot be undone.");
|
| 551 |
+
if (!confirmed) return;
|
| 552 |
+
|
| 553 |
+
try
|
| 554 |
+
{
|
| 555 |
+
var success = await ApiClient.DeleteUserAsync(_selectedUser.User.Id);
|
| 556 |
+
if (success)
|
| 557 |
+
{
|
| 558 |
+
await JS.InvokeVoidAsync("alert", "Member removed.");
|
| 559 |
+
await LoadUsers();
|
| 560 |
+
BackToList();
|
| 561 |
+
}
|
| 562 |
+
}
|
| 563 |
+
catch (Exception ex)
|
| 564 |
+
{
|
| 565 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
private async Task UpdateRole()
|
| 570 |
+
{
|
| 571 |
+
if (_selectedUser == null) return;
|
| 572 |
+
var newRole = _selectedUser.User.Role == "Admin" ? "Member" : "Admin";
|
| 573 |
+
try
|
| 574 |
+
{
|
| 575 |
+
var success = await ApiClient.UpdateUserRoleAsync(_selectedUser.User.Id, newRole);
|
| 576 |
+
if (success)
|
| 577 |
+
{
|
| 578 |
+
await JS.InvokeVoidAsync("alert", $"Role updated to {newRole}.");
|
| 579 |
+
await ShowDetails(_selectedUser.User.Id); // Refresh details
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
catch (Exception ex)
|
| 583 |
+
{
|
| 584 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 585 |
+
}
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
private async Task AssignSubscription(int membershipId, string planName)
|
| 589 |
+
{
|
| 590 |
+
if (_selectedUser == null) return;
|
| 591 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Assign {planName} plan to this member?");
|
| 592 |
+
if (!confirmed) return;
|
| 593 |
+
|
| 594 |
+
try
|
| 595 |
+
{
|
| 596 |
+
var request = new AdminSubscribeRequest { UserId = _selectedUser.User.Id, MembershipId = membershipId };
|
| 597 |
+
var result = await ApiClient.AdminSubscribeAsync(request);
|
| 598 |
+
if (result != null)
|
| 599 |
+
{
|
| 600 |
+
await JS.InvokeVoidAsync("alert", "Subscription assigned successfully.");
|
| 601 |
+
await ShowDetails(_selectedUser.User.Id); // Refresh details
|
| 602 |
+
}
|
| 603 |
+
}
|
| 604 |
+
catch (Exception ex)
|
| 605 |
+
{
|
| 606 |
+
await JS.InvokeVoidAsync("alert", "Error: " + ex.Message);
|
| 607 |
+
}
|
| 608 |
+
}
|
| 609 |
+
}
|
BlazorWebAssembly/Pages/Membership.razor
CHANGED
|
@@ -3,11 +3,13 @@
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
|
|
|
| 6 |
|
| 7 |
-
<div class="max-w-
|
| 8 |
<!-- Header -->
|
| 9 |
-
<div class="text-center mb-
|
| 10 |
-
<
|
|
|
|
| 11 |
<p class="text-xl text-muted max-w-2xl mx-auto leading-relaxed">
|
| 12 |
From casual readers to devoted bookworms, we have a plan that perfectly matches your literary appetite.
|
| 13 |
</p>
|
|
@@ -18,83 +20,116 @@
|
|
| 18 |
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 19 |
@for (int i = 0; i < 3; i++)
|
| 20 |
{
|
| 21 |
-
<div class="animate-pulse bg-card rounded-
|
| 22 |
}
|
| 23 |
</div>
|
| 24 |
}
|
| 25 |
else
|
| 26 |
{
|
| 27 |
-
<!-- Current Subscription Banner -->
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
<div class="
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
</div>
|
| 43 |
-
</div>
|
| 44 |
-
}
|
| 45 |
|
| 46 |
<!-- Membership Plans -->
|
| 47 |
-
<div class="grid grid-cols-1
|
| 48 |
@foreach (var plan in _memberships)
|
| 49 |
{
|
| 50 |
-
<div class='relative bg-card rounded-[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@if (IsCurrentPlan(plan.Id))
|
| 52 |
{
|
| 53 |
-
<div class="absolute -top-
|
| 54 |
-
Current Plan
|
| 55 |
</div>
|
| 56 |
}
|
| 57 |
|
| 58 |
<div class="mb-10">
|
| 59 |
-
<h3 class="text-sm font-black text-muted uppercase tracking-[0.
|
| 60 |
-
<div class="flex
|
| 61 |
-
<
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
|
| 66 |
-
<div class="space-y-6 mb-
|
| 67 |
-
<div class="flex items-center gap-
|
| 68 |
-
<div class="w-
|
| 69 |
-
<svg class="w-
|
| 70 |
</div>
|
| 71 |
-
<span class="text-main font-
|
| 72 |
</div>
|
| 73 |
-
<div class="flex items-center gap-
|
| 74 |
-
<div class="w-
|
| 75 |
-
<svg class="w-
|
| 76 |
</div>
|
| 77 |
-
<span class="text-main font-
|
| 78 |
</div>
|
| 79 |
-
<div class="flex items-center gap-
|
| 80 |
-
<div class="w-
|
| 81 |
-
<svg class="w-
|
| 82 |
</div>
|
| 83 |
-
<span class="text-main font-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
</div>
|
| 86 |
|
| 87 |
<button @onclick="() => SubscribeAsync(plan)"
|
| 88 |
disabled="@IsCurrentPlan(plan.Id)"
|
| 89 |
-
class='w-full py-
|
| 90 |
-
@(IsCurrentPlan(plan.Id) ? "
|
| 91 |
</button>
|
| 92 |
</div>
|
| 93 |
}
|
| 94 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
</div>
|
| 97 |
|
|
|
|
| 98 |
@code {
|
| 99 |
private bool _isLoading = true;
|
| 100 |
private List<MembershipDto> _memberships = new();
|
|
@@ -121,10 +156,18 @@
|
|
| 121 |
|
| 122 |
private async Task SubscribeAsync(MembershipDto plan)
|
| 123 |
{
|
|
|
|
|
|
|
|
|
|
| 124 |
var result = await ApiClient.SubscribeAsync(new SubscribeRequest { MembershipId = plan.Id });
|
| 125 |
if (result != null)
|
| 126 |
{
|
|
|
|
| 127 |
await LoadData();
|
| 128 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
}
|
|
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
|
| 8 |
+
<div class="max-w-7xl mx-auto py-24 px-8 animate-fade">
|
| 9 |
<!-- Header -->
|
| 10 |
+
<div class="text-center mb-24">
|
| 11 |
+
<h2 class="text-xs font-black text-primary uppercase tracking-[0.3em] mb-4">Pricing Plans</h2>
|
| 12 |
+
<h1 class="text-7xl font-black tracking-tighter text-main mb-8 uppercase leading-[0.9]">Choose Your <br /> <span class="text-primary italic">Journey.</span></h1>
|
| 13 |
<p class="text-xl text-muted max-w-2xl mx-auto leading-relaxed">
|
| 14 |
From casual readers to devoted bookworms, we have a plan that perfectly matches your literary appetite.
|
| 15 |
</p>
|
|
|
|
| 20 |
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
| 21 |
@for (int i = 0; i < 3; i++)
|
| 22 |
{
|
| 23 |
+
<div class="animate-pulse bg-card rounded-[3rem] border border-border h-[600px]"></div>
|
| 24 |
}
|
| 25 |
</div>
|
| 26 |
}
|
| 27 |
else
|
| 28 |
{
|
| 29 |
+
<!-- Current Subscription Banner (Only for Auth) -->
|
| 30 |
+
<AuthorizeView>
|
| 31 |
+
<Authorized>
|
| 32 |
+
@if (_currentSub != null)
|
| 33 |
+
{
|
| 34 |
+
<div class="bg-primary/5 border border-primary/20 rounded-[2.5rem] p-10 mb-20 flex flex-col md:flex-row items-center justify-between gap-8 shadow-sm">
|
| 35 |
+
<div class="flex items-center gap-8">
|
| 36 |
+
<div class="w-20 h-20 bg-primary rounded-3xl flex items-center justify-center text-white text-4xl font-black shadow-lg shadow-primary/20">
|
| 37 |
+
@(_currentSub.MembershipType?[..1].ToUpper())
|
| 38 |
+
</div>
|
| 39 |
+
<div>
|
| 40 |
+
<h3 class="text-3xl font-black text-main uppercase tracking-tight">@_currentSub.MembershipType Plan</h3>
|
| 41 |
+
<p class="text-muted font-medium">Active until <span class="text-main font-bold">@_currentSub.ExpiryDate.ToString("MMMM dd, yyyy")</span></p>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="flex items-center gap-6">
|
| 45 |
+
<div class="flex flex-col items-end">
|
| 46 |
+
<span class="px-4 py-1.5 bg-green-500/10 text-green-600 rounded-full text-[10px] font-black uppercase tracking-widest mb-2">Active Status</span>
|
| 47 |
+
<span class="text-xs text-muted font-bold uppercase tracking-widest">Auto-renews monthly</span>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
</div>
|
| 51 |
+
}
|
| 52 |
+
</Authorized>
|
| 53 |
+
</AuthorizeView>
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
<!-- Membership Plans -->
|
| 56 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-10">
|
| 57 |
@foreach (var plan in _memberships)
|
| 58 |
{
|
| 59 |
+
<div class='relative bg-card rounded-[3.5rem] border p-12 flex flex-col transition-all duration-700 hover:-translate-y-4 @(IsCurrentPlan(plan.Id) ? "border-primary shadow-2xl shadow-primary/10 scale-105 z-10" : "border-border shadow-xl hover:shadow-2xl")'>
|
| 60 |
+
@if (plan.Price > 20 && !IsCurrentPlan(plan.Id))
|
| 61 |
+
{
|
| 62 |
+
<div class="absolute -top-5 left-1/2 -translate-x-1/2 bg-emerald-500 text-white px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-[0.2em] shadow-lg shadow-emerald-500/30">
|
| 63 |
+
Most Popular
|
| 64 |
+
</div>
|
| 65 |
+
}
|
| 66 |
@if (IsCurrentPlan(plan.Id))
|
| 67 |
{
|
| 68 |
+
<div class="absolute -top-5 left-1/2 -translate-x-1/2 bg-primary text-white px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-[0.2em] shadow-lg shadow-primary/30">
|
| 69 |
+
Your Current Plan
|
| 70 |
</div>
|
| 71 |
}
|
| 72 |
|
| 73 |
<div class="mb-10">
|
| 74 |
+
<h3 class="text-sm font-black text-muted uppercase tracking-[0.3em] mb-6">@plan.Type</h3>
|
| 75 |
+
<div class="flex flex-col gap-1">
|
| 76 |
+
<div class="flex items-baseline gap-2 flex-wrap">
|
| 77 |
+
<span class="text-5xl font-black text-main tracking-tighter">@plan.Price.ToString("N0")</span>
|
| 78 |
+
<span class="text-2xl font-bold text-muted">mmk</span>
|
| 79 |
+
</div>
|
| 80 |
+
<span class="text-muted font-bold uppercase text-sm tracking-widest mt-1">/ @plan.DurationMonths mo</span>
|
| 81 |
</div>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
+
<div class="space-y-6 mb-16 flex-grow">
|
| 85 |
+
<div class="flex items-center gap-5 group/item">
|
| 86 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover/item:scale-110 transition-transform flex-shrink-0">
|
| 87 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 88 |
</div>
|
| 89 |
+
<span class="text-main font-bold text-lg">Borrow <span class="text-primary">@plan.MaxBooks</span> books</span>
|
| 90 |
</div>
|
| 91 |
+
<div class="flex items-center gap-5 group/item">
|
| 92 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover/item:scale-110 transition-transform flex-shrink-0">
|
| 93 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 94 |
</div>
|
| 95 |
+
<span class="text-main font-bold text-lg">@plan.BorrowingDays Day period</span>
|
| 96 |
</div>
|
| 97 |
+
<div class="flex items-center gap-5 group/item">
|
| 98 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover/item:scale-110 transition-transform flex-shrink-0">
|
| 99 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 100 |
</div>
|
| 101 |
+
<span class="text-main font-bold text-lg">Digital access included</span>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="flex items-center gap-5 group/item">
|
| 104 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary group-hover/item:scale-110 transition-transform flex-shrink-0">
|
| 105 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 106 |
+
</div>
|
| 107 |
+
<span class="text-main font-bold text-lg">Loyalty point multiplier</span>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
| 111 |
<button @onclick="() => SubscribeAsync(plan)"
|
| 112 |
disabled="@IsCurrentPlan(plan.Id)"
|
| 113 |
+
class='w-full py-6 rounded-[2rem] font-black uppercase tracking-widest text-sm transition-all @(IsCurrentPlan(plan.Id) ? "bg-body text-muted border border-border cursor-not-allowed" : "bg-primary text-white hover:bg-primary-hover hover:shadow-2xl hover:shadow-primary/30 transform hover:scale-[1.02] active:scale-95")'>
|
| 114 |
+
@(IsCurrentPlan(plan.Id) ? "Current Plan" : "Select Plan")
|
| 115 |
</button>
|
| 116 |
</div>
|
| 117 |
}
|
| 118 |
</div>
|
| 119 |
+
|
| 120 |
+
<div class="mt-24 text-center">
|
| 121 |
+
<p class="text-muted font-medium mb-8">All plans include 24/7 support and access to our community events.</p>
|
| 122 |
+
<div class="flex items-center justify-center gap-12 opacity-50 grayscale hover:grayscale-0 transition-all">
|
| 123 |
+
<!-- Trust Badges Placeholder -->
|
| 124 |
+
<span class="font-black text-xl uppercase tracking-tighter">Secure Payment</span>
|
| 125 |
+
<span class="font-black text-xl uppercase tracking-tighter">Cancel Anytime</span>
|
| 126 |
+
<span class="font-black text-xl uppercase tracking-tighter">No Hidden Fees</span>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
}
|
| 130 |
</div>
|
| 131 |
|
| 132 |
+
|
| 133 |
@code {
|
| 134 |
private bool _isLoading = true;
|
| 135 |
private List<MembershipDto> _memberships = new();
|
|
|
|
| 156 |
|
| 157 |
private async Task SubscribeAsync(MembershipDto plan)
|
| 158 |
{
|
| 159 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Are you sure you want to subscribe to {plan.Type} for {plan.Price} mmk?");
|
| 160 |
+
if (!confirmed) return;
|
| 161 |
+
|
| 162 |
var result = await ApiClient.SubscribeAsync(new SubscribeRequest { MembershipId = plan.Id });
|
| 163 |
if (result != null)
|
| 164 |
{
|
| 165 |
+
await JS.InvokeVoidAsync("alert", "Subscription successful!");
|
| 166 |
await LoadData();
|
| 167 |
}
|
| 168 |
+
else
|
| 169 |
+
{
|
| 170 |
+
await JS.InvokeVoidAsync("alert", "Subscription failed. Please check your account balance or try again.");
|
| 171 |
+
}
|
| 172 |
}
|
| 173 |
}
|
BlazorWebAssembly/Pages/Privacy.razor
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/privacy"
|
| 2 |
+
|
| 3 |
+
<div class="max-w-4xl mx-auto py-24 px-8 animate-fade">
|
| 4 |
+
<h1 class="text-5xl font-black tracking-tighter text-main mb-8 uppercase">Privacy Policy</h1>
|
| 5 |
+
<p class="text-sm text-muted mb-12 font-bold uppercase tracking-widest">Last Updated: May 2026</p>
|
| 6 |
+
|
| 7 |
+
<div class="space-y-12 text-muted leading-relaxed">
|
| 8 |
+
<section>
|
| 9 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">1. Information We Collect</h2>
|
| 10 |
+
<p>
|
| 11 |
+
We collect information you provide directly to us when you create an account, subscribe to a membership, or interact with our library services. This includes your name, email address, payment information, and reading history.
|
| 12 |
+
</p>
|
| 13 |
+
</section>
|
| 14 |
+
|
| 15 |
+
<section>
|
| 16 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">2. How We Use Your Information</h2>
|
| 17 |
+
<p>
|
| 18 |
+
We use the information we collect to maintain your account, process transactions, provide personalized recommendations, and send you important notifications regarding your loans and memberships.
|
| 19 |
+
</p>
|
| 20 |
+
</section>
|
| 21 |
+
|
| 22 |
+
<section>
|
| 23 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">3. Data Security</h2>
|
| 24 |
+
<p>
|
| 25 |
+
We implement a variety of security measures to maintain the safety of your personal information. Your sensitive data is encrypted via SSL technology and stored behind secured networks.
|
| 26 |
+
</p>
|
| 27 |
+
</section>
|
| 28 |
+
|
| 29 |
+
<section>
|
| 30 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">4. Cookies</h2>
|
| 31 |
+
<p>
|
| 32 |
+
We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction.
|
| 33 |
+
</p>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section class="p-8 bg-card rounded-3xl border border-border">
|
| 37 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Contact Privacy Officer</h2>
|
| 38 |
+
<p class="mb-4">If you have any questions regarding this privacy policy, you may contact our privacy officer at:</p>
|
| 39 |
+
<p class="font-bold text-primary">privacy@libraryluxe.com</p>
|
| 40 |
+
</section>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
BlazorWebAssembly/Pages/Rewards.razor
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
|
|
|
| 6 |
|
| 7 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 8 |
<!-- Header -->
|
|
@@ -94,7 +95,11 @@
|
|
| 94 |
if (success)
|
| 95 |
{
|
| 96 |
await LoadData();
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
}
|
| 100 |
}
|
|
|
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
@inject NavigationManager Navigation
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
|
| 8 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 9 |
<!-- Header -->
|
|
|
|
| 95 |
if (success)
|
| 96 |
{
|
| 97 |
await LoadData();
|
| 98 |
+
await JS.InvokeVoidAsync("alert", msg);
|
| 99 |
+
}
|
| 100 |
+
else
|
| 101 |
+
{
|
| 102 |
+
await JS.InvokeVoidAsync("alert", msg);
|
| 103 |
}
|
| 104 |
}
|
| 105 |
}
|
BlazorWebAssembly/Pages/RewardsManage.razor
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/rewards/manage"
|
| 2 |
+
@using BlazorWebAssembly.Services
|
| 3 |
+
@using LibraryManagement.Shared.Models
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
+
|
| 9 |
+
<div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
|
| 10 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-6">
|
| 11 |
+
<div>
|
| 12 |
+
<h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Rewards Management</h1>
|
| 13 |
+
<p class="text-muted text-lg font-medium">Fulfill pending redemptions and review all members' loyalty activity.</p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="flex items-center gap-4">
|
| 16 |
+
<div class="bg-card px-6 py-3 flex items-center gap-4 border border-border rounded-xl shadow-sm">
|
| 17 |
+
<div class="text-center">
|
| 18 |
+
<p class="text-[10px] font-black text-amber-500 uppercase tracking-widest">Pending</p>
|
| 19 |
+
<p class="text-2xl font-black text-main">@_pendingRedemptions.Count()</p>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="w-px h-8 bg-border"></div>
|
| 22 |
+
<div class="text-center">
|
| 23 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">Members</p>
|
| 24 |
+
<p class="text-2xl font-black text-main">@_allMembersHistory.Count()</p>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div class="mb-8 border-b border-border flex gap-8">
|
| 31 |
+
<button @onclick='() => _activeTab = "pending"'
|
| 32 |
+
class='pb-4 text-sm font-bold border-b-2 transition-all relative @(_activeTab == "pending" ? "border-primary text-main" : "border-transparent text-muted hover:text-main")'>
|
| 33 |
+
<span class="flex items-center gap-2">
|
| 34 |
+
Pending Redemptions
|
| 35 |
+
@if (_pendingRedemptions.Any())
|
| 36 |
+
{
|
| 37 |
+
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[10px] font-black rounded-full bg-red-500 text-white">@_pendingRedemptions.Count()</span>
|
| 38 |
+
}
|
| 39 |
+
</span>
|
| 40 |
+
</button>
|
| 41 |
+
<button @onclick='() => _activeTab = "history"'
|
| 42 |
+
class='pb-4 text-sm font-bold border-b-2 transition-all @(_activeTab == "history" ? "border-primary text-main" : "border-transparent text-muted hover:text-main")'>
|
| 43 |
+
Members Points History
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
@if (_isLoading)
|
| 48 |
+
{
|
| 49 |
+
<div class="space-y-4">
|
| 50 |
+
@for (int i = 0; i < 5; i++)
|
| 51 |
+
{
|
| 52 |
+
<div class="animate-pulse bg-card rounded-xl border border-border h-20 w-full"></div>
|
| 53 |
+
}
|
| 54 |
+
</div>
|
| 55 |
+
}
|
| 56 |
+
else
|
| 57 |
+
{
|
| 58 |
+
@if (_activeTab == "pending")
|
| 59 |
+
{
|
| 60 |
+
<div class="animate-fade">
|
| 61 |
+
@if (!_pendingRedemptions.Any())
|
| 62 |
+
{
|
| 63 |
+
<div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm">
|
| 64 |
+
<p class="text-muted font-bold text-lg mb-2">No pending redemptions</p>
|
| 65 |
+
<p class="text-muted/70 text-sm">All member requests have been fulfilled.</p>
|
| 66 |
+
</div>
|
| 67 |
+
}
|
| 68 |
+
else
|
| 69 |
+
{
|
| 70 |
+
<div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
|
| 71 |
+
<table class="w-full text-left border-collapse min-w-max whitespace-nowrap">
|
| 72 |
+
<thead class="bg-body border-b border-border">
|
| 73 |
+
<tr>
|
| 74 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
|
| 75 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Reward</th>
|
| 76 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Points</th>
|
| 77 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Claimed Date</th>
|
| 78 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th>
|
| 79 |
+
<th class="px-6 py-4 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Action</th>
|
| 80 |
+
</tr>
|
| 81 |
+
</thead>
|
| 82 |
+
<tbody class="divide-y divide-border">
|
| 83 |
+
@foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt))
|
| 84 |
+
{
|
| 85 |
+
<tr class="hover:bg-body transition-colors">
|
| 86 |
+
<td class="px-6 py-4">
|
| 87 |
+
<div class="flex items-center gap-3">
|
| 88 |
+
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">
|
| 89 |
+
@(r.ExternalUserId.Length >= 2 ? r.ExternalUserId.Substring(0, 2).ToUpper() : "U")
|
| 90 |
+
</div>
|
| 91 |
+
<span class="text-sm font-bold text-main">@r.ExternalUserId</span>
|
| 92 |
+
</div>
|
| 93 |
+
</td>
|
| 94 |
+
<td class="px-6 py-4">
|
| 95 |
+
<p class="text-sm font-bold text-main">@r.RewardName</p>
|
| 96 |
+
<p class="text-[10px] text-muted font-bold tracking-widest uppercase">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)</p>
|
| 97 |
+
</td>
|
| 98 |
+
<td class="px-6 py-4 text-sm font-black text-primary">@r.PointCost.ToString("N0") pts</td>
|
| 99 |
+
<td class="px-6 py-4 text-sm font-medium text-muted">@r.RedeemedAt.ToString("MMM dd, yyyy")</td>
|
| 100 |
+
<td class="px-6 py-4">
|
| 101 |
+
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-amber-500/10 text-amber-500">
|
| 102 |
+
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
| 103 |
+
Pending
|
| 104 |
+
</span>
|
| 105 |
+
</td>
|
| 106 |
+
<td class="px-6 py-4 text-right">
|
| 107 |
+
<button @onclick="() => FulfillAsync(r)"
|
| 108 |
+
class="inline-flex items-center gap-1.5 bg-green-500/10 text-green-500 border border-green-500/20 px-4 py-2 rounded-lg text-xs font-bold hover:bg-green-500 hover:text-white transition-all shadow-sm active:scale-95">
|
| 109 |
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path></svg>
|
| 110 |
+
Fulfill
|
| 111 |
+
</button>
|
| 112 |
+
</td>
|
| 113 |
+
</tr>
|
| 114 |
+
}
|
| 115 |
+
</tbody>
|
| 116 |
+
</table>
|
| 117 |
+
</div>
|
| 118 |
+
}
|
| 119 |
+
</div>
|
| 120 |
+
}
|
| 121 |
+
else if (_activeTab == "history")
|
| 122 |
+
{
|
| 123 |
+
<div class="animate-fade">
|
| 124 |
+
@if (!_allMembersHistory.Any())
|
| 125 |
+
{
|
| 126 |
+
<div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm">
|
| 127 |
+
<p class="text-muted font-bold text-lg">No loyalty history found.</p>
|
| 128 |
+
</div>
|
| 129 |
+
}
|
| 130 |
+
else
|
| 131 |
+
{
|
| 132 |
+
<div class="mb-6">
|
| 133 |
+
<input type="text" placeholder="Filter by member name or email..."
|
| 134 |
+
class="w-full max-w-md bg-card border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main shadow-sm transition-all"
|
| 135 |
+
@bind="_searchQuery" @bind:event="oninput" />
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div class="space-y-4">
|
| 139 |
+
@foreach (var member in FilteredMembers)
|
| 140 |
+
{
|
| 141 |
+
<div class="bg-card border border-border rounded-2xl overflow-hidden shadow-sm hover:border-primary/50 transition-colors">
|
| 142 |
+
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between px-6 py-5 cursor-pointer hover:bg-body transition-colors"
|
| 143 |
+
@onclick="() => ToggleMember(member.AccountId)">
|
| 144 |
+
<div class="flex items-center gap-4 mb-4 sm:mb-0">
|
| 145 |
+
<div class="w-12 h-12 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center font-bold shadow-inner flex-shrink-0">
|
| 146 |
+
@(member.UserName.Length >= 2 ? member.UserName.Substring(0, 2).ToUpper() : "?")
|
| 147 |
+
</div>
|
| 148 |
+
<div>
|
| 149 |
+
<p class="font-bold text-main text-lg">@member.UserName</p>
|
| 150 |
+
<p class="text-xs font-medium text-muted">@member.UserEmail</p>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
<div class="flex items-center gap-6 w-full sm:w-auto justify-between sm:justify-end">
|
| 154 |
+
<div class="text-right">
|
| 155 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">Balance</p>
|
| 156 |
+
<p class="font-black text-main text-lg">@member.CurrentBalance.ToString("N0") pts</p>
|
| 157 |
+
</div>
|
| 158 |
+
<div class="text-right hidden md:block">
|
| 159 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">Tier</p>
|
| 160 |
+
<span class="text-xs font-bold text-primary">@member.Tier</span>
|
| 161 |
+
</div>
|
| 162 |
+
<div class="text-right hidden sm:block">
|
| 163 |
+
<p class="text-[10px] font-black text-muted uppercase tracking-widest">History</p>
|
| 164 |
+
<p class="font-bold text-main text-sm">@member.History.Count() items</p>
|
| 165 |
+
</div>
|
| 166 |
+
<svg class="w-5 h-5 text-muted transition-transform @(_expandedMembers.Contains(member.AccountId) ? "rotate-180" : "")" 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"></path></svg>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
@if (_expandedMembers.Contains(member.AccountId))
|
| 171 |
+
{
|
| 172 |
+
<div class="border-t border-border bg-body animate-fade-in-down">
|
| 173 |
+
@if (!member.History.Any())
|
| 174 |
+
{
|
| 175 |
+
<p class="px-6 py-6 text-sm text-muted font-medium text-center">No transactions found for this member.</p>
|
| 176 |
+
}
|
| 177 |
+
else
|
| 178 |
+
{
|
| 179 |
+
<div class="overflow-x-auto">
|
| 180 |
+
<table class="min-w-full text-left min-w-max whitespace-nowrap">
|
| 181 |
+
<thead>
|
| 182 |
+
<tr class="bg-card">
|
| 183 |
+
<th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Date</th>
|
| 184 |
+
<th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Type</th>
|
| 185 |
+
<th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Details</th>
|
| 186 |
+
<th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Subject</th>
|
| 187 |
+
<th class="px-6 py-3 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Points</th>
|
| 188 |
+
</tr>
|
| 189 |
+
</thead>
|
| 190 |
+
<tbody class="divide-y divide-border">
|
| 191 |
+
@foreach (var h in member.History.OrderByDescending(x => x.CreatedAt))
|
| 192 |
+
{
|
| 193 |
+
var isPos = h.PointDelta >= 0;
|
| 194 |
+
var label = PrettyEvent(h.EventKey, h.PointDelta);
|
| 195 |
+
string? spentOn = h.RewardName;
|
| 196 |
+
|
| 197 |
+
<tr class="hover:bg-card transition-colors">
|
| 198 |
+
<td class="px-6 py-3 text-xs font-medium text-muted whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
|
| 199 |
+
<td class="px-6 py-3">
|
| 200 |
+
<span class="px-2 py-0.5 rounded-sm text-[9px] font-black uppercase tracking-widest border @(isPos ? "bg-green-500/10 text-green-500 border-green-500/20" : "bg-red-500/10 text-red-500 border-red-500/20")">@label</span>
|
| 201 |
+
</td>
|
| 202 |
+
<td class="px-6 py-3 text-xs font-medium text-main max-w-xs truncate">@h.Description</td>
|
| 203 |
+
<td class="px-6 py-3 text-xs font-bold text-muted max-w-xs truncate">
|
| 204 |
+
@if (!string.IsNullOrWhiteSpace(spentOn))
|
| 205 |
+
{
|
| 206 |
+
<span class="text-main">@spentOn</span>
|
| 207 |
+
}
|
| 208 |
+
else
|
| 209 |
+
{
|
| 210 |
+
<span>—</span>
|
| 211 |
+
}
|
| 212 |
+
</td>
|
| 213 |
+
<td class="px-6 py-3 text-right font-black text-sm @(isPos ? "text-green-500" : "text-red-500")">
|
| 214 |
+
@(isPos ? "+" : "")@h.PointDelta.ToString("N0")
|
| 215 |
+
</td>
|
| 216 |
+
</tr>
|
| 217 |
+
}
|
| 218 |
+
</tbody>
|
| 219 |
+
</table>
|
| 220 |
+
</div>
|
| 221 |
+
}
|
| 222 |
+
</div>
|
| 223 |
+
}
|
| 224 |
+
</div>
|
| 225 |
+
}
|
| 226 |
+
</div>
|
| 227 |
+
}
|
| 228 |
+
</div>
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
@code {
|
| 234 |
+
private string _activeTab = "pending";
|
| 235 |
+
private bool _isLoading = true;
|
| 236 |
+
private string _searchQuery = "";
|
| 237 |
+
private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 238 |
+
private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>();
|
| 239 |
+
private HashSet<string> _expandedMembers = new();
|
| 240 |
+
|
| 241 |
+
protected override async Task OnInitializedAsync()
|
| 242 |
+
{
|
| 243 |
+
await LoadDataAsync();
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
private async Task LoadDataAsync()
|
| 247 |
+
{
|
| 248 |
+
_isLoading = true;
|
| 249 |
+
try
|
| 250 |
+
{
|
| 251 |
+
_pendingRedemptions = await ApiClient.GetPendingRedemptionsAsync();
|
| 252 |
+
_allMembersHistory = await ApiClient.GetAllMembersPointsHistoryAsync();
|
| 253 |
+
}
|
| 254 |
+
catch (Exception ex)
|
| 255 |
+
{
|
| 256 |
+
await JS.InvokeVoidAsync("alert", $"Error loading management data: {ex.Message}");
|
| 257 |
+
}
|
| 258 |
+
finally
|
| 259 |
+
{
|
| 260 |
+
_isLoading = false;
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
private IEnumerable<UserPointsHistoryDto> FilteredMembers =>
|
| 265 |
+
string.IsNullOrWhiteSpace(_searchQuery)
|
| 266 |
+
? _allMembersHistory
|
| 267 |
+
: _allMembersHistory.Where(m => m.UserName.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 268 |
+
m.UserEmail.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
|
| 269 |
+
|
| 270 |
+
private void ToggleMember(string accountId)
|
| 271 |
+
{
|
| 272 |
+
if (_expandedMembers.Contains(accountId))
|
| 273 |
+
_expandedMembers.Remove(accountId);
|
| 274 |
+
else
|
| 275 |
+
_expandedMembers.Add(accountId);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
private async Task FulfillAsync(LoyaltyRedemptionDto redemption)
|
| 279 |
+
{
|
| 280 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Fulfill this redemption and grant membership to the member?");
|
| 281 |
+
if (!confirmed) return;
|
| 282 |
+
|
| 283 |
+
var result = await ApiClient.FulfillRedemptionAsync(redemption.Id);
|
| 284 |
+
if (result.Success)
|
| 285 |
+
{
|
| 286 |
+
await JS.InvokeVoidAsync("alert", result.Message);
|
| 287 |
+
await LoadDataAsync();
|
| 288 |
+
}
|
| 289 |
+
else
|
| 290 |
+
{
|
| 291 |
+
await JS.InvokeVoidAsync("alert", result.Message);
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
private string PrettyEvent(string? eventKey, double points)
|
| 296 |
+
{
|
| 297 |
+
var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
|
| 298 |
+
|
| 299 |
+
return key switch
|
| 300 |
+
{
|
| 301 |
+
"RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
|
| 302 |
+
"SIGNUP" or "REGISTER" => "SIGNUP",
|
| 303 |
+
"SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
|
| 304 |
+
"REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
|
| 305 |
+
_ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
|
| 306 |
+
};
|
| 307 |
+
}
|
| 308 |
+
}
|
BlazorWebAssembly/Pages/Terms.razor
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/terms"
|
| 2 |
+
|
| 3 |
+
<div class="max-w-4xl mx-auto py-24 px-8 animate-fade">
|
| 4 |
+
<h1 class="text-5xl font-black tracking-tighter text-main mb-8 uppercase">Terms of Service</h1>
|
| 5 |
+
<p class="text-sm text-muted mb-12 font-bold uppercase tracking-widest">Effective Date: May 2026</p>
|
| 6 |
+
|
| 7 |
+
<div class="space-y-12 text-muted leading-relaxed">
|
| 8 |
+
<section>
|
| 9 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">1. Acceptance of Terms</h2>
|
| 10 |
+
<p>
|
| 11 |
+
By accessing and using LibraryLuxe, you agree to be bound by these Terms of Service and all applicable laws and regulations.
|
| 12 |
+
</p>
|
| 13 |
+
</section>
|
| 14 |
+
|
| 15 |
+
<section>
|
| 16 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">2. Use License</h2>
|
| 17 |
+
<p>
|
| 18 |
+
Permission is granted to temporarily download one copy of the materials on LibraryLuxe's website for personal, non-commercial transitory viewing only.
|
| 19 |
+
</p>
|
| 20 |
+
</section>
|
| 21 |
+
|
| 22 |
+
<section>
|
| 23 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">3. User Conduct</h2>
|
| 24 |
+
<p>
|
| 25 |
+
Users are expected to return borrowed materials on time. Repeated late returns may lead to account suspension or loyalty point penalties.
|
| 26 |
+
</p>
|
| 27 |
+
</section>
|
| 28 |
+
|
| 29 |
+
<section>
|
| 30 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">4. Account Termination</h2>
|
| 31 |
+
<p>
|
| 32 |
+
We reserve the right to terminate or suspend access to our service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.
|
| 33 |
+
</p>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section class="p-8 bg-card rounded-3xl border border-border">
|
| 37 |
+
<h2 class="text-xl font-bold text-main mb-4 uppercase tracking-tight">Questions?</h2>
|
| 38 |
+
<p class="mb-4">If you have any questions about these Terms, please contact us.</p>
|
| 39 |
+
<a href="/contact" class="text-primary font-bold hover:underline">Contact Support</a>
|
| 40 |
+
</section>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
BlazorWebAssembly/Pages/UserInsights.razor
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
@using Microsoft.AspNetCore.Authorization
|
| 7 |
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
|
| 9 |
-
<div class="max-w-7xl mx-auto py-12 px-6">
|
| 10 |
<!-- Header -->
|
| 11 |
<div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-6">
|
| 12 |
<div>
|
|
@@ -16,85 +16,255 @@
|
|
| 16 |
</p>
|
| 17 |
</div>
|
| 18 |
<div class="flex gap-4">
|
| 19 |
-
<div class="bg-card border border-border px-6 py-3 rounded-2xl flex flex-col">
|
| 20 |
<span class="text-[10px] font-black text-muted uppercase tracking-[0.2em]">Total Members</span>
|
| 21 |
-
<span class="text-2xl font-black text-main">
|
| 22 |
</div>
|
| 23 |
-
<div class="bg-
|
| 24 |
-
<span class="text-[10px] font-black text-primary uppercase tracking-[0.2em]">Active
|
| 25 |
-
<span class="text-2xl font-black text-primary">
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
<
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</div>
|
| 50 |
-
</div>
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
<div class="flex items-center gap-4">
|
| 59 |
-
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-black text-xs">
|
| 60 |
-
L
|
| 61 |
-
</div>
|
| 62 |
-
<div class="flex-grow">
|
| 63 |
-
<p class="font-bold text-main text-sm">User Alpha @i</p>
|
| 64 |
-
<p class="text-[10px] text-muted font-medium uppercase tracking-widest">@(5000 - (i * 500)) Points</p>
|
| 65 |
-
</div>
|
| 66 |
-
<span class="text-xs font-black text-primary">TIER @(6-i)</span>
|
| 67 |
</div>
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
-
</div>
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
<
|
| 79 |
-
<div class="bg-primary/60 w-full rounded-t-xl" style="height: 30%"></div>
|
| 80 |
-
<div class="bg-primary/80 w-full rounded-t-xl" style="height: 90%"></div>
|
| 81 |
-
<div class="bg-primary w-full rounded-t-xl" style="height: 100%"></div>
|
| 82 |
</div>
|
| 83 |
-
<div class="
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
</div>
|
| 94 |
|
| 95 |
@code {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
protected override async Task OnInitializedAsync()
|
| 97 |
{
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
}
|
|
|
|
| 6 |
@using Microsoft.AspNetCore.Authorization
|
| 7 |
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
|
| 9 |
+
<div class="max-w-7xl mx-auto py-12 px-6 animate-fade">
|
| 10 |
<!-- Header -->
|
| 11 |
<div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-16 gap-6">
|
| 12 |
<div>
|
|
|
|
| 16 |
</p>
|
| 17 |
</div>
|
| 18 |
<div class="flex gap-4">
|
| 19 |
+
<div class="bg-card border border-border px-6 py-3 rounded-2xl flex flex-col shadow-sm">
|
| 20 |
<span class="text-[10px] font-black text-muted uppercase tracking-[0.2em]">Total Members</span>
|
| 21 |
+
<span class="text-2xl font-black text-main">@(_totalMembers)</span>
|
| 22 |
</div>
|
| 23 |
+
<div class="bg-primary/10 border border-primary/20 px-6 py-3 rounded-2xl flex flex-col text-primary shadow-sm">
|
| 24 |
+
<span class="text-[10px] font-black text-primary uppercase tracking-[0.2em]">Active Subs</span>
|
| 25 |
+
<span class="text-2xl font-black text-primary">@(_activeSubscriptions)</span>
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
|
| 30 |
+
@if (_isLoading)
|
| 31 |
+
{
|
| 32 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
|
| 33 |
+
@for (int i = 0; i < 3; i++)
|
| 34 |
+
{
|
| 35 |
+
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm animate-pulse h-96"></div>
|
| 36 |
+
}
|
| 37 |
+
</div>
|
| 38 |
+
}
|
| 39 |
+
else
|
| 40 |
+
{
|
| 41 |
+
<!-- Metrics Grid -->
|
| 42 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16">
|
| 43 |
+
|
| 44 |
+
<!-- Most Active Readers -->
|
| 45 |
+
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
|
| 46 |
+
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
|
| 47 |
+
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Top Readers</h3>
|
| 48 |
+
<div class="w-8 h-8 rounded-full bg-blue-500/10 text-blue-500 flex items-center justify-center">
|
| 49 |
+
<svg class="w-4 h-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 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 50 |
</div>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="space-y-6 flex-grow">
|
| 53 |
+
@if (!_activeReaders.Any())
|
| 54 |
+
{
|
| 55 |
+
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
|
| 56 |
+
}
|
| 57 |
+
@foreach (var (user, index) in _activeReaders.Take(5).Select((u, i) => (u, i)))
|
| 58 |
+
{
|
| 59 |
+
var maxRaw = _activeReaders.Max(u => u.RawValue);
|
| 60 |
+
var pct = maxRaw > 0 ? (user.RawValue / maxRaw) * 100 : 0;
|
| 61 |
+
<div class="flex items-center gap-4">
|
| 62 |
+
<span class="w-8 h-8 rounded-lg bg-body flex items-center justify-center font-black text-muted text-xs">@(index + 1)</span>
|
| 63 |
+
<div class="flex-grow">
|
| 64 |
+
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
|
| 65 |
+
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="w-16 bg-body h-1.5 rounded-full overflow-hidden">
|
| 68 |
+
<div class="bg-blue-500 h-full rounded-full" style="width: @pct%"></div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
}
|
| 72 |
+
</div>
|
| 73 |
</div>
|
|
|
|
| 74 |
|
| 75 |
+
<!-- Loyalty Leaders -->
|
| 76 |
+
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
|
| 77 |
+
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
|
| 78 |
+
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Point Leaders</h3>
|
| 79 |
+
<div class="w-8 h-8 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center">
|
| 80 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"></path></svg>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</div>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="space-y-6 flex-grow">
|
| 84 |
+
@if (!_loyalty.Any())
|
| 85 |
+
{
|
| 86 |
+
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
|
| 87 |
+
}
|
| 88 |
+
@foreach (var (user, index) in _loyalty.Take(5).Select((u, i) => (u, i)))
|
| 89 |
+
{
|
| 90 |
+
<div class="flex items-center gap-4">
|
| 91 |
+
<div class="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500 font-black text-xs">
|
| 92 |
+
@(user.Name.Length >= 2 ? user.Name.Substring(0, 2).ToUpper() : "?")
|
| 93 |
+
</div>
|
| 94 |
+
<div class="flex-grow">
|
| 95 |
+
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
|
| 96 |
+
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
|
| 97 |
+
</div>
|
| 98 |
+
<span class="text-[10px] font-black text-amber-500 bg-amber-500/10 px-2 py-1 rounded-md">#@(index + 1)</span>
|
| 99 |
+
</div>
|
| 100 |
+
}
|
| 101 |
+
</div>
|
| 102 |
</div>
|
|
|
|
| 103 |
|
| 104 |
+
<!-- Top Subscribers -->
|
| 105 |
+
<div class="bg-card rounded-[2rem] border border-border p-10 shadow-sm flex flex-col">
|
| 106 |
+
<div class="flex items-center justify-between mb-8 border-b border-border pb-4">
|
| 107 |
+
<h3 class="text-xl font-bold text-main uppercase tracking-widest text-xs">Top Subscribers</h3>
|
| 108 |
+
<div class="w-8 h-8 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center">
|
| 109 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path></svg>
|
| 110 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
+
<div class="space-y-6 flex-grow">
|
| 113 |
+
@if (!_subscribers.Any())
|
| 114 |
+
{
|
| 115 |
+
<p class="text-muted text-sm italic text-center py-10">No data available yet.</p>
|
| 116 |
+
}
|
| 117 |
+
@foreach (var (user, index) in _subscribers.Take(5).Select((u, i) => (u, i)))
|
| 118 |
+
{
|
| 119 |
+
<div class="flex items-center gap-4">
|
| 120 |
+
<span class="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-600 flex items-center justify-center font-black text-xs border border-emerald-500/20">@(index + 1)</span>
|
| 121 |
+
<div class="flex-grow">
|
| 122 |
+
<p class="font-bold text-main text-sm truncate max-w-[150px]">@user.Name</p>
|
| 123 |
+
<p class="text-[10px] text-muted font-black uppercase tracking-widest">@user.ValueLabel</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
}
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
+
|
| 131 |
+
<div class="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
|
| 132 |
+
<div class="p-6 border-b border-border bg-body">
|
| 133 |
+
<h3 class="text-lg font-bold text-main">Full Member Insights (All Users)</h3>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="overflow-x-auto">
|
| 136 |
+
<table class="w-full text-left border-collapse">
|
| 137 |
+
<thead>
|
| 138 |
+
<tr class="bg-body border-b border-border">
|
| 139 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
|
| 140 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Joined Date</th>
|
| 141 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-right">Lifetime Points</th>
|
| 142 |
+
<th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest text-right">Books Read</th>
|
| 143 |
+
</tr>
|
| 144 |
+
</thead>
|
| 145 |
+
<tbody class="divide-y divide-border">
|
| 146 |
+
@foreach (var user in _allUsers.OrderByDescending(u => u.CreatedAt))
|
| 147 |
+
{
|
| 148 |
+
var points = _loyalty.FirstOrDefault(l => l.UserId == user.Id)?.RawValue ?? 0;
|
| 149 |
+
var books = _activeReaders.FirstOrDefault(r => r.UserId == user.Id)?.RawValue ?? 0;
|
| 150 |
+
|
| 151 |
+
<tr class="hover:bg-body transition-colors">
|
| 152 |
+
<td class="px-6 py-5">
|
| 153 |
+
<div class="flex items-center gap-3">
|
| 154 |
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs border border-primary/20">
|
| 155 |
+
@(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U")
|
| 156 |
+
</div>
|
| 157 |
+
<p class="text-sm font-bold text-main">@user.FullName</p>
|
| 158 |
+
</div>
|
| 159 |
+
</td>
|
| 160 |
+
<td class="px-6 py-5 text-sm font-medium text-muted">@user.CreatedAt.ToString("MMM dd, yyyy")</td>
|
| 161 |
+
<td class="px-6 py-5 text-right font-black text-amber-500">@points.ToString("N0")</td>
|
| 162 |
+
<td class="px-6 py-5 text-right font-black text-blue-500">@books.ToString("N0")</td>
|
| 163 |
+
</tr>
|
| 164 |
+
}
|
| 165 |
+
</tbody>
|
| 166 |
+
</table>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
}
|
| 170 |
</div>
|
| 171 |
|
| 172 |
@code {
|
| 173 |
+
private bool _isLoading = true;
|
| 174 |
+
|
| 175 |
+
private List<InsightItem> _subscribers = new();
|
| 176 |
+
private List<InsightItem> _loyalty = new();
|
| 177 |
+
private List<InsightItem> _activeReaders = new();
|
| 178 |
+
|
| 179 |
+
private List<UserDto> _allUsers = new();
|
| 180 |
+
private int _totalMembers = 0;
|
| 181 |
+
private int _activeSubscriptions = 0;
|
| 182 |
+
|
| 183 |
protected override async Task OnInitializedAsync()
|
| 184 |
{
|
| 185 |
+
await LoadInsightsAsync();
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
private async Task LoadInsightsAsync()
|
| 189 |
+
{
|
| 190 |
+
_isLoading = true;
|
| 191 |
+
try
|
| 192 |
+
{
|
| 193 |
+
var users = await ApiClient.GetUsersAsync();
|
| 194 |
+
_allUsers = users.ToList();
|
| 195 |
+
_totalMembers = _allUsers.Count;
|
| 196 |
+
|
| 197 |
+
var borrowings = await ApiClient.GetAllBorrowingsAsync();
|
| 198 |
+
var subscriptions = await ApiClient.GetAllSubscriptionsAsync();
|
| 199 |
+
var memberships = await ApiClient.GetMembershipsAsync();
|
| 200 |
+
var loyaltyHistory = await ApiClient.GetAllMembersPointsHistoryAsync();
|
| 201 |
+
|
| 202 |
+
_activeSubscriptions = subscriptions.Count(s => s.IsActive);
|
| 203 |
+
|
| 204 |
+
// 1. Top Subscribers (Total Spent)
|
| 205 |
+
var membershipPriceMap = memberships.ToDictionary(m => m.Id, m => m.Price);
|
| 206 |
+
_subscribers = subscriptions
|
| 207 |
+
.GroupBy(s => s.UserId)
|
| 208 |
+
.Select(g => {
|
| 209 |
+
var user = _allUsers.FirstOrDefault(u => u.Id == g.Key);
|
| 210 |
+
var totalSpent = g.Sum(s => membershipPriceMap.TryGetValue(s.MembershipId, out var price) ? price : 0);
|
| 211 |
+
return new InsightItem {
|
| 212 |
+
UserId = g.Key,
|
| 213 |
+
Name = user?.FullName ?? "Unknown User",
|
| 214 |
+
RawValue = (double)totalSpent,
|
| 215 |
+
ValueLabel = $"MMK {totalSpent:N0}"
|
| 216 |
+
};
|
| 217 |
+
})
|
| 218 |
+
.OrderByDescending(i => i.RawValue)
|
| 219 |
+
.ToList();
|
| 220 |
+
|
| 221 |
+
// 2. Top Loyalty (Earned Points only)
|
| 222 |
+
_loyalty = loyaltyHistory
|
| 223 |
+
.Select(lh => {
|
| 224 |
+
var user = _allUsers.FirstOrDefault(u => u.Id == lh.UserId);
|
| 225 |
+
var totalEarned = lh.History.Where(h => h.PointDelta > 0).Sum(h => h.PointDelta);
|
| 226 |
+
return new InsightItem {
|
| 227 |
+
UserId = lh.UserId,
|
| 228 |
+
Name = user?.FullName ?? lh.UserName,
|
| 229 |
+
RawValue = totalEarned,
|
| 230 |
+
ValueLabel = $"{totalEarned:N0} pts"
|
| 231 |
+
};
|
| 232 |
+
})
|
| 233 |
+
.OrderByDescending(i => i.RawValue)
|
| 234 |
+
.ToList();
|
| 235 |
+
|
| 236 |
+
// 3. Most Active (Approved Returns only)
|
| 237 |
+
_activeReaders = borrowings
|
| 238 |
+
.Where(b => b.Status == "Returned")
|
| 239 |
+
.GroupBy(b => b.UserId)
|
| 240 |
+
.Select(g => {
|
| 241 |
+
var user = _allUsers.FirstOrDefault(u => u.Id == g.Key);
|
| 242 |
+
var count = g.Count();
|
| 243 |
+
return new InsightItem {
|
| 244 |
+
UserId = g.Key,
|
| 245 |
+
Name = user?.FullName ?? "Unknown User",
|
| 246 |
+
RawValue = count,
|
| 247 |
+
ValueLabel = $"{count} books"
|
| 248 |
+
};
|
| 249 |
+
})
|
| 250 |
+
.OrderByDescending(i => i.RawValue)
|
| 251 |
+
.ToList();
|
| 252 |
+
}
|
| 253 |
+
catch (Exception ex)
|
| 254 |
+
{
|
| 255 |
+
Console.WriteLine($"Error loading insights: {ex.Message}");
|
| 256 |
+
}
|
| 257 |
+
finally
|
| 258 |
+
{
|
| 259 |
+
_isLoading = false;
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
private class InsightItem
|
| 264 |
+
{
|
| 265 |
+
public Guid UserId { get; set; }
|
| 266 |
+
public string Name { get; set; } = "";
|
| 267 |
+
public double RawValue { get; set; }
|
| 268 |
+
public string ValueLabel { get; set; } = "";
|
| 269 |
}
|
| 270 |
}
|
BlazorWebAssembly/Pages/Wishlist.razor
CHANGED
|
@@ -2,7 +2,9 @@
|
|
| 2 |
@using LibraryManagement.Shared.Models
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
|
|
|
| 5 |
@inject NavigationManager Navigation
|
|
|
|
| 6 |
|
| 7 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 8 |
<!-- Header -->
|
|
@@ -56,7 +58,7 @@
|
|
| 56 |
<span class='px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest @(book.AvailableCopies > 0 ? "bg-green-500/10 text-green-600" : "bg-orange-500/10 text-orange-600")'>
|
| 57 |
@(book.AvailableCopies > 0 ? "Available" : "Waiting")
|
| 58 |
</span>
|
| 59 |
-
<button class="text-xs font-bold text-red-400 hover:text-red-500">Remove</button>
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
</div>
|
|
@@ -66,14 +68,32 @@
|
|
| 66 |
</div>
|
| 67 |
|
| 68 |
@code {
|
| 69 |
-
private bool _isLoading =
|
| 70 |
private List<BookDto> _wishlistItems = new();
|
| 71 |
|
| 72 |
protected override async Task OnInitializedAsync()
|
| 73 |
{
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
_isLoading = true;
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
}
|
|
|
|
| 2 |
@using LibraryManagement.Shared.Models
|
| 3 |
@using BlazorWebAssembly.Services
|
| 4 |
@inject LibraryApiClient ApiClient
|
| 5 |
+
@inject WishlistService WishlistService
|
| 6 |
@inject NavigationManager Navigation
|
| 7 |
+
@inject IJSRuntime JS
|
| 8 |
|
| 9 |
<div class="max-w-6xl mx-auto py-12 px-6">
|
| 10 |
<!-- Header -->
|
|
|
|
| 58 |
<span class='px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest @(book.AvailableCopies > 0 ? "bg-green-500/10 text-green-600" : "bg-orange-500/10 text-orange-600")'>
|
| 59 |
@(book.AvailableCopies > 0 ? "Available" : "Waiting")
|
| 60 |
</span>
|
| 61 |
+
<button @onclick="() => RemoveFromWishlistAsync(book.Id)" class="text-xs font-bold text-red-400 hover:text-red-500 transition-colors">Remove</button>
|
| 62 |
</div>
|
| 63 |
</div>
|
| 64 |
</div>
|
|
|
|
| 68 |
</div>
|
| 69 |
|
| 70 |
@code {
|
| 71 |
+
private bool _isLoading = true;
|
| 72 |
private List<BookDto> _wishlistItems = new();
|
| 73 |
|
| 74 |
protected override async Task OnInitializedAsync()
|
| 75 |
{
|
| 76 |
+
await LoadData();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
private async Task LoadData()
|
| 80 |
+
{
|
| 81 |
_isLoading = true;
|
| 82 |
+
try
|
| 83 |
+
{
|
| 84 |
+
_wishlistItems = await WishlistService.GetWishlistAsync();
|
| 85 |
+
}
|
| 86 |
+
catch { }
|
| 87 |
+
finally
|
| 88 |
+
{
|
| 89 |
+
_isLoading = false;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
private async Task RemoveFromWishlistAsync(int bookId)
|
| 94 |
+
{
|
| 95 |
+
await WishlistService.RemoveFromWishlistAsync(bookId);
|
| 96 |
+
await LoadData();
|
| 97 |
+
await JS.InvokeVoidAsync("alert", "Removed from wishlist");
|
| 98 |
}
|
| 99 |
}
|
BlazorWebAssembly/Program.cs
CHANGED
|
@@ -32,4 +32,7 @@ builder.Services.AddScoped<ThemeService>();
|
|
| 32 |
// Register LibraryApiClient
|
| 33 |
builder.Services.AddScoped<LibraryApiClient>();
|
| 34 |
|
|
|
|
|
|
|
|
|
|
| 35 |
await builder.Build().RunAsync();
|
|
|
|
| 32 |
// Register LibraryApiClient
|
| 33 |
builder.Services.AddScoped<LibraryApiClient>();
|
| 34 |
|
| 35 |
+
// Register WishlistService
|
| 36 |
+
builder.Services.AddScoped<WishlistService>();
|
| 37 |
+
|
| 38 |
await builder.Build().RunAsync();
|
BlazorWebAssembly/Providers/JwtAuthenticationStateProvider.cs
CHANGED
|
@@ -26,9 +26,23 @@ namespace BlazorWebAssembly.Providers
|
|
| 26 |
if (string.IsNullOrWhiteSpace(token))
|
| 27 |
return _anonymous;
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", token);
|
| 30 |
|
| 31 |
-
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
|
| 32 |
}
|
| 33 |
|
| 34 |
public void NotifyUserAuthentication(string token)
|
|
@@ -57,16 +71,20 @@ namespace BlazorWebAssembly.Providers
|
|
| 57 |
{
|
| 58 |
foreach (var kvp in keyValuePairs)
|
| 59 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if (kvp.Value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
| 61 |
{
|
| 62 |
foreach (var item in element.EnumerateArray())
|
| 63 |
{
|
| 64 |
-
claims.Add(new Claim(
|
| 65 |
}
|
| 66 |
}
|
| 67 |
else
|
| 68 |
{
|
| 69 |
-
claims.Add(new Claim(
|
| 70 |
}
|
| 71 |
}
|
| 72 |
}
|
|
|
|
| 26 |
if (string.IsNullOrWhiteSpace(token))
|
| 27 |
return _anonymous;
|
| 28 |
|
| 29 |
+
var claims = ParseClaimsFromJwt(token);
|
| 30 |
+
|
| 31 |
+
// Check expiration
|
| 32 |
+
var expClaim = claims.FirstOrDefault(c => c.Type == "exp");
|
| 33 |
+
if (expClaim != null && long.TryParse(expClaim.Value, out long expTime))
|
| 34 |
+
{
|
| 35 |
+
var expDateTime = DateTimeOffset.FromUnixTimeSeconds(expTime).UtcDateTime;
|
| 36 |
+
if (expDateTime <= DateTime.UtcNow)
|
| 37 |
+
{
|
| 38 |
+
await _localStorage.RemoveItemAsync("authToken");
|
| 39 |
+
return _anonymous;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", token);
|
| 44 |
|
| 45 |
+
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")));
|
| 46 |
}
|
| 47 |
|
| 48 |
public void NotifyUserAuthentication(string token)
|
|
|
|
| 71 |
{
|
| 72 |
foreach (var kvp in keyValuePairs)
|
| 73 |
{
|
| 74 |
+
string claimType = kvp.Key;
|
| 75 |
+
if (claimType == "role" || claimType == "Role") claimType = ClaimTypes.Role;
|
| 76 |
+
else if (claimType == "unique_name" || claimType == "name") claimType = ClaimTypes.Name;
|
| 77 |
+
|
| 78 |
if (kvp.Value is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
| 79 |
{
|
| 80 |
foreach (var item in element.EnumerateArray())
|
| 81 |
{
|
| 82 |
+
claims.Add(new Claim(claimType, item.ToString()));
|
| 83 |
}
|
| 84 |
}
|
| 85 |
else
|
| 86 |
{
|
| 87 |
+
claims.Add(new Claim(claimType, kvp.Value.ToString() ?? ""));
|
| 88 |
}
|
| 89 |
}
|
| 90 |
}
|
BlazorWebAssembly/Services/LibraryApiClient.cs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
|
|
| 1 |
using System.Net.Http.Json;
|
| 2 |
using System.Text.Json;
|
| 3 |
-
using System.Text.Json.Serialization;
|
| 4 |
using LibraryManagement.Shared.Models;
|
| 5 |
|
| 6 |
namespace BlazorWebAssembly.Services
|
|
@@ -8,12 +8,30 @@ namespace BlazorWebAssembly.Services
|
|
| 8 |
public class LibraryApiClient
|
| 9 |
{
|
| 10 |
private readonly HttpClient _httpClient;
|
| 11 |
-
|
| 12 |
public LibraryApiClient(HttpClient httpClient)
|
| 13 |
{
|
| 14 |
_httpClient = httpClient;
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
#region Books
|
| 18 |
public async Task<IEnumerable<BookDto>> GetBooksAsync(int? categoryId = null)
|
| 19 |
{
|
|
@@ -26,6 +44,13 @@ namespace BlazorWebAssembly.Services
|
|
| 26 |
return await _httpClient.GetFromJsonAsync<BookDto>($"api/books/{id}");
|
| 27 |
}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
public async Task<BookDto?> CreateBookAsync(BookCreateRequest request)
|
| 30 |
{
|
| 31 |
var response = await _httpClient.PostAsJsonAsync("api/books", request);
|
|
@@ -67,12 +92,6 @@ namespace BlazorWebAssembly.Services
|
|
| 67 |
{
|
| 68 |
return await _httpClient.GetFromJsonAsync<IEnumerable<BorrowingDto>>("api/borrowings") ?? Enumerable.Empty<BorrowingDto>();
|
| 69 |
}
|
| 70 |
-
|
| 71 |
-
public async Task<bool> RequestReturnAsync(Guid borrowingId)
|
| 72 |
-
{
|
| 73 |
-
var response = await _httpClient.PostAsync($"api/borrowings/return-request/{borrowingId}", null);
|
| 74 |
-
return response.IsSuccessStatusCode;
|
| 75 |
-
}
|
| 76 |
#endregion
|
| 77 |
|
| 78 |
#region Categories
|
|
@@ -80,6 +99,23 @@ namespace BlazorWebAssembly.Services
|
|
| 80 |
{
|
| 81 |
return await _httpClient.GetFromJsonAsync<IEnumerable<CategoryDto>>("api/categories") ?? Enumerable.Empty<CategoryDto>();
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
#endregion
|
| 84 |
|
| 85 |
#region Subscriptions
|
|
@@ -90,41 +126,273 @@ namespace BlazorWebAssembly.Services
|
|
| 90 |
|
| 91 |
public async Task<SubscriptionDto?> GetMySubscriptionAsync()
|
| 92 |
{
|
| 93 |
-
try
|
|
|
|
|
|
|
|
|
|
| 94 |
catch { return null; }
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
public async Task<SubscriptionDto?> SubscribeAsync(SubscribeRequest request)
|
| 98 |
{
|
| 99 |
var response = await _httpClient.PostAsJsonAsync("api/subscriptions/subscribe", request);
|
| 100 |
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<SubscriptionDto>() : null;
|
| 101 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
#endregion
|
| 103 |
|
| 104 |
-
#region Loyalty
|
| 105 |
public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
|
| 106 |
{
|
| 107 |
try
|
| 108 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
-
catch { return null; }
|
| 113 |
}
|
| 114 |
|
| 115 |
public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
|
| 116 |
{
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
-
public async Task<
|
| 121 |
{
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
#endregion
|
| 130 |
}
|
|
|
|
| 1 |
+
using System.Text.Json.Serialization;
|
| 2 |
using System.Net.Http.Json;
|
| 3 |
using System.Text.Json;
|
|
|
|
| 4 |
using LibraryManagement.Shared.Models;
|
| 5 |
|
| 6 |
namespace BlazorWebAssembly.Services
|
|
|
|
| 8 |
public class LibraryApiClient
|
| 9 |
{
|
| 10 |
private readonly HttpClient _httpClient;
|
|
|
|
| 11 |
public LibraryApiClient(HttpClient httpClient)
|
| 12 |
{
|
| 13 |
_httpClient = httpClient;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
#region Auth
|
| 17 |
+
public async Task<AuthResponse?> LoginAsync(LoginRequest request)
|
| 18 |
+
{
|
| 19 |
+
var response = await _httpClient.PostAsJsonAsync("api/auth/login", request);
|
| 20 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<AuthResponse>() : null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
public async Task<AuthResponse?> RegisterAsync(RegisterRequest request)
|
| 24 |
+
{
|
| 25 |
+
var response = await _httpClient.PostAsJsonAsync("api/auth/register", request);
|
| 26 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<AuthResponse>() : null;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
public async Task<object?> GetProfileAsync()
|
| 30 |
+
{
|
| 31 |
+
return await _httpClient.GetFromJsonAsync<object>("api/auth/me");
|
| 32 |
+
}
|
| 33 |
+
#endregion
|
| 34 |
+
|
| 35 |
#region Books
|
| 36 |
public async Task<IEnumerable<BookDto>> GetBooksAsync(int? categoryId = null)
|
| 37 |
{
|
|
|
|
| 44 |
return await _httpClient.GetFromJsonAsync<BookDto>($"api/books/{id}");
|
| 45 |
}
|
| 46 |
|
| 47 |
+
public async Task<List<BookDto>> GetBooksByIdsAsync(List<int> ids)
|
| 48 |
+
{
|
| 49 |
+
if (ids == null || !ids.Any()) return new List<BookDto>();
|
| 50 |
+
var idsStr = string.Join(",", ids);
|
| 51 |
+
return await _httpClient.GetFromJsonAsync<List<BookDto>>($"api/books/by-ids?ids={idsStr}") ?? new List<BookDto>();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
public async Task<BookDto?> CreateBookAsync(BookCreateRequest request)
|
| 55 |
{
|
| 56 |
var response = await _httpClient.PostAsJsonAsync("api/books", request);
|
|
|
|
| 92 |
{
|
| 93 |
return await _httpClient.GetFromJsonAsync<IEnumerable<BorrowingDto>>("api/borrowings") ?? Enumerable.Empty<BorrowingDto>();
|
| 94 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
#endregion
|
| 96 |
|
| 97 |
#region Categories
|
|
|
|
| 99 |
{
|
| 100 |
return await _httpClient.GetFromJsonAsync<IEnumerable<CategoryDto>>("api/categories") ?? Enumerable.Empty<CategoryDto>();
|
| 101 |
}
|
| 102 |
+
|
| 103 |
+
public async Task<IEnumerable<CategoryDto>> GetCategoriesWithBooksAsync()
|
| 104 |
+
{
|
| 105 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<CategoryDto>>("api/categories/with-books") ?? Enumerable.Empty<CategoryDto>();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
public async Task<CategoryDto?> CreateCategoryAsync(CategoryCreateRequest request)
|
| 109 |
+
{
|
| 110 |
+
var response = await _httpClient.PostAsJsonAsync("api/categories", request);
|
| 111 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<CategoryDto>() : null;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
public async Task<bool> DeleteCategoryAsync(int id)
|
| 115 |
+
{
|
| 116 |
+
var response = await _httpClient.DeleteAsync($"api/categories/{id}");
|
| 117 |
+
return response.IsSuccessStatusCode;
|
| 118 |
+
}
|
| 119 |
#endregion
|
| 120 |
|
| 121 |
#region Subscriptions
|
|
|
|
| 126 |
|
| 127 |
public async Task<SubscriptionDto?> GetMySubscriptionAsync()
|
| 128 |
{
|
| 129 |
+
try
|
| 130 |
+
{
|
| 131 |
+
return await _httpClient.GetFromJsonAsync<SubscriptionDto>("api/subscriptions/me");
|
| 132 |
+
}
|
| 133 |
catch { return null; }
|
| 134 |
}
|
| 135 |
|
| 136 |
+
public async Task<IEnumerable<SubscriptionDto>> GetMyAllSubscriptionsAsync()
|
| 137 |
+
{
|
| 138 |
+
try
|
| 139 |
+
{
|
| 140 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/me/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 141 |
+
}
|
| 142 |
+
catch { return Enumerable.Empty<SubscriptionDto>(); }
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
public async Task<SubscriptionDto?> SubscribeAsync(SubscribeRequest request)
|
| 146 |
{
|
| 147 |
var response = await _httpClient.PostAsJsonAsync("api/subscriptions/subscribe", request);
|
| 148 |
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<SubscriptionDto>() : null;
|
| 149 |
}
|
| 150 |
+
|
| 151 |
+
public async Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId)
|
| 152 |
+
{
|
| 153 |
+
try
|
| 154 |
+
{
|
| 155 |
+
return await _httpClient.GetFromJsonAsync<SubscriptionDto>($"api/subscriptions/user/{userId}");
|
| 156 |
+
}
|
| 157 |
+
catch { return null; }
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
public async Task<SubscriptionDto?> AdminSubscribeAsync(AdminSubscribeRequest request)
|
| 161 |
+
{
|
| 162 |
+
var response = await _httpClient.PostAsJsonAsync("api/subscriptions/admin-subscribe", request);
|
| 163 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<SubscriptionDto>() : null;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
public async Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync()
|
| 167 |
+
{
|
| 168 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/all") ?? Enumerable.Empty<SubscriptionDto>();
|
| 169 |
+
}
|
| 170 |
#endregion
|
| 171 |
|
|
|
|
| 172 |
public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
|
| 173 |
{
|
| 174 |
try
|
| 175 |
{
|
| 176 |
+
var response = await _httpClient.GetAsync("api/Loyalty/my-account");
|
| 177 |
+
if (response.IsSuccessStatusCode)
|
| 178 |
+
{
|
| 179 |
+
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
|
| 180 |
+
return await response.Content.ReadFromJsonAsync<LoyaltyAccountDto>(options);
|
| 181 |
+
}
|
| 182 |
+
return null;
|
| 183 |
+
}
|
| 184 |
+
catch
|
| 185 |
+
{
|
| 186 |
+
return null;
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
public async Task<IEnumerable<LoyaltyRedemptionDto>> GetMyRedemptionsAsync()
|
| 191 |
+
{
|
| 192 |
+
try
|
| 193 |
+
{
|
| 194 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>("api/Loyalty/my-redemptions") ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 195 |
+
}
|
| 196 |
+
catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
public async Task<IEnumerable<LoyaltyRedemptionDto>> GetMyPendingRedemptionsAsync()
|
| 200 |
+
{
|
| 201 |
+
try
|
| 202 |
+
{
|
| 203 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>("api/Loyalty/my-pending-redemptions") ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 204 |
+
}
|
| 205 |
+
catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
public async Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId)
|
| 209 |
+
{
|
| 210 |
+
try
|
| 211 |
+
{
|
| 212 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<PointHistoryEntryDto>>("api/Loyalty/my-points-history") ?? Enumerable.Empty<PointHistoryEntryDto>();
|
| 213 |
+
}
|
| 214 |
+
catch { return Enumerable.Empty<PointHistoryEntryDto>(); }
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
public async Task<IEnumerable<UserPointsHistoryDto>> GetAllMembersPointsHistoryAsync()
|
| 218 |
+
{
|
| 219 |
+
try {
|
| 220 |
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
|
| 221 |
+
var response = await _httpClient.GetAsync("api/Loyalty/admin/all-points-history");
|
| 222 |
+
var raw = await response.Content.ReadAsStringAsync();
|
| 223 |
+
if (!response.IsSuccessStatusCode)
|
| 224 |
+
return Enumerable.Empty<UserPointsHistoryDto>();
|
| 225 |
+
|
| 226 |
+
var data = JsonSerializer.Deserialize<List<UserPointsHistoryDto>>(raw, options) ?? new List<UserPointsHistoryDto>();
|
| 227 |
+
foreach (var member in data)
|
| 228 |
+
{
|
| 229 |
+
member.History = member.History?.ToList() ?? new List<PointHistoryEntryDto>();
|
| 230 |
+
member.Redemptions = member.Redemptions?.ToList() ?? new List<LoyaltyRedemptionDto>();
|
| 231 |
+
}
|
| 232 |
+
return data;
|
| 233 |
+
} catch { return Enumerable.Empty<UserPointsHistoryDto>(); }
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
public async Task<bool> RequestReturnAsync(Guid borrowingId)
|
| 237 |
+
{
|
| 238 |
+
var response = await _httpClient.PostAsync($"api/borrowings/return-request/{borrowingId}", null);
|
| 239 |
+
return response.IsSuccessStatusCode;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
public async Task<(bool Success, string Message)> ClaimRewardAsync(string rewardId, string? notes = null)
|
| 243 |
+
{
|
| 244 |
+
try
|
| 245 |
+
{
|
| 246 |
+
var request = new ClaimRewardRequestDto { RewardId = rewardId, Notes = notes };
|
| 247 |
+
var response = await _httpClient.PostAsJsonAsync("api/Loyalty/claim", request);
|
| 248 |
+
|
| 249 |
+
if (response.IsSuccessStatusCode)
|
| 250 |
+
{
|
| 251 |
+
var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 252 |
+
string msg = "Reward claimed successfully!";
|
| 253 |
+
if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
|
| 254 |
+
{
|
| 255 |
+
msg = msgElement.GetString() ?? msg;
|
| 256 |
+
}
|
| 257 |
+
return (true, msg);
|
| 258 |
+
}
|
| 259 |
+
else
|
| 260 |
+
{
|
| 261 |
+
var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 262 |
+
string msg = "Failed to claim reward.";
|
| 263 |
+
if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
|
| 264 |
+
{
|
| 265 |
+
msg = msgElement.GetString() ?? msg;
|
| 266 |
+
}
|
| 267 |
+
return (false, msg);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
catch (Exception ex)
|
| 271 |
+
{
|
| 272 |
+
return (false, $"An error occurred: {ex.Message}");
|
| 273 |
}
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
|
| 277 |
{
|
| 278 |
+
try
|
| 279 |
+
{
|
| 280 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRewardDto>>("api/Loyalty/rewards") ?? Enumerable.Empty<LoyaltyRewardDto>();
|
| 281 |
+
}
|
| 282 |
+
catch { return Enumerable.Empty<LoyaltyRewardDto>(); }
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
#region Users
|
| 286 |
+
public async Task<IEnumerable<UserDto>> GetUsersAsync()
|
| 287 |
+
{
|
| 288 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<UserDto>>("api/users") ?? Enumerable.Empty<UserDto>();
|
| 289 |
}
|
| 290 |
|
| 291 |
+
public async Task<UserDto?> GetUserAsync(Guid id)
|
| 292 |
{
|
| 293 |
+
return await _httpClient.GetFromJsonAsync<UserDto>($"api/users/{id}");
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
public async Task<UserDto?> CreateUserAsync(UserCreateRequest request)
|
| 297 |
+
{
|
| 298 |
+
var response = await _httpClient.PostAsJsonAsync("api/users", request);
|
| 299 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<UserDto>() : null;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
public async Task<UserDto?> UpdateUserAsync(Guid id, UserUpdateRequest request)
|
| 303 |
+
{
|
| 304 |
+
var response = await _httpClient.PutAsJsonAsync($"api/users/{id}", request);
|
| 305 |
+
return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync<UserDto>() : null;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
public async Task<bool> UpdateUserRoleAsync(Guid id, string role)
|
| 309 |
+
{
|
| 310 |
+
var response = await _httpClient.PutAsJsonAsync($"api/users/role/{id}", new UserRoleUpdateRequest { Role = role });
|
| 311 |
+
return response.IsSuccessStatusCode;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
public async Task<bool> DeleteUserAsync(Guid id)
|
| 315 |
+
{
|
| 316 |
+
var response = await _httpClient.DeleteAsync($"api/users/{id}");
|
| 317 |
+
return response.IsSuccessStatusCode;
|
| 318 |
+
}
|
| 319 |
+
#endregion
|
| 320 |
+
|
| 321 |
+
public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
|
| 322 |
+
{
|
| 323 |
+
try
|
| 324 |
+
{
|
| 325 |
+
var response = await _httpClient.GetAsync("api/Loyalty/admin/redemptions/pending");
|
| 326 |
+
if (response.IsSuccessStatusCode)
|
| 327 |
+
{
|
| 328 |
+
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
|
| 329 |
+
return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 330 |
+
}
|
| 331 |
+
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 332 |
+
}
|
| 333 |
+
catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
public async Task<(bool Success, string Message)> FulfillRedemptionAsync(string id)
|
| 337 |
+
{
|
| 338 |
+
try
|
| 339 |
+
{
|
| 340 |
+
var response = await _httpClient.PostAsync($"api/Loyalty/admin/redemptions/{id}/fulfill", null);
|
| 341 |
+
if (response.IsSuccessStatusCode)
|
| 342 |
+
{
|
| 343 |
+
var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 344 |
+
string msg = "Redemption fulfilled successfully!";
|
| 345 |
+
if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
|
| 346 |
+
{
|
| 347 |
+
msg = msgElement.GetString() ?? msg;
|
| 348 |
+
}
|
| 349 |
+
return (true, msg);
|
| 350 |
+
}
|
| 351 |
+
else
|
| 352 |
+
{
|
| 353 |
+
var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 354 |
+
string msg = "Failed to fulfill redemption.";
|
| 355 |
+
if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
|
| 356 |
+
{
|
| 357 |
+
msg = msgElement.GetString() ?? msg;
|
| 358 |
+
}
|
| 359 |
+
return (false, msg);
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
catch (Exception ex) { return (false, $"Error: {ex.Message}"); }
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
#region Notifications
|
| 366 |
+
public async Task<IEnumerable<NotificationDto>> GetNotificationsAsync()
|
| 367 |
+
{
|
| 368 |
+
return await _httpClient.GetFromJsonAsync<IEnumerable<NotificationDto>>("api/notifications") ?? Enumerable.Empty<NotificationDto>();
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
public async Task<int> GetUnreadNotificationCountAsync()
|
| 372 |
+
{
|
| 373 |
+
try
|
| 374 |
+
{
|
| 375 |
+
return await _httpClient.GetFromJsonAsync<int>("api/notifications/unread-count");
|
| 376 |
+
}
|
| 377 |
+
catch { return 0; }
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
public async Task<bool> MarkNotificationsReadAsync()
|
| 381 |
+
{
|
| 382 |
+
var response = await _httpClient.PostAsJsonAsync("api/notifications/mark-read", new { });
|
| 383 |
+
return response.IsSuccessStatusCode;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
public async Task<bool> UpdateFcmTokenAsync(string fcmToken)
|
| 387 |
+
{
|
| 388 |
+
var response = await _httpClient.PostAsJsonAsync("api/users/fcm-token", new { fcmToken });
|
| 389 |
+
return response.IsSuccessStatusCode;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
public async Task<bool> SyncWishlistAsync(List<int> bookIds)
|
| 393 |
+
{
|
| 394 |
+
var response = await _httpClient.PostAsJsonAsync("api/wishlist/sync", bookIds);
|
| 395 |
+
return response.IsSuccessStatusCode;
|
| 396 |
}
|
| 397 |
#endregion
|
| 398 |
}
|
BlazorWebAssembly/Services/WishlistService.cs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using LibraryManagement.Shared.Models;
|
| 2 |
+
using Blazored.LocalStorage;
|
| 3 |
+
using System.Text.Json;
|
| 4 |
+
|
| 5 |
+
namespace BlazorWebAssembly.Services;
|
| 6 |
+
|
| 7 |
+
public class WishlistService
|
| 8 |
+
{
|
| 9 |
+
private readonly ILocalStorageService _localStorage;
|
| 10 |
+
private readonly LibraryApiClient _apiClient;
|
| 11 |
+
private const string WishlistKey = "user_wishlist";
|
| 12 |
+
private const string FavouriteKey = "user_favourites";
|
| 13 |
+
|
| 14 |
+
public WishlistService(ILocalStorageService localStorage, LibraryApiClient apiClient)
|
| 15 |
+
{
|
| 16 |
+
_localStorage = localStorage;
|
| 17 |
+
_apiClient = apiClient;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
public async Task SyncCurrentWishlistAsync()
|
| 21 |
+
{
|
| 22 |
+
var list = await GetWishlistAsync();
|
| 23 |
+
await SyncWithServerAsync(list);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
private async Task SyncWithServerAsync(List<BookDto> list)
|
| 27 |
+
{
|
| 28 |
+
try
|
| 29 |
+
{
|
| 30 |
+
var bookIds = list.Select(b => b.Id).ToList();
|
| 31 |
+
await _apiClient.SyncWishlistAsync(bookIds);
|
| 32 |
+
}
|
| 33 |
+
catch { }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
public async Task<List<BookDto>> GetWishlistAsync()
|
| 37 |
+
{
|
| 38 |
+
try
|
| 39 |
+
{
|
| 40 |
+
var result = await _localStorage.GetItemAsStringAsync(WishlistKey);
|
| 41 |
+
if (!string.IsNullOrWhiteSpace(result))
|
| 42 |
+
{
|
| 43 |
+
var localList = JsonSerializer.Deserialize<List<BookDto>>(result) ?? new List<BookDto>();
|
| 44 |
+
if (!localList.Any()) return localList;
|
| 45 |
+
|
| 46 |
+
// Refresh from server
|
| 47 |
+
var latestList = await _apiClient.GetBooksByIdsAsync(localList.Select(b => b.Id).ToList());
|
| 48 |
+
if (latestList.Any())
|
| 49 |
+
{
|
| 50 |
+
// Update local storage with fresh data
|
| 51 |
+
await _localStorage.SetItemAsStringAsync(WishlistKey, JsonSerializer.Serialize(latestList));
|
| 52 |
+
return latestList;
|
| 53 |
+
}
|
| 54 |
+
return localList;
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
catch { }
|
| 58 |
+
return new List<BookDto>();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
public async Task AddToWishlistAsync(BookDto book)
|
| 62 |
+
{
|
| 63 |
+
var list = await GetWishlistAsync();
|
| 64 |
+
if (!list.Any(b => b.Id == book.Id))
|
| 65 |
+
{
|
| 66 |
+
list.Add(book);
|
| 67 |
+
await _localStorage.SetItemAsStringAsync(WishlistKey, JsonSerializer.Serialize(list));
|
| 68 |
+
await SyncWithServerAsync(list);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
public async Task RemoveFromWishlistAsync(int bookId)
|
| 73 |
+
{
|
| 74 |
+
var list = await GetWishlistAsync();
|
| 75 |
+
var item = list.FirstOrDefault(b => b.Id == bookId);
|
| 76 |
+
if (item != null)
|
| 77 |
+
{
|
| 78 |
+
list.Remove(item);
|
| 79 |
+
await _localStorage.SetItemAsStringAsync(WishlistKey, JsonSerializer.Serialize(list));
|
| 80 |
+
await SyncWithServerAsync(list);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
public async Task<List<BookDto>> GetFavouritesAsync()
|
| 85 |
+
{
|
| 86 |
+
try
|
| 87 |
+
{
|
| 88 |
+
var result = await _localStorage.GetItemAsStringAsync(FavouriteKey);
|
| 89 |
+
if (!string.IsNullOrWhiteSpace(result))
|
| 90 |
+
{
|
| 91 |
+
var localList = JsonSerializer.Deserialize<List<BookDto>>(result) ?? new List<BookDto>();
|
| 92 |
+
if (!localList.Any()) return localList;
|
| 93 |
+
|
| 94 |
+
// Refresh from server
|
| 95 |
+
var latestList = await _apiClient.GetBooksByIdsAsync(localList.Select(b => b.Id).ToList());
|
| 96 |
+
if (latestList.Any())
|
| 97 |
+
{
|
| 98 |
+
// Update local storage with fresh data
|
| 99 |
+
await _localStorage.SetItemAsStringAsync(FavouriteKey, JsonSerializer.Serialize(latestList));
|
| 100 |
+
return latestList;
|
| 101 |
+
}
|
| 102 |
+
return localList;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
catch { }
|
| 106 |
+
return new List<BookDto>();
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
public async Task AddToFavouritesAsync(BookDto book)
|
| 110 |
+
{
|
| 111 |
+
var list = await GetFavouritesAsync();
|
| 112 |
+
if (!list.Any(b => b.Id == book.Id))
|
| 113 |
+
{
|
| 114 |
+
list.Add(book);
|
| 115 |
+
await _localStorage.SetItemAsStringAsync(FavouriteKey, JsonSerializer.Serialize(list));
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
public async Task RemoveFromFavouriteAsync(int bookId)
|
| 120 |
+
{
|
| 121 |
+
var list = await GetFavouritesAsync();
|
| 122 |
+
var item = list.FirstOrDefault(b => b.Id == bookId);
|
| 123 |
+
if (item != null)
|
| 124 |
+
{
|
| 125 |
+
list.Remove(item);
|
| 126 |
+
await _localStorage.SetItemAsStringAsync(FavouriteKey, JsonSerializer.Serialize(list));
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
BlazorWebAssembly/wwwroot/firebase-init.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
|
| 2 |
+
import { getMessaging, getToken, onMessage } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-messaging.js";
|
| 3 |
+
|
| 4 |
+
const firebaseConfig = {
|
| 5 |
+
apiKey: "AIzaSyBodr7GJuCFdJfBYV8XBY77P9A4Wrxz_CA",
|
| 6 |
+
authDomain: "library-ths.firebaseapp.com",
|
| 7 |
+
projectId: "library-ths",
|
| 8 |
+
storageBucket: "library-ths.firebasestorage.app",
|
| 9 |
+
messagingSenderId: "222408896403",
|
| 10 |
+
appId: "1:222408896403:web:8533e26175c043e40a0266",
|
| 11 |
+
measurementId: "G-LVVGH9T1K3"
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const app = initializeApp(firebaseConfig);
|
| 15 |
+
const messaging = getMessaging(app);
|
| 16 |
+
|
| 17 |
+
window.getFcmToken = async () => {
|
| 18 |
+
try {
|
| 19 |
+
const permission = await Notification.requestPermission();
|
| 20 |
+
if (permission === 'granted') {
|
| 21 |
+
// IMPORTANT: Replace 'YOUR_VAPID_KEY' with your actual key from
|
| 22 |
+
// Firebase Console -> Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates
|
| 23 |
+
console.log('Registering service worker...');
|
| 24 |
+
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
| 25 |
+
console.log('Service worker registered successfully:', registration.scope);
|
| 26 |
+
|
| 27 |
+
const token = await getToken(messaging, {
|
| 28 |
+
vapidKey: 'BNgDM2Eq7TPwap0FhF-xv0CK6cS5EfshHOiC8z4916XnewFWYKdAfhwHctsgte5q8jQicQO8PEDVeymlXToasVQ',
|
| 29 |
+
serviceWorkerRegistration: registration
|
| 30 |
+
});
|
| 31 |
+
return token;
|
| 32 |
+
} else {
|
| 33 |
+
throw new Error('Permission not granted for notifications. Current state: ' + permission);
|
| 34 |
+
}
|
| 35 |
+
} catch (error) {
|
| 36 |
+
console.error('Error getting FCM token:', error);
|
| 37 |
+
throw new Error(error.message || error.toString());
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
onMessage(messaging, (payload) => {
|
| 42 |
+
console.log('Foreground Message received: ', payload);
|
| 43 |
+
|
| 44 |
+
const title = payload.notification.title;
|
| 45 |
+
const body = payload.notification.body;
|
| 46 |
+
|
| 47 |
+
if (window.showToast) {
|
| 48 |
+
window.showToast(title + ": " + body, 'success');
|
| 49 |
+
}
|
| 50 |
+
});
|
BlazorWebAssembly/wwwroot/firebase-messaging-sw.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Scripts for firebase and messaging
|
| 2 |
+
// Service worker တွေမှာ import အစား importScripts ကို သုံးပါတယ်
|
| 3 |
+
importScripts('https://www.gstatic.com/firebasejs/11.6.1/firebase-app-compat.js');
|
| 4 |
+
importScripts('https://www.gstatic.com/firebasejs/11.6.1/firebase-messaging-compat.js');
|
| 5 |
+
|
| 6 |
+
// --- Firebase Config ---
|
| 7 |
+
// Step 2 ကနေ ရခဲ့တဲ့ ကျွန်တော်တို့ရဲ့ Firebase Project Configuration ကို ဒီနေရာမှာ ထည့်ပေးပါ
|
| 8 |
+
const firebaseConfig = {
|
| 9 |
+
apiKey: "AIzaSyBodr7GJuCFdJfBYV8XBY77P9A4Wrxz_CA",
|
| 10 |
+
authDomain: "library-ths.firebaseapp.com",
|
| 11 |
+
projectId: "library-ths",
|
| 12 |
+
storageBucket: "library-ths.firebasestorage.app",
|
| 13 |
+
messagingSenderId: "222408896403",
|
| 14 |
+
appId: "1:222408896403:web:8533e26175c043e40a0266",
|
| 15 |
+
measurementId: "G-LVVGH9T1K3"
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// Service worker မှာ Firebase app ကို initialize လုပ်ခြင်း
|
| 19 |
+
firebase.initializeApp(firebaseConfig);
|
| 20 |
+
|
| 21 |
+
// Background မှာ message တွေ လက်ခံနိုင်ဖို့ Firebase Messaging instance ကို ရယူခြင်း
|
| 22 |
+
const messaging = firebase.messaging();
|
| 23 |
+
|
| 24 |
+
// Background မှာရောက်လာတဲ့ message တွေကို ကိုင်တွယ်ဖြေရှင်းမယ့် အပိုင်း
|
| 25 |
+
messaging.onBackgroundMessage(function(payload) {
|
| 26 |
+
console.log('[firebase-messaging-sw.js] Received background message ', payload);
|
| 27 |
+
|
| 28 |
+
const title = payload.notification.title;
|
| 29 |
+
const body = payload.notification.body;
|
| 30 |
+
|
| 31 |
+
const notificationOptions = {
|
| 32 |
+
body: body,
|
| 33 |
+
icon: '/favicon.png'
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
self.registration.showNotification(title, notificationOptions);
|
| 37 |
+
});
|
BlazorWebAssembly/wwwroot/index.html
CHANGED
|
@@ -63,6 +63,14 @@
|
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<script src="_framework/blazor.webassembly.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</body>
|
| 67 |
|
| 68 |
<style>
|
|
|
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<script src="_framework/blazor.webassembly.js"></script>
|
| 66 |
+
|
| 67 |
+
<!-- Firebase Push Notifications -->
|
| 68 |
+
<script type="module" src="firebase-init.js"></script>
|
| 69 |
+
<script>
|
| 70 |
+
window.showToast = function (message, type) {
|
| 71 |
+
alert(message); // simple fallback, can be enhanced with UI toast
|
| 72 |
+
};
|
| 73 |
+
</script>
|
| 74 |
</body>
|
| 75 |
|
| 76 |
<style>
|