Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
b866d20
1
Parent(s): 0acba57
add: books crud, borrowing workflow, member crud, rewards page
Browse files- Backend/Features/Books/BookService.cs +6 -0
- BlazorFrontend/Components/Layout/MainLayout.razor +1 -1
- BlazorFrontend/Components/Pages/Books.razor +454 -50
- BlazorFrontend/Components/Pages/Borrowings.razor +156 -7
- BlazorFrontend/Components/Pages/BorrowingsManage.razor +213 -6
- BlazorFrontend/Components/Pages/Home.razor +208 -44
- BlazorFrontend/Components/Pages/Members.razor +629 -7
- BlazorFrontend/Components/Pages/Membership.razor +315 -7
- BlazorFrontend/Components/Pages/Rewards.razor +289 -6
- BlazorFrontend/Components/Pages/RewardsManage.razor +308 -6
- BlazorFrontend/Models/Dtos/LibraryDtos.cs +3 -0
- BlazorFrontend/Providers/JwtAuthenticationStateProvider.cs +10 -1
- Frontend/Models/Dtos/LibraryDtos.cs +1 -0
- scratch/check_jwt.cs +34 -0
Backend/Features/Books/BookService.cs
CHANGED
|
@@ -61,6 +61,7 @@ namespace Backend.Features.Books
|
|
| 61 |
Description = request.Description,
|
| 62 |
TotalCopies = request.TotalCopies,
|
| 63 |
AvailableCopies = request.TotalCopies,
|
|
|
|
| 64 |
Status = request.TotalCopies > 0 ? "Available" : "Out Of Stock",
|
| 65 |
IsActive = true,
|
| 66 |
CreatedAt = DateTime.UtcNow
|
|
@@ -92,6 +93,7 @@ namespace Backend.Features.Books
|
|
| 92 |
book.Title = request.Title;
|
| 93 |
book.Author = request.Author;
|
| 94 |
book.Description = request.Description;
|
|
|
|
| 95 |
|
| 96 |
// Basic logic to sync availability when total copies change
|
| 97 |
int difference = request.TotalCopies - book.TotalCopies;
|
|
@@ -145,6 +147,7 @@ namespace Backend.Features.Books
|
|
| 145 |
AvailableCopies = book.AvailableCopies,
|
| 146 |
CreatedAt = book.CreatedAt,
|
| 147 |
UpdatedAt = book.UpdatedAt,
|
|
|
|
| 148 |
Categories = book.Categories.Select(c => new BookCategoryDto { Id = c.Id, Name = c.Name }).ToList()
|
| 149 |
};
|
| 150 |
}
|
|
@@ -163,6 +166,7 @@ namespace Backend.Features.Books
|
|
| 163 |
public int AvailableCopies { get; set; }
|
| 164 |
public DateTime CreatedAt { get; set; }
|
| 165 |
public DateTime? UpdatedAt { get; set; }
|
|
|
|
| 166 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 167 |
}
|
| 168 |
|
|
@@ -179,6 +183,7 @@ namespace Backend.Features.Books
|
|
| 179 |
public string Author { get; set; } = string.Empty;
|
| 180 |
public string? Description { get; set; }
|
| 181 |
public int TotalCopies { get; set; }
|
|
|
|
| 182 |
public List<int>? CategoryIds { get; set; }
|
| 183 |
}
|
| 184 |
|
|
@@ -188,6 +193,7 @@ namespace Backend.Features.Books
|
|
| 188 |
public string Author { get; set; } = string.Empty;
|
| 189 |
public string? Description { get; set; }
|
| 190 |
public int TotalCopies { get; set; }
|
|
|
|
| 191 |
public List<int>? CategoryIds { get; set; }
|
| 192 |
}
|
| 193 |
}
|
|
|
|
| 61 |
Description = request.Description,
|
| 62 |
TotalCopies = request.TotalCopies,
|
| 63 |
AvailableCopies = request.TotalCopies,
|
| 64 |
+
CoverUrl = request.CoverUrl,
|
| 65 |
Status = request.TotalCopies > 0 ? "Available" : "Out Of Stock",
|
| 66 |
IsActive = true,
|
| 67 |
CreatedAt = DateTime.UtcNow
|
|
|
|
| 93 |
book.Title = request.Title;
|
| 94 |
book.Author = request.Author;
|
| 95 |
book.Description = request.Description;
|
| 96 |
+
book.CoverUrl = request.CoverUrl;
|
| 97 |
|
| 98 |
// Basic logic to sync availability when total copies change
|
| 99 |
int difference = request.TotalCopies - book.TotalCopies;
|
|
|
|
| 147 |
AvailableCopies = book.AvailableCopies,
|
| 148 |
CreatedAt = book.CreatedAt,
|
| 149 |
UpdatedAt = book.UpdatedAt,
|
| 150 |
+
CoverUrl = book.CoverUrl,
|
| 151 |
Categories = book.Categories.Select(c => new BookCategoryDto { Id = c.Id, Name = c.Name }).ToList()
|
| 152 |
};
|
| 153 |
}
|
|
|
|
| 166 |
public int AvailableCopies { get; set; }
|
| 167 |
public DateTime CreatedAt { get; set; }
|
| 168 |
public DateTime? UpdatedAt { get; set; }
|
| 169 |
+
public string? CoverUrl { get; set; }
|
| 170 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 171 |
}
|
| 172 |
|
|
|
|
| 183 |
public string Author { get; set; } = string.Empty;
|
| 184 |
public string? Description { get; set; }
|
| 185 |
public int TotalCopies { get; set; }
|
| 186 |
+
public string? CoverUrl { get; set; }
|
| 187 |
public List<int>? CategoryIds { get; set; }
|
| 188 |
}
|
| 189 |
|
|
|
|
| 193 |
public string Author { get; set; } = string.Empty;
|
| 194 |
public string? Description { get; set; }
|
| 195 |
public int TotalCopies { get; set; }
|
| 196 |
+
public string? CoverUrl { get; set; }
|
| 197 |
public List<int>? CategoryIds { get; set; }
|
| 198 |
}
|
| 199 |
}
|
BlazorFrontend/Components/Layout/MainLayout.razor
CHANGED
|
@@ -80,7 +80,7 @@
|
|
| 80 |
</div>
|
| 81 |
<div class="flex flex-col">
|
| 82 |
<span class="text-sm font-medium">@authContext.User.Identity?.Name</span>
|
| 83 |
-
<span class="text-xs text-slate-500">@authContext.User.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Role)?.Value</span>
|
| 84 |
</div>
|
| 85 |
</div>
|
| 86 |
</Authorized>
|
|
|
|
| 80 |
</div>
|
| 81 |
<div class="flex flex-col">
|
| 82 |
<span class="text-sm font-medium">@authContext.User.Identity?.Name</span>
|
| 83 |
+
<span class="text-xs text-slate-500">@authContext.User.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.Role || c.Type == "role")?.Value</span>
|
| 84 |
</div>
|
| 85 |
</div>
|
| 86 |
</Authorized>
|
BlazorFrontend/Components/Pages/Books.razor
CHANGED
|
@@ -1,88 +1,492 @@
|
|
| 1 |
@page "/Books"
|
| 2 |
@using BlazorFrontend.Services
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
-
@
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
<PageTitle>Books Catalog</PageTitle>
|
| 8 |
|
| 9 |
-
|
| 10 |
-
<h1 class="text-2xl font-bold text-slate-900">Books Catalog</h1>
|
| 11 |
-
<p class="text-sm text-slate-500 mt-1">Browse and search our collection of books.</p>
|
| 12 |
-
</div>
|
| 13 |
-
|
| 14 |
-
@if (_books == null)
|
| 15 |
{
|
| 16 |
-
<div class="
|
| 17 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
{
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
{
|
| 38 |
-
<
|
| 39 |
-
@string.Join(", ", book.Categories.Select(c => c.Name))
|
| 40 |
-
</span>
|
| 41 |
}
|
| 42 |
-
|
| 43 |
{
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
}
|
| 48 |
-
|
|
|
|
| 49 |
{
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
</
|
| 53 |
}
|
| 54 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
<AuthorizeView>
|
| 58 |
-
<Authorized>
|
| 59 |
-
<button class="w-full btn-primary py-2" disabled="@(book.AvailableCopies <= 0)">
|
| 60 |
-
Borrow Book
|
| 61 |
-
</button>
|
| 62 |
-
</Authorized>
|
| 63 |
<NotAuthorized>
|
| 64 |
-
<a href="/login" class="
|
| 65 |
</NotAuthorized>
|
| 66 |
</AuthorizeView>
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
}
|
| 72 |
|
| 73 |
@code {
|
| 74 |
-
private
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
protected override async Task OnInitializedAsync()
|
| 77 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
try
|
| 79 |
{
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
catch (Exception ex)
|
| 83 |
{
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
|
|
|
| 1 |
@page "/Books"
|
| 2 |
@using BlazorFrontend.Services
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@inject NavigationManager Navigation
|
| 8 |
|
| 9 |
<PageTitle>Books Catalog</PageTitle>
|
| 10 |
|
| 11 |
+
@if (_viewMode == ViewMode.List)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
{
|
| 13 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 14 |
+
<div>
|
| 15 |
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900">Books Catalog</h1>
|
| 16 |
+
<p class="mt-2 text-slate-500">
|
| 17 |
+
Explore and discover your next great read.
|
| 18 |
+
<span class="ml-2 text-xs text-slate-400">(@FilteredBooks.Count() total)</span>
|
| 19 |
+
</p>
|
| 20 |
+
</div>
|
| 21 |
+
<AuthorizeView Roles="Librarian">
|
| 22 |
+
<div class="flex items-center gap-3">
|
| 23 |
+
<button @onclick="ShowCreate" class="btn-primary">
|
| 24 |
+
<svg class="w-4 h-4 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 New Book
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
</AuthorizeView>
|
| 29 |
</div>
|
| 30 |
+
|
| 31 |
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 animate-in fade-in duration-700">
|
| 32 |
+
<div class="md:col-span-3">
|
| 33 |
+
<div class="relative">
|
| 34 |
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
| 35 |
+
<svg class="h-4 w-4 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
| 36 |
+
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
| 37 |
+
</svg>
|
| 38 |
+
</div>
|
| 39 |
+
<input type="text" @bind="_searchQuery" @bind:event="oninput"
|
| 40 |
+
class="block w-full pl-10 pr-3 py-2 border border-slate-200 rounded-lg bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent sm:text-sm shadow-sm"
|
| 41 |
+
placeholder="Search by title, author or ISBN...">
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
</div>
|
| 45 |
+
|
| 46 |
+
@if (_categories.Any())
|
| 47 |
+
{
|
| 48 |
+
<div class="flex flex-wrap items-center gap-2 mb-8 animate-in fade-in duration-700">
|
| 49 |
+
<span class="text-xs font-semibold text-slate-400 uppercase tracking-wider mr-1">Filter:</span>
|
| 50 |
+
<button @onclick="() => FilterByCategory(null)"
|
| 51 |
+
class='inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border transition-colors @(_selectedCategoryId == null ? "bg-slate-900 text-white border-slate-900" : "bg-white text-slate-600 border-slate-200 hover:border-slate-400 hover:text-slate-900")'>
|
| 52 |
+
All
|
| 53 |
+
</button>
|
| 54 |
+
@foreach (var cat in _categories)
|
| 55 |
+
{
|
| 56 |
+
<button @onclick="() => FilterByCategory(cat.Id)"
|
| 57 |
+
class='inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border transition-colors @(_selectedCategoryId == cat.Id ? "bg-slate-900 text-white border-slate-900" : "bg-white text-slate-600 border-slate-200 hover:border-slate-400 hover:text-slate-900")'>
|
| 58 |
+
@cat.Name
|
| 59 |
+
</button>
|
| 60 |
+
}
|
| 61 |
+
</div>
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
@if (_isLoading)
|
| 65 |
+
{
|
| 66 |
+
<div class="py-20 flex justify-center">
|
| 67 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-slate-900"></div>
|
| 68 |
+
</div>
|
| 69 |
+
}
|
| 70 |
+
else if (!FilteredBooks.Any())
|
| 71 |
+
{
|
| 72 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200 animate-in fade-in duration-500">
|
| 73 |
+
<svg class="w-16 h-16 mx-auto text-slate-300 mb-4" 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>
|
| 74 |
+
<p class="text-slate-500 font-medium">No books found matching your criteria.</p>
|
| 75 |
+
</div>
|
| 76 |
+
}
|
| 77 |
+
else
|
| 78 |
+
{
|
| 79 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 gap-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 80 |
+
@foreach (var book in FilteredBooks)
|
| 81 |
+
{
|
| 82 |
+
<div class="group card hover:shadow-lg transition-all duration-300 flex flex-col h-full">
|
| 83 |
+
<div @onclick="() => ShowDetails(book.Id)" class="relative aspect-[2/3] w-full bg-slate-100 flex items-center justify-center text-slate-300 overflow-hidden text-center cursor-pointer group-hover:scale-105 transition-transform duration-500">
|
| 84 |
+
@if (!string.IsNullOrWhiteSpace(book.CoverUrl))
|
| 85 |
{
|
| 86 |
+
<img src="@book.CoverUrl" alt="@book.Title" class="absolute inset-0 w-full h-full object-cover" />
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
+
else
|
| 89 |
{
|
| 90 |
+
<div class="flex flex-col items-center gap-4 p-4">
|
| 91 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" 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>
|
| 92 |
+
<span class="text-[10px] font-bold text-slate-400">@book.Isbn</span>
|
| 93 |
+
</div>
|
| 94 |
}
|
| 95 |
+
|
| 96 |
+
@if (book.AvailableCopies <= 0)
|
| 97 |
{
|
| 98 |
+
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-[2px] flex items-center justify-center">
|
| 99 |
+
<span class="bg-white text-slate-900 text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded">Out of Stock</span>
|
| 100 |
+
</div>
|
| 101 |
}
|
| 102 |
</div>
|
| 103 |
+
<div class="p-4 flex-grow flex flex-col">
|
| 104 |
+
<div class="flex items-start justify-between gap-2">
|
| 105 |
+
<div class="min-w-0">
|
| 106 |
+
<h3 @onclick="() => ShowDetails(book.Id)" class="text-sm font-bold text-slate-900 truncate group-hover:text-amber-600 transition-colors cursor-pointer">
|
| 107 |
+
@book.Title
|
| 108 |
+
</h3>
|
| 109 |
+
<p class="text-xs text-slate-500 mt-0.5 truncate">@book.Author</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
@if (book.Categories.Any())
|
| 113 |
+
{
|
| 114 |
+
<div class="mt-2 flex flex-wrap gap-1">
|
| 115 |
+
@foreach (var cat in book.Categories)
|
| 116 |
+
{
|
| 117 |
+
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2 py-0.5 text-[9px] font-semibold text-amber-700">
|
| 118 |
+
@cat.Name
|
| 119 |
+
</span>
|
| 120 |
+
}
|
| 121 |
+
</div>
|
| 122 |
+
}
|
| 123 |
+
<div class="mt-3 flex items-center justify-between">
|
| 124 |
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">@book.Status</span>
|
| 125 |
+
<span class="text-[10px] font-medium @(book.AvailableCopies > 0 ? "text-emerald-600" : "text-rose-600")">
|
| 126 |
+
@book.AvailableCopies / @book.TotalCopies
|
| 127 |
+
</span>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="mt-auto pt-4 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 130 |
+
<button @onclick="() => ShowDetails(book.Id)" class="flex-1 btn-secondary py-1 text-[11px]">Details</button>
|
| 131 |
+
<AuthorizeView Roles="Librarian">
|
| 132 |
+
<button @onclick="() => ShowEdit(book.Id)" class="p-1.5 rounded-md border border-slate-200 hover:bg-slate-50 text-slate-600">
|
| 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="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>
|
| 134 |
+
</button>
|
| 135 |
+
</AuthorizeView>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
</div>
|
| 139 |
+
}
|
| 140 |
+
</div>
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
else if (_viewMode == ViewMode.Details && _selectedBook != null)
|
| 144 |
+
{
|
| 145 |
+
<div class="mb-8 animate-in fade-in slide-in-from-left-4 duration-500">
|
| 146 |
+
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-slate-500 hover:text-slate-900 mb-6 group">
|
| 147 |
+
<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>
|
| 148 |
+
Back to Catalog
|
| 149 |
+
</button>
|
| 150 |
+
|
| 151 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 152 |
+
<div class="lg:col-span-1">
|
| 153 |
+
<div class="aspect-[2/3] w-full rounded-2xl bg-slate-100 border border-slate-200 shadow-sm flex items-center justify-center text-slate-300 overflow-hidden">
|
| 154 |
+
@if (!string.IsNullOrWhiteSpace(_selectedBook.CoverUrl))
|
| 155 |
+
{
|
| 156 |
+
<img src="@_selectedBook.CoverUrl" alt="@_selectedBook.Title" class="w-full h-full object-cover" />
|
| 157 |
+
}
|
| 158 |
+
else
|
| 159 |
+
{
|
| 160 |
+
<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>
|
| 161 |
+
}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="lg:col-span-2 space-y-8">
|
| 166 |
+
<div>
|
| 167 |
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 mb-4 tracking-wide uppercase">@_selectedBook.Status</span>
|
| 168 |
+
<h1 class="text-4xl font-extrabold text-slate-900 tracking-tight">@_selectedBook.Title</h1>
|
| 169 |
+
<p class="text-xl text-slate-500 mt-2 font-medium">by @_selectedBook.Author</p>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
<div class="prose prose-slate max-w-none">
|
| 173 |
+
<h3 class="text-lg font-bold text-slate-900 border-b border-slate-100 pb-2 mb-4 uppercase tracking-wider text-sm">Description</h3>
|
| 174 |
+
<p class="text-slate-600 leading-relaxed">
|
| 175 |
+
@(string.IsNullOrEmpty(_selectedBook.Description) ? "No description available for this book." : _selectedBook.Description)
|
| 176 |
+
</p>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div class="grid grid-cols-2 gap-8 border-y border-slate-100 py-6">
|
| 180 |
+
<div>
|
| 181 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">ISBN</h4>
|
| 182 |
+
<p class="text-sm font-semibold text-slate-900">@_selectedBook.Isbn</p>
|
| 183 |
+
</div>
|
| 184 |
+
<div>
|
| 185 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Availability</h4>
|
| 186 |
+
<p class="text-sm font-semibold @(_selectedBook.AvailableCopies > 0 ? "text-emerald-600" : "text-rose-600")">
|
| 187 |
+
@(_selectedBook.AvailableCopies > 0 ? $"{_selectedBook.AvailableCopies} / {_selectedBook.TotalCopies} units in stock" : "Currently occupied")
|
| 188 |
+
</p>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<div class="flex items-center gap-4 pt-4">
|
| 193 |
+
<AuthorizeView Roles="Member">
|
| 194 |
+
@if (_selectedBook.AvailableCopies > 0)
|
| 195 |
+
{
|
| 196 |
+
<button @onclick="() => BorrowAsync(_selectedBook.Id)" class="btn-primary py-3 px-8 text-base shadow-lg shadow-slate-200">Borrow This Book</button>
|
| 197 |
+
}
|
| 198 |
+
else
|
| 199 |
+
{
|
| 200 |
+
<button disabled class="btn-primary py-3 px-8 text-base opacity-50 cursor-not-allowed">Out of Stock</button>
|
| 201 |
+
}
|
| 202 |
+
</AuthorizeView>
|
| 203 |
+
|
| 204 |
+
<AuthorizeView Roles="Librarian">
|
| 205 |
+
<button @onclick="() => ShowEdit(_selectedBook.Id)" class="btn-secondary py-3 px-8 text-base">Edit Details</button>
|
| 206 |
+
<button @onclick="() => DeleteAsync(_selectedBook.Id)" class="p-3 text-rose-600 hover:text-rose-700 hover:bg-rose-50 rounded-xl transition-colors">
|
| 207 |
+
<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>
|
| 208 |
+
</button>
|
| 209 |
+
</AuthorizeView>
|
| 210 |
+
|
| 211 |
<AuthorizeView>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
<NotAuthorized>
|
| 213 |
+
<a href="/login" class="btn-primary py-3 px-8 text-base">Sign In to Borrow</a>
|
| 214 |
</NotAuthorized>
|
| 215 |
</AuthorizeView>
|
| 216 |
</div>
|
| 217 |
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
}
|
| 221 |
+
else if (_viewMode == ViewMode.Create || _viewMode == ViewMode.Edit)
|
| 222 |
+
{
|
| 223 |
+
<div class="mb-8 animate-in fade-in slide-in-from-right-4 duration-500">
|
| 224 |
+
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-slate-500 hover:text-slate-900 mb-6 group">
|
| 225 |
+
<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>
|
| 226 |
+
Back to Catalog
|
| 227 |
+
</button>
|
| 228 |
+
|
| 229 |
+
<div class="max-w-3xl mx-auto">
|
| 230 |
+
<div class="mb-8">
|
| 231 |
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900">@(_viewMode == ViewMode.Create ? "Add New Book" : "Edit Book")</h1>
|
| 232 |
+
<p class="mt-2 text-slate-500">Fill in the details below to @(_viewMode == ViewMode.Create ? "add a new book to" : "update the") library catalog.</p>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<div class="card p-6 sm:p-8">
|
| 236 |
+
<EditForm Model="@_formModel" OnValidSubmit="HandleSubmit" class="space-y-6">
|
| 237 |
+
<DataAnnotationsValidator />
|
| 238 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 239 |
+
<div class="sm:col-span-2">
|
| 240 |
+
<label class="block text-sm font-medium text-slate-700">Book Title <span class="text-rose-500">*</span></label>
|
| 241 |
+
<InputText @bind-Value="_formModel.Title" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. The Great Gatsby" required />
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<div>
|
| 245 |
+
<label class="block text-sm font-medium text-slate-700">Author <span class="text-rose-500">*</span></label>
|
| 246 |
+
<InputText @bind-Value="_formModel.Author" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. F. Scott Fitzgerald" required />
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div>
|
| 250 |
+
<label class="block text-sm font-medium text-slate-700">ISBN <span class="text-rose-500">*</span></label>
|
| 251 |
+
<InputText @bind-Value="_formModel.Isbn" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. 978-0743273565" required />
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
<div>
|
| 255 |
+
<label class="block text-sm font-medium text-slate-700">Total Copies <span class="text-rose-500">*</span></label>
|
| 256 |
+
<InputNumber @bind-Value="_formModel.TotalCopies" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="1" required />
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div>
|
| 260 |
+
<label class="block text-sm font-medium text-slate-700">Cover Image URL</label>
|
| 261 |
+
<InputText @bind-Value="_formModel.CoverUrl" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="https://example.com/cover.jpg" />
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div class="hidden sm:block"></div>
|
| 265 |
+
|
| 266 |
+
<div class="sm:col-span-2">
|
| 267 |
+
<label class="block text-sm font-medium text-slate-700">Description</label>
|
| 268 |
+
<InputTextArea @bind-Value="_formModel.Description" rows="4" class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="Brief summary of the book..."></InputTextArea>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<div class="sm:col-span-2">
|
| 272 |
+
<label class="block text-sm font-medium text-slate-700 mb-2">Categories</label>
|
| 273 |
+
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
| 274 |
+
@foreach (var cat in _categories)
|
| 275 |
+
{
|
| 276 |
+
<label class="flex items-center gap-2 px-3 py-2 rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer text-sm text-slate-700 transition-colors">
|
| 277 |
+
<input type="checkbox" checked="@(_formModel.CategoryIds.Contains(cat.Id))"
|
| 278 |
+
@onchange='(e) => ToggleCategory(cat.Id, (bool)e.Value!)'
|
| 279 |
+
class="h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900" />
|
| 280 |
+
@cat.Name
|
| 281 |
+
</label>
|
| 282 |
+
}
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<div class="pt-5 border-t border-slate-100 flex justify-end gap-3">
|
| 288 |
+
<button type="button" @onclick="ShowList" class="btn-secondary">Cancel</button>
|
| 289 |
+
<button type="submit" class="btn-primary" disabled="@_isSubmitting">
|
| 290 |
+
@(_isSubmitting ? "Saving..." : (_viewMode == ViewMode.Create ? "Add Book" : "Update Book"))
|
| 291 |
+
</button>
|
| 292 |
+
</div>
|
| 293 |
+
</EditForm>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
</div>
|
| 297 |
}
|
| 298 |
|
| 299 |
@code {
|
| 300 |
+
private enum ViewMode { List, Details, Create, Edit }
|
| 301 |
+
private ViewMode _viewMode = ViewMode.List;
|
| 302 |
+
private bool _isLoading = true;
|
| 303 |
+
private bool _isSubmitting = false;
|
| 304 |
+
private string _searchQuery = "";
|
| 305 |
+
private int? _selectedCategoryId;
|
| 306 |
+
|
| 307 |
+
private IEnumerable<BookDto> _books = Enumerable.Empty<BookDto>();
|
| 308 |
+
private IEnumerable<CategoryDto> _categories = Enumerable.Empty<CategoryDto>();
|
| 309 |
+
private BookDto? _selectedBook;
|
| 310 |
+
|
| 311 |
+
// Form Model
|
| 312 |
+
private BookFormModel _formModel = new();
|
| 313 |
|
| 314 |
protected override async Task OnInitializedAsync()
|
| 315 |
{
|
| 316 |
+
await LoadDataAsync();
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
private async Task LoadDataAsync()
|
| 320 |
+
{
|
| 321 |
+
_isLoading = true;
|
| 322 |
+
try
|
| 323 |
+
{
|
| 324 |
+
_books = await ApiClient.GetBooksAsync();
|
| 325 |
+
_categories = await ApiClient.GetCategoriesAsync();
|
| 326 |
+
}
|
| 327 |
+
catch (Exception ex)
|
| 328 |
+
{
|
| 329 |
+
Console.WriteLine($"Error: {ex.Message}");
|
| 330 |
+
}
|
| 331 |
+
finally
|
| 332 |
+
{
|
| 333 |
+
_isLoading = false;
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
private IEnumerable<BookDto> FilteredBooks => _books
|
| 338 |
+
.Where(b => (string.IsNullOrWhiteSpace(_searchQuery) ||
|
| 339 |
+
b.Title.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 340 |
+
b.Author.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 341 |
+
b.Isbn.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase)) &&
|
| 342 |
+
(_selectedCategoryId == null || b.Categories.Any(c => c.Id == _selectedCategoryId)));
|
| 343 |
+
|
| 344 |
+
private void FilterByCategory(int? id) => _selectedCategoryId = id;
|
| 345 |
+
|
| 346 |
+
private void ShowList()
|
| 347 |
+
{
|
| 348 |
+
_viewMode = ViewMode.List;
|
| 349 |
+
_selectedBook = null;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
private async Task ShowDetails(int id)
|
| 353 |
+
{
|
| 354 |
+
_isLoading = true;
|
| 355 |
+
_selectedBook = await ApiClient.GetBookAsync(id);
|
| 356 |
+
_viewMode = ViewMode.Details;
|
| 357 |
+
_isLoading = false;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
private void ShowCreate()
|
| 361 |
+
{
|
| 362 |
+
_formModel = new BookFormModel();
|
| 363 |
+
_viewMode = ViewMode.Create;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
private async Task ShowEdit(int id)
|
| 367 |
+
{
|
| 368 |
+
_isLoading = true;
|
| 369 |
+
var book = await ApiClient.GetBookAsync(id);
|
| 370 |
+
if (book != null)
|
| 371 |
+
{
|
| 372 |
+
_selectedBook = book;
|
| 373 |
+
_formModel = new BookFormModel
|
| 374 |
+
{
|
| 375 |
+
Id = book.Id,
|
| 376 |
+
Title = book.Title,
|
| 377 |
+
Author = book.Author,
|
| 378 |
+
Isbn = book.Isbn,
|
| 379 |
+
Description = book.Description,
|
| 380 |
+
TotalCopies = book.TotalCopies,
|
| 381 |
+
CoverUrl = book.CoverUrl,
|
| 382 |
+
CategoryIds = book.Categories.Select(c => c.Id).ToList()
|
| 383 |
+
};
|
| 384 |
+
_viewMode = ViewMode.Edit;
|
| 385 |
+
}
|
| 386 |
+
_isLoading = false;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
private void ToggleCategory(int id, bool isChecked)
|
| 390 |
+
{
|
| 391 |
+
if (isChecked && !_formModel.CategoryIds.Contains(id))
|
| 392 |
+
_formModel.CategoryIds.Add(id);
|
| 393 |
+
else if (!isChecked)
|
| 394 |
+
_formModel.CategoryIds.Remove(id);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
private async Task HandleSubmit()
|
| 398 |
+
{
|
| 399 |
+
_isSubmitting = true;
|
| 400 |
try
|
| 401 |
{
|
| 402 |
+
if (_viewMode == ViewMode.Create)
|
| 403 |
+
{
|
| 404 |
+
var request = new BookCreateRequest
|
| 405 |
+
{
|
| 406 |
+
Title = _formModel.Title,
|
| 407 |
+
Author = _formModel.Author,
|
| 408 |
+
Isbn = _formModel.Isbn,
|
| 409 |
+
Description = _formModel.Description,
|
| 410 |
+
TotalCopies = _formModel.TotalCopies,
|
| 411 |
+
CoverUrl = _formModel.CoverUrl,
|
| 412 |
+
CategoryIds = _formModel.CategoryIds
|
| 413 |
+
};
|
| 414 |
+
var result = await ApiClient.CreateBookAsync(request);
|
| 415 |
+
if (result != null)
|
| 416 |
+
{
|
| 417 |
+
await JS.InvokeVoidAsync("showToast", "Book added successfully!", "success");
|
| 418 |
+
await LoadDataAsync();
|
| 419 |
+
ShowList();
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
else
|
| 423 |
+
{
|
| 424 |
+
var request = new BookUpdateRequest
|
| 425 |
+
{
|
| 426 |
+
Title = _formModel.Title,
|
| 427 |
+
Author = _formModel.Author,
|
| 428 |
+
Description = _formModel.Description,
|
| 429 |
+
TotalCopies = _formModel.TotalCopies,
|
| 430 |
+
CoverUrl = _formModel.CoverUrl,
|
| 431 |
+
CategoryIds = _formModel.CategoryIds
|
| 432 |
+
};
|
| 433 |
+
var result = await ApiClient.UpdateBookAsync(_formModel.Id, request);
|
| 434 |
+
if (result != null)
|
| 435 |
+
{
|
| 436 |
+
await JS.InvokeVoidAsync("showToast", "Book updated successfully!", "success");
|
| 437 |
+
await LoadDataAsync();
|
| 438 |
+
ShowList();
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
}
|
| 442 |
catch (Exception ex)
|
| 443 |
{
|
| 444 |
+
await JS.InvokeVoidAsync("showToast", $"Error: {ex.Message}", "error");
|
| 445 |
+
}
|
| 446 |
+
finally
|
| 447 |
+
{
|
| 448 |
+
_isSubmitting = false;
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
private async Task DeleteAsync(int id)
|
| 453 |
+
{
|
| 454 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to remove this book from the catalog?");
|
| 455 |
+
if (!confirmed) return;
|
| 456 |
+
|
| 457 |
+
var success = await ApiClient.DeleteBookAsync(id);
|
| 458 |
+
if (success)
|
| 459 |
+
{
|
| 460 |
+
await JS.InvokeVoidAsync("showToast", "Book deleted successfully", "success");
|
| 461 |
+
await LoadDataAsync();
|
| 462 |
+
ShowList();
|
| 463 |
}
|
| 464 |
}
|
| 465 |
+
|
| 466 |
+
private async Task BorrowAsync(int id)
|
| 467 |
+
{
|
| 468 |
+
var result = await ApiClient.BorrowBookAsync(new BorrowRequest { BookId = id });
|
| 469 |
+
if (result != null)
|
| 470 |
+
{
|
| 471 |
+
await JS.InvokeVoidAsync("showToast", "Book borrowed successfully! Check 'My Loans' for details.", "success");
|
| 472 |
+
await LoadDataAsync();
|
| 473 |
+
await ShowDetails(id);
|
| 474 |
+
}
|
| 475 |
+
else
|
| 476 |
+
{
|
| 477 |
+
await JS.InvokeVoidAsync("showToast", "Failed to borrow book. Check your loan limit.", "error");
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
private class BookFormModel
|
| 482 |
+
{
|
| 483 |
+
public int Id { get; set; }
|
| 484 |
+
public string Title { get; set; } = "";
|
| 485 |
+
public string Author { get; set; } = "";
|
| 486 |
+
public string Isbn { get; set; } = "";
|
| 487 |
+
public string? Description { get; set; }
|
| 488 |
+
public int TotalCopies { get; set; } = 1;
|
| 489 |
+
public string? CoverUrl { get; set; }
|
| 490 |
+
public List<int> CategoryIds { get; set; } = new();
|
| 491 |
+
}
|
| 492 |
}
|
BlazorFrontend/Components/Pages/Borrowings.razor
CHANGED
|
@@ -1,12 +1,161 @@
|
|
| 1 |
@page "/Borrowings"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
<PageTitle>
|
| 4 |
|
| 5 |
-
<div class="mb-
|
| 6 |
-
<h1 class="text-
|
| 7 |
-
<p class="
|
| 8 |
</div>
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@page "/Borrowings"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@inject LibraryApiClient ApiClient
|
| 5 |
+
@inject IJSRuntime JS
|
| 6 |
|
| 7 |
+
<PageTitle>My Loans</PageTitle>
|
| 8 |
|
| 9 |
+
<div class="mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 10 |
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900">My Loans</h1>
|
| 11 |
+
<p class="mt-2 text-slate-500">Track your current borrowings and return upcoming books.</p>
|
| 12 |
</div>
|
| 13 |
|
| 14 |
+
@if (_isLoading)
|
| 15 |
+
{
|
| 16 |
+
<div class="py-20 flex justify-center">
|
| 17 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-slate-900"></div>
|
| 18 |
+
</div>
|
| 19 |
+
}
|
| 20 |
+
else
|
| 21 |
+
{
|
| 22 |
+
<div class="card overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 23 |
+
<div class="overflow-x-auto">
|
| 24 |
+
<table class="w-full text-left border-collapse">
|
| 25 |
+
<thead>
|
| 26 |
+
<tr class="bg-slate-50/50 border-b border-slate-100">
|
| 27 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Book Information</th>
|
| 28 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Dates</th>
|
| 29 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Status</th>
|
| 30 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Fines</th>
|
| 31 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
| 32 |
+
</tr>
|
| 33 |
+
</thead>
|
| 34 |
+
<tbody class="divide-y divide-slate-100">
|
| 35 |
+
@if (!_borrowings.Any())
|
| 36 |
+
{
|
| 37 |
+
<tr>
|
| 38 |
+
<td colspan="5" class="px-6 py-12 text-center text-slate-400 italic">
|
| 39 |
+
You don't have any borrowing history yet.
|
| 40 |
+
</td>
|
| 41 |
+
</tr>
|
| 42 |
+
}
|
| 43 |
+
@foreach (var loan in _borrowings)
|
| 44 |
+
{
|
| 45 |
+
<tr class="hover:bg-slate-50 transition-colors group">
|
| 46 |
+
<td class="px-6 py-4">
|
| 47 |
+
<div class="flex items-center gap-3">
|
| 48 |
+
<div class="w-10 h-14 bg-slate-100 rounded flex items-center justify-center text-slate-300 group-hover:bg-slate-200 transition-colors">
|
| 49 |
+
<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>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="min-w-0">
|
| 52 |
+
<p class="text-sm font-bold text-slate-900 truncate">@loan.BookTitle</p>
|
| 53 |
+
<p class="text-[11px] text-slate-500 mt-0.5">Loan ID: #@loan.Id</p>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
</td>
|
| 57 |
+
<td class="px-6 py-4">
|
| 58 |
+
<div class="space-y-1">
|
| 59 |
+
<p class="text-[11px]"><span class="text-slate-400">Borrowed:</span> <span class="font-medium text-slate-700">@loan.BorrowDate.ToString("MMM dd, yyyy")</span></p>
|
| 60 |
+
<p class="text-[11px]"><span class="text-slate-400">Due:</span> <span class="font-medium @(loan.DueDate < DateTime.Now && loan.Status == "Borrowed" ? "text-rose-600" : "text-slate-700")">@loan.DueDate.ToString("MMM dd, yyyy")</span></p>
|
| 61 |
+
@if (loan.ReturnDate.HasValue)
|
| 62 |
+
{
|
| 63 |
+
<p class="text-[11px]"><span class="text-slate-400">Returned:</span> <span class="font-medium text-emerald-600">@loan.ReturnDate.Value.ToString("MMM dd, yyyy")</span></p>
|
| 64 |
+
}
|
| 65 |
+
</div>
|
| 66 |
+
</td>
|
| 67 |
+
<td class="px-6 py-4">
|
| 68 |
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
| 69 |
+
@(loan.Status == "Borrowed" ? "bg-amber-100 text-amber-700" :
|
| 70 |
+
loan.Status == "PendingReturn" ? "bg-blue-100 text-blue-700" :
|
| 71 |
+
"bg-emerald-100 text-emerald-700")">
|
| 72 |
+
@(loan.Status == "PendingReturn" ? "Pending Approval" : loan.Status)
|
| 73 |
+
</span>
|
| 74 |
+
</td>
|
| 75 |
+
<td class="px-6 py-4">
|
| 76 |
+
@if (loan.FineAmount > 0)
|
| 77 |
+
{
|
| 78 |
+
<div class="flex flex-col">
|
| 79 |
+
<span class="text-xs font-bold @(loan.IsFinePaid ? "text-slate-500 line-through" : "text-rose-600")">
|
| 80 |
+
MMK @loan.FineAmount.ToString("N0")
|
| 81 |
+
</span>
|
| 82 |
+
@if (loan.IsFinePaid)
|
| 83 |
+
{
|
| 84 |
+
<span class="text-[10px] text-emerald-600 font-medium">Paid</span>
|
| 85 |
+
}
|
| 86 |
+
</div>
|
| 87 |
+
}
|
| 88 |
+
else
|
| 89 |
+
{
|
| 90 |
+
<span class="text-[11px] text-slate-400">No Fines</span>
|
| 91 |
+
}
|
| 92 |
+
</td>
|
| 93 |
+
<td class="px-6 py-4 text-right">
|
| 94 |
+
@if (loan.Status == "Borrowed")
|
| 95 |
+
{
|
| 96 |
+
<button @onclick="() => RequestReturnAsync(loan)"
|
| 97 |
+
class="bg-slate-900 text-white py-1.5 px-4 rounded-xl text-xs font-bold hover:bg-slate-800 transition-all active:scale-95 shadow-sm">
|
| 98 |
+
Return Book
|
| 99 |
+
</button>
|
| 100 |
+
}
|
| 101 |
+
else if (loan.Status == "PendingReturn")
|
| 102 |
+
{
|
| 103 |
+
<span class="text-xs font-medium text-slate-400 italic">Awaiting Approval</span>
|
| 104 |
+
}
|
| 105 |
+
else
|
| 106 |
+
{
|
| 107 |
+
<svg class="w-5 h-5 ml-auto text-emerald-500" 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>
|
| 108 |
+
}
|
| 109 |
+
</td>
|
| 110 |
+
</tr>
|
| 111 |
+
}
|
| 112 |
+
</tbody>
|
| 113 |
+
</table>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
@code {
|
| 119 |
+
private bool _isLoading = true;
|
| 120 |
+
private IEnumerable<BorrowingDto> _borrowings = Enumerable.Empty<BorrowingDto>();
|
| 121 |
+
|
| 122 |
+
protected override async Task OnInitializedAsync()
|
| 123 |
+
{
|
| 124 |
+
await LoadDataAsync();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
private async Task LoadDataAsync()
|
| 128 |
+
{
|
| 129 |
+
_isLoading = true;
|
| 130 |
+
try
|
| 131 |
+
{
|
| 132 |
+
_borrowings = await ApiClient.GetMyBorrowingsAsync();
|
| 133 |
+
}
|
| 134 |
+
catch (Exception ex)
|
| 135 |
+
{
|
| 136 |
+
Console.WriteLine($"Error loading borrowings: {ex.Message}");
|
| 137 |
+
}
|
| 138 |
+
finally
|
| 139 |
+
{
|
| 140 |
+
_isLoading = false;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
private async Task RequestReturnAsync(BorrowingDto loan)
|
| 145 |
+
{
|
| 146 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Request return for \"{loan.BookTitle}\"? It will be marked as pending until approved by a librarian.");
|
| 147 |
+
if (!confirmed) return;
|
| 148 |
+
|
| 149 |
+
var success = await ApiClient.RequestReturnAsync(loan.Id);
|
| 150 |
+
if (success)
|
| 151 |
+
{
|
| 152 |
+
await JS.InvokeVoidAsync("showToast", "Return request submitted successfully!", "success");
|
| 153 |
+
await LoadDataAsync();
|
| 154 |
+
}
|
| 155 |
+
else
|
| 156 |
+
{
|
| 157 |
+
await JS.InvokeVoidAsync("showToast", "Failed to submit return request.", "error");
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
BlazorFrontend/Components/Pages/BorrowingsManage.razor
CHANGED
|
@@ -1,12 +1,219 @@
|
|
| 1 |
@page "/Borrowings/Manage"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
<PageTitle>Manage Borrowings</PageTitle>
|
| 4 |
|
| 5 |
-
<div class="mb-
|
| 6 |
-
<
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</div>
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@page "/Borrowings/Manage"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
|
| 9 |
<PageTitle>Manage Borrowings</PageTitle>
|
| 10 |
|
| 11 |
+
<div class="mb-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 12 |
+
<div class="flex items-center justify-between mb-4">
|
| 13 |
+
<div>
|
| 14 |
+
<h1 class="text-3xl font-extrabold tracking-tight text-slate-900 border-b border-slate-100 pb-4 mb-2">Manage All Borrowings</h1>
|
| 15 |
+
<p class="text-slate-500 font-medium">Monitor and process library loans across all members.</p>
|
| 16 |
+
</div>
|
| 17 |
+
<div class="flex gap-3">
|
| 18 |
+
<div class="card px-4 py-2 flex items-center gap-2 bg-slate-50 border-slate-200">
|
| 19 |
+
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest">Total Active:</span>
|
| 20 |
+
<span class="text-lg font-bold text-slate-900">@_borrowings.Count(m => m.Status == "Borrowed")</span>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
</div>
|
| 25 |
|
| 26 |
+
@if (_isLoading)
|
| 27 |
+
{
|
| 28 |
+
<div class="py-20 flex justify-center">
|
| 29 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-slate-900"></div>
|
| 30 |
+
</div>
|
| 31 |
+
}
|
| 32 |
+
else
|
| 33 |
+
{
|
| 34 |
+
<div class="card overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 35 |
+
<div class="overflow-x-auto">
|
| 36 |
+
<table class="w-full text-left border-collapse">
|
| 37 |
+
<thead>
|
| 38 |
+
<tr class="bg-slate-50 border-b border-slate-200">
|
| 39 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Book & Loan Info</th>
|
| 40 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Member Details</th>
|
| 41 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Loan Dates</th>
|
| 42 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-center">Status</th>
|
| 43 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">Fines</th>
|
| 44 |
+
<th class="px-6 py-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest text-right">Actions</th>
|
| 45 |
+
</tr>
|
| 46 |
+
</thead>
|
| 47 |
+
<tbody class="divide-y divide-slate-100">
|
| 48 |
+
@if (!_borrowings.Any())
|
| 49 |
+
{
|
| 50 |
+
<tr>
|
| 51 |
+
<td colspan="6" class="px-6 py-16 text-center text-slate-400 italic bg-white">
|
| 52 |
+
No borrowing records found in the system.
|
| 53 |
+
</td>
|
| 54 |
+
</tr>
|
| 55 |
+
}
|
| 56 |
+
@foreach (var loan in _borrowings.OrderByDescending(l => l.BorrowDate))
|
| 57 |
+
{
|
| 58 |
+
<tr class="hover:bg-slate-50/80 transition-colors">
|
| 59 |
+
<td class="px-6 py-5">
|
| 60 |
+
<div class="flex items-center gap-4">
|
| 61 |
+
<div class="w-10 h-14 bg-slate-900/5 rounded-lg flex items-center justify-center text-slate-400 border border-slate-100">
|
| 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-slate-900 truncate mb-0.5">@loan.BookTitle</p>
|
| 66 |
+
<p class="text-[10px] font-mono text-slate-400 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-slate-800">@loan.UserEmail</p>
|
| 73 |
+
<p class="text-[10px] text-slate-500 font-medium tracking-wide">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-slate-400 w-12">Out:</span>
|
| 80 |
+
<span class="font-bold text-slate-700">@loan.BorrowDate.ToString("MMM dd, yyyy")</span>
|
| 81 |
+
</div>
|
| 82 |
+
<div class="flex items-center gap-2 text-[11px]">
|
| 83 |
+
<span class="text-slate-400 w-12">Due:</span>
|
| 84 |
+
@{
|
| 85 |
+
var isOverdue = loan.DueDate < DateTime.Now && loan.Status == "Borrowed";
|
| 86 |
+
}
|
| 87 |
+
<span class="font-bold @(isOverdue ? "text-rose-600 animate-pulse" : "text-amber-700")">@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-slate-400 w-12">Back:</span>
|
| 93 |
+
<span class="font-bold text-emerald-600">@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-bold uppercase tracking-wider
|
| 100 |
+
@(loan.Status == "Borrowed" ? "bg-amber-100 text-amber-700" :
|
| 101 |
+
loan.Status == "PendingReturn" ? "bg-blue-600 text-white shadow-sm" :
|
| 102 |
+
"bg-emerald-100 text-emerald-700")">
|
| 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-slate-400 line-through" : "text-rose-600")">
|
| 111 |
+
MMK @loan.FineAmount.ToString("N0")
|
| 112 |
+
</span>
|
| 113 |
+
@if (loan.IsFinePaid)
|
| 114 |
+
{
|
| 115 |
+
<span class="text-[9px] text-emerald-600 font-bold uppercase mt-0.5 tracking-tighter">Settled</span>
|
| 116 |
+
}
|
| 117 |
+
else
|
| 118 |
+
{
|
| 119 |
+
<span class="text-[9px] text-rose-400 font-bold uppercase mt-0.5 tracking-tighter">Pending</span>
|
| 120 |
+
}
|
| 121 |
+
</div>
|
| 122 |
+
}
|
| 123 |
+
else
|
| 124 |
+
{
|
| 125 |
+
<span class="text-[10px] text-slate-300 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-emerald-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-emerald-700 transition-all shadow-md 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-slate-900 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-slate-800 transition-all shadow-sm active:scale-95">
|
| 141 |
+
Process Return
|
| 142 |
+
</button>
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
<div class="text-emerald-600">
|
| 147 |
+
<svg class="w-6 h-6 ml-auto" 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 |
+
</div>
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
@code {
|
| 160 |
+
private bool _isLoading = true;
|
| 161 |
+
private IEnumerable<BorrowingDto> _borrowings = Enumerable.Empty<BorrowingDto>();
|
| 162 |
+
|
| 163 |
+
protected override async Task OnInitializedAsync()
|
| 164 |
+
{
|
| 165 |
+
await LoadDataAsync();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
private async Task LoadDataAsync()
|
| 169 |
+
{
|
| 170 |
+
_isLoading = true;
|
| 171 |
+
try
|
| 172 |
+
{
|
| 173 |
+
_borrowings = await ApiClient.GetAllBorrowingsAsync();
|
| 174 |
+
}
|
| 175 |
+
catch (Exception ex)
|
| 176 |
+
{
|
| 177 |
+
Console.WriteLine($"Error loading all borrowings: {ex.Message}");
|
| 178 |
+
}
|
| 179 |
+
finally
|
| 180 |
+
{
|
| 181 |
+
_isLoading = false;
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
private async Task ApproveReturnAsync(BorrowingDto loan)
|
| 186 |
+
{
|
| 187 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Approve return for \"{loan.BookTitle}\" borrowed by {loan.UserEmail}?");
|
| 188 |
+
if (!confirmed) return;
|
| 189 |
+
|
| 190 |
+
var result = await ApiClient.ReturnBookAsync(loan.Id);
|
| 191 |
+
if (result != null)
|
| 192 |
+
{
|
| 193 |
+
await JS.InvokeVoidAsync("showToast", "Return approved successfully!", "success");
|
| 194 |
+
await LoadDataAsync();
|
| 195 |
+
}
|
| 196 |
+
else
|
| 197 |
+
{
|
| 198 |
+
await JS.InvokeVoidAsync("showToast", "Failed to approve return.", "error");
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
private async Task ForceReturnAsync(BorrowingDto loan)
|
| 203 |
+
{
|
| 204 |
+
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.");
|
| 205 |
+
if (!confirmed) return;
|
| 206 |
+
|
| 207 |
+
var result = await ApiClient.ReturnBookAsync(loan.Id);
|
| 208 |
+
if (result != null)
|
| 209 |
+
{
|
| 210 |
+
await JS.InvokeVoidAsync("showToast", "Book marked as returned!", "success");
|
| 211 |
+
await LoadDataAsync();
|
| 212 |
+
}
|
| 213 |
+
else
|
| 214 |
+
{
|
| 215 |
+
await JS.InvokeVoidAsync("showToast", "Failed to process return.", "error");
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
BlazorFrontend/Components/Pages/Home.razor
CHANGED
|
@@ -1,74 +1,238 @@
|
|
| 1 |
@page "/"
|
| 2 |
@using BlazorFrontend.Services
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
-
@
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
<PageTitle>Dashboard</PageTitle>
|
| 7 |
|
| 8 |
-
<div class="mb-
|
| 9 |
-
<h1 class="text-
|
| 10 |
-
<p class="text-
|
| 11 |
</div>
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
<
|
| 17 |
</div>
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
<
|
| 22 |
-
<AuthorizeView Roles="Librarian">
|
| 23 |
<Authorized>
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</Authorized>
|
| 29 |
-
<NotAuthorized>
|
| 30 |
-
<div class="card p-6">
|
| 31 |
-
<h3 class="text-lg font-medium text-slate-900">My Active Loans</h3>
|
| 32 |
-
<p class="text-3xl font-bold text-slate-700 mt-2">@_myLoans</p>
|
| 33 |
-
</div>
|
| 34 |
-
</NotAuthorized>
|
| 35 |
</AuthorizeView>
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
@code {
|
| 39 |
-
private
|
| 40 |
-
private
|
| 41 |
-
private
|
| 42 |
-
private
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
protected override async Task OnInitializedAsync()
|
| 45 |
{
|
| 46 |
try
|
| 47 |
{
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
try
|
| 56 |
{
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
-
catch { }
|
| 68 |
}
|
| 69 |
catch (Exception ex)
|
| 70 |
{
|
| 71 |
-
Console.WriteLine("Error
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
}
|
| 74 |
}
|
|
|
|
|
|
| 1 |
@page "/"
|
| 2 |
@using BlazorFrontend.Services
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using Microsoft.AspNetCore.Components.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject AuthenticationStateProvider AuthStateProvider
|
| 7 |
+
@inject NavigationManager Navigation
|
| 8 |
|
| 9 |
<PageTitle>Dashboard</PageTitle>
|
| 10 |
|
| 11 |
+
<div class="mb-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 12 |
+
<h1 class="text-3xl font-extrabold tracking-tight text-slate-900 border-b border-slate-100 pb-4 mb-2">Welcome Back</h1>
|
| 13 |
+
<p class="text-slate-500 font-medium">Here's what's happening with the library today.</p>
|
| 14 |
</div>
|
| 15 |
|
| 16 |
+
@if (_isLoading)
|
| 17 |
+
{
|
| 18 |
+
<div class="flex items-center justify-center min-h-[400px]">
|
| 19 |
+
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-900"></div>
|
| 20 |
</div>
|
| 21 |
+
}
|
| 22 |
+
else
|
| 23 |
+
{
|
| 24 |
+
<AuthorizeView>
|
|
|
|
| 25 |
<Authorized>
|
| 26 |
+
<!-- Loyalty Banner -->
|
| 27 |
+
@if (_loyaltyAccount != null)
|
| 28 |
+
{
|
| 29 |
+
<a href="Rewards" class="block group mb-10 animate-in fade-in zoom-in-95 duration-700">
|
| 30 |
+
<div class="bg-gradient-to-r from-amber-500 to-amber-700 rounded-2xl p-6 text-white shadow-lg relative overflow-hidden transition-all duration-300 hover:shadow-xl hover:translate-y-[-2px] border border-white/10">
|
| 31 |
+
<div class="relative z-10 flex items-center justify-between">
|
| 32 |
+
<div>
|
| 33 |
+
<p class="text-amber-100 text-sm font-bold uppercase tracking-widest mb-1">Your Loyalty Status</p>
|
| 34 |
+
<div class="flex items-baseline gap-3">
|
| 35 |
+
<h2 class="text-4xl font-extrabold">@_loyaltyAccount.CurrentBalance.ToString("N0")</h2>
|
| 36 |
+
<span class="text-amber-200 font-medium tracking-wide">Points</span>
|
| 37 |
+
</div>
|
| 38 |
+
<p class="text-[10px] text-amber-300/60 mt-1 uppercase tracking-tighter">Connection: Connected</p>
|
| 39 |
+
</div>
|
| 40 |
+
<div class="text-right flex items-center gap-4">
|
| 41 |
+
<div class="inline-flex items-center gap-2 bg-black/20 backdrop-blur-sm px-4 py-2 rounded-full border border-white/10 shadow-inner">
|
| 42 |
+
<svg class="w-5 h-5 text-amber-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path></svg>
|
| 43 |
+
<span class="font-bold text-lg">@_loyaltyAccount.Tier</span>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="p-2 bg-white/20 rounded-full group-hover:bg-white/30 transition-colors">
|
| 46 |
+
<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>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="absolute -right-10 -top-10 w-48 h-48 bg-white/10 rounded-full blur-2xl transform group-hover:scale-110 transition-transform duration-700"></div>
|
| 51 |
+
</div>
|
| 52 |
+
</a>
|
| 53 |
+
}
|
| 54 |
</Authorized>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</AuthorizeView>
|
| 56 |
+
|
| 57 |
+
<!-- Stats Grid -->
|
| 58 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
| 59 |
+
<div class="card p-6 flex flex-col gap-3 group hover:border-slate-300 transition-all duration-300">
|
| 60 |
+
<div class="flex items-center justify-between">
|
| 61 |
+
<div class="p-2 bg-slate-50 rounded-lg group-hover:bg-slate-100 transition-colors">
|
| 62 |
+
<svg class="w-5 h-5 text-slate-600" 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 |
+
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Books</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div>
|
| 67 |
+
<div class="text-3xl font-bold text-slate-900">@_totalBooks</div>
|
| 68 |
+
<div class="text-xs text-slate-500 mt-1 font-medium italic">Total in catalog</div>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="card p-6 flex flex-col gap-3 group hover:border-slate-300 transition-all duration-300">
|
| 73 |
+
<div class="flex items-center justify-between">
|
| 74 |
+
<div class="p-2 bg-emerald-50 rounded-lg group-hover:bg-emerald-100 transition-colors">
|
| 75 |
+
<svg class="w-5 h-5 text-emerald-600" 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>
|
| 76 |
+
</div>
|
| 77 |
+
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Active Loans</span>
|
| 78 |
+
</div>
|
| 79 |
+
<div>
|
| 80 |
+
<div class="text-3xl font-bold text-slate-900">@_activeLoans</div>
|
| 81 |
+
<div class="text-xs text-slate-500 mt-1 font-medium italic">Checking out right now</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div class="card p-6 flex flex-col gap-3 group hover:border-slate-300 border-l-4 border-l-rose-500 transition-all duration-300">
|
| 86 |
+
<div class="flex items-center justify-between">
|
| 87 |
+
<div class="p-2 bg-rose-50 rounded-lg group-hover:bg-rose-100 transition-colors">
|
| 88 |
+
<svg class="w-5 h-5 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 89 |
+
</div>
|
| 90 |
+
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest text-rose-500">Overdue</span>
|
| 91 |
+
</div>
|
| 92 |
+
<div>
|
| 93 |
+
<div class="text-3xl font-bold text-rose-600">@_overdueLoans</div>
|
| 94 |
+
<div class="text-xs text-rose-500 mt-1 font-medium italic">Require attention</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div class="card p-6 flex flex-col gap-3 group hover:border-slate-300 transition-all duration-300">
|
| 99 |
+
<div class="flex items-center justify-between">
|
| 100 |
+
<div class="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
| 101 |
+
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
| 102 |
+
</div>
|
| 103 |
+
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Active Members</span>
|
| 104 |
+
</div>
|
| 105 |
+
<div>
|
| 106 |
+
<div class="text-3xl font-bold text-slate-900">@_totalMembers</div>
|
| 107 |
+
<div class="text-xs text-slate-500 mt-1 font-medium italic">In the community</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- Recent Arrivals -->
|
| 113 |
+
@if (_recentBooks.Any())
|
| 114 |
+
{
|
| 115 |
+
<div class="mb-12 animate-in fade-in slide-in-from-bottom-6 duration-700 delay-150">
|
| 116 |
+
<div class="flex items-center justify-between mb-8">
|
| 117 |
+
<h2 class="text-2xl font-bold text-slate-900 tracking-tight">Recent Arrivals</h2>
|
| 118 |
+
<a href="Books" class="text-sm font-bold text-slate-500 hover:text-slate-900 flex items-center group">
|
| 119 |
+
Browse All
|
| 120 |
+
<svg class="w-4 h-4 ml-1 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="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
|
| 121 |
+
</a>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
| 125 |
+
@foreach (var book in _recentBooks)
|
| 126 |
+
{
|
| 127 |
+
<div class="group cursor-pointer">
|
| 128 |
+
<a href="Books">
|
| 129 |
+
<div class="aspect-[2/3] w-full bg-slate-100 rounded-xl mb-3 flex items-center justify-center text-slate-300 group-hover:shadow-lg transition-all duration-300 relative overflow-hidden">
|
| 130 |
+
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
| 131 |
+
@if (book.AvailableCopies <= 0)
|
| 132 |
+
{
|
| 133 |
+
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-[1px] flex items-center justify-center">
|
| 134 |
+
<span class="bg-white text-slate-900 text-[8px] font-bold uppercase px-2 py-1 rounded">Out</span>
|
| 135 |
+
</div>
|
| 136 |
+
}
|
| 137 |
+
</div>
|
| 138 |
+
<h3 class="text-xs font-bold text-slate-900 truncate group-hover:text-amber-600 transition-colors">@book.Title</h3>
|
| 139 |
+
<p class="text-[10px] text-slate-500 truncate mt-0.5 font-medium">@book.Author</p>
|
| 140 |
+
</a>
|
| 141 |
+
</div>
|
| 142 |
+
}
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
<!-- Quick Actions -->
|
| 148 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in fade-in slide-in-from-bottom-8 duration-700 delay-300">
|
| 149 |
+
<div class="bg-slate-900 text-white rounded-3xl p-8 flex flex-col justify-between overflow-hidden relative group">
|
| 150 |
+
<div class="relative z-10">
|
| 151 |
+
<h2 class="text-2xl font-bold mb-2">Explore the Library</h2>
|
| 152 |
+
<p class="text-slate-400 text-sm max-w-[280px]">Access thousands of books across all categories with a premium member account.</p>
|
| 153 |
+
</div>
|
| 154 |
+
<div class="mt-8 relative z-10">
|
| 155 |
+
<a href="Books" class="inline-flex items-center bg-white text-slate-900 px-6 py-2.5 rounded-full text-sm font-bold hover:bg-slate-100 transition-colors">
|
| 156 |
+
View Catalog
|
| 157 |
+
</a>
|
| 158 |
+
</div>
|
| 159 |
+
<svg class="absolute bottom-0 right-0 w-48 h-48 text-slate-800 -mb-12 -mr-12 opacity-50 transition-transform group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24"><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>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div class="bg-amber-50 rounded-3xl p-8 flex flex-col justify-between group border border-amber-100">
|
| 163 |
+
<div>
|
| 164 |
+
<h2 class="text-2xl font-bold text-slate-900 mb-2">My Current Loans</h2>
|
| 165 |
+
<p class="text-slate-600 text-sm max-w-[280px]">Don't forget to return your books on time to avoid late fees.</p>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="mt-8">
|
| 168 |
+
<a href="Borrowings" class="inline-flex items-center text-slate-900 px-6 py-2.5 rounded-full text-sm font-bold border-2 border-slate-900 hover:bg-slate-900 hover:text-white transition-all">
|
| 169 |
+
Manage My Loans
|
| 170 |
+
</a>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
}
|
| 175 |
|
| 176 |
@code {
|
| 177 |
+
private int _totalBooks = 0;
|
| 178 |
+
private int _activeLoans = 0;
|
| 179 |
+
private int _overdueLoans = 0;
|
| 180 |
+
private int _totalMembers = 0;
|
| 181 |
+
private IEnumerable<BookDto> _recentBooks = Enumerable.Empty<BookDto>();
|
| 182 |
+
private LoyaltyAccountDto? _loyaltyAccount;
|
| 183 |
+
private bool _isLoading = true;
|
| 184 |
|
| 185 |
protected override async Task OnInitializedAsync()
|
| 186 |
{
|
| 187 |
try
|
| 188 |
{
|
| 189 |
+
// 1. Basic Stats (Public)
|
| 190 |
+
var books = await ApiClient.GetBooksAsync();
|
| 191 |
+
_totalBooks = books.Count();
|
| 192 |
+
_recentBooks = books.Take(6);
|
| 193 |
|
| 194 |
+
// 2. Personal/Authenticated Stats
|
| 195 |
+
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
| 196 |
+
var user = authState.User;
|
| 197 |
|
| 198 |
+
if (user.Identity?.IsAuthenticated == true)
|
|
|
|
| 199 |
{
|
| 200 |
+
// Fetch borrowings
|
| 201 |
+
try
|
| 202 |
+
{
|
| 203 |
+
var borrowings = await ApiClient.GetMyBorrowingsAsync();
|
| 204 |
+
_activeLoans = borrowings.Count(b => b.Status == "Borrowed");
|
| 205 |
+
_overdueLoans = borrowings.Count(b => b.Status == "Overdue" || (b.Status == "Borrowed" && b.DueDate < DateTime.UtcNow));
|
| 206 |
+
}
|
| 207 |
+
catch { }
|
| 208 |
|
| 209 |
+
// Fetch loyalty
|
| 210 |
+
try
|
| 211 |
+
{
|
| 212 |
+
_loyaltyAccount = await ApiClient.GetMyLoyaltyAccountAsync();
|
| 213 |
+
}
|
| 214 |
+
catch { }
|
| 215 |
+
|
| 216 |
+
// 3. Librarian Only Stats
|
| 217 |
+
if (user.IsInRole("Librarian"))
|
| 218 |
+
{
|
| 219 |
+
try
|
| 220 |
+
{
|
| 221 |
+
var members = await ApiClient.GetUsersAsync();
|
| 222 |
+
_totalMembers = members.Count();
|
| 223 |
+
}
|
| 224 |
+
catch { }
|
| 225 |
+
}
|
| 226 |
}
|
|
|
|
| 227 |
}
|
| 228 |
catch (Exception ex)
|
| 229 |
{
|
| 230 |
+
Console.WriteLine("Error loading dashboard data: " + ex.Message);
|
| 231 |
+
}
|
| 232 |
+
finally
|
| 233 |
+
{
|
| 234 |
+
_isLoading = false;
|
| 235 |
}
|
| 236 |
}
|
| 237 |
}
|
| 238 |
+
|
BlazorFrontend/Components/Pages/Members.razor
CHANGED
|
@@ -1,12 +1,634 @@
|
|
| 1 |
@page "/Members"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
<PageTitle>Members Directory</PageTitle>
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
<
|
| 8 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
<div class="card p-6">
|
| 11 |
-
<p class="text-slate-600">Members list will be displayed here.</p>
|
| 12 |
-
</div>
|
|
|
|
| 1 |
@page "/Members"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using BlazorFrontend.Models
|
| 5 |
+
@using Microsoft.AspNetCore.Authorization
|
| 6 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 7 |
+
@inject LibraryApiClient ApiClient
|
| 8 |
+
@inject IJSRuntime JSRuntime
|
| 9 |
+
@inject NavigationManager Navigation
|
| 10 |
|
| 11 |
<PageTitle>Members Directory</PageTitle>
|
| 12 |
|
| 13 |
+
@if (_currentView == ViewMode.List)
|
| 14 |
+
{
|
| 15 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 16 |
+
<div>
|
| 17 |
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900">Members Directory</h1>
|
| 18 |
+
<p class="mt-2 text-slate-500">
|
| 19 |
+
Manage library members and administrator accounts.
|
| 20 |
+
<span class="ml-2 text-xs text-slate-400">(@(_allUsers?.Count() ?? 0) total)</span>
|
| 21 |
+
</p>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="flex items-center gap-3">
|
| 24 |
+
<button @onclick="ShowCreate" class="btn-primary">
|
| 25 |
+
<svg class="w-4 h-4 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>
|
| 26 |
+
Add New Member
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
@if (_isLoading)
|
| 32 |
+
{
|
| 33 |
+
<div class="flex items-center justify-center min-h-[400px]">
|
| 34 |
+
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-900"></div>
|
| 35 |
+
</div>
|
| 36 |
+
}
|
| 37 |
+
else if (_pagedResult != null)
|
| 38 |
+
{
|
| 39 |
+
<div class="card overflow-hidden animate-in fade-in duration-700">
|
| 40 |
+
<div class="overflow-x-auto">
|
| 41 |
+
<table class="min-w-full divide-y divide-slate-200">
|
| 42 |
+
<thead class="bg-slate-50">
|
| 43 |
+
<tr>
|
| 44 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Member</th>
|
| 45 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Contact</th>
|
| 46 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Role</th>
|
| 47 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
| 48 |
+
<th scope="col" class="relative px-6 py-3"><span class="sr-only">Actions</span></th>
|
| 49 |
+
</tr>
|
| 50 |
+
</thead>
|
| 51 |
+
<tbody class="bg-white divide-y divide-slate-200">
|
| 52 |
+
@foreach (var user in _pagedResult.Items)
|
| 53 |
+
{
|
| 54 |
+
<tr class="hover:bg-slate-50 transition-colors">
|
| 55 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 56 |
+
<div class="flex items-center">
|
| 57 |
+
<div class="h-10 w-10 flex-shrink-0 rounded-full bg-slate-900 flex items-center justify-center text-white font-bold text-sm">
|
| 58 |
+
@(user.FullName?.Substring(0, Math.Min(2, user.FullName.Length)).ToUpper() ?? "U")
|
| 59 |
+
</div>
|
| 60 |
+
<div class="ml-4">
|
| 61 |
+
<div class="text-sm font-medium text-slate-900">
|
| 62 |
+
<button @onclick="() => ShowDetails(user.Id)" class="hover:underline text-left">@user.FullName</button>
|
| 63 |
+
</div>
|
| 64 |
+
@if (!string.IsNullOrEmpty(user.StudentId))
|
| 65 |
+
{
|
| 66 |
+
<div class="text-xs text-slate-500">ID: @user.StudentId</div>
|
| 67 |
+
}
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</td>
|
| 71 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 72 |
+
<div class="text-sm text-slate-900">@user.Email</div>
|
| 73 |
+
<div class="text-xs text-slate-500">@(string.IsNullOrEmpty(user.PhoneNumber) ? "No phone" : user.PhoneNumber)</div>
|
| 74 |
+
</td>
|
| 75 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 76 |
+
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 @(user.Role == "Admin" ? "bg-amber-100 text-amber-800" : "bg-blue-100 text-blue-800")">
|
| 77 |
+
@user.Role
|
| 78 |
+
</span>
|
| 79 |
+
</td>
|
| 80 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 81 |
+
@if (user.BanStatus == true)
|
| 82 |
+
{
|
| 83 |
+
<span class="inline-flex rounded-full bg-rose-100 px-2 text-xs font-semibold leading-5 text-rose-800">
|
| 84 |
+
Suspended
|
| 85 |
+
</span>
|
| 86 |
+
}
|
| 87 |
+
else if (user.IsActive)
|
| 88 |
+
{
|
| 89 |
+
<span class="inline-flex rounded-full bg-emerald-100 px-2 text-xs font-semibold leading-5 text-emerald-800">
|
| 90 |
+
Active
|
| 91 |
+
</span>
|
| 92 |
+
}
|
| 93 |
+
else
|
| 94 |
+
{
|
| 95 |
+
<span class="inline-flex rounded-full bg-slate-100 px-2 text-xs font-semibold leading-5 text-slate-800">
|
| 96 |
+
Inactive
|
| 97 |
+
</span>
|
| 98 |
+
}
|
| 99 |
+
</td>
|
| 100 |
+
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
| 101 |
+
<div class="flex items-center justify-end gap-3">
|
| 102 |
+
<button @onclick="() => ShowDetails(user.Id)" class="text-slate-600 hover:text-slate-900 transition-colors">Details</button>
|
| 103 |
+
<button @onclick="() => ShowEdit(user.Id)" class="text-indigo-600 hover:text-indigo-900 transition-colors">Edit</button>
|
| 104 |
+
</div>
|
| 105 |
+
</td>
|
| 106 |
+
</tr>
|
| 107 |
+
}
|
| 108 |
+
</tbody>
|
| 109 |
+
</table>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
@* Pagination *@
|
| 113 |
+
@if (_pagedResult.TotalPages > 1)
|
| 114 |
+
{
|
| 115 |
+
<div class="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-white">
|
| 116 |
+
<p class="text-sm text-slate-500">
|
| 117 |
+
Showing <span class="font-medium text-slate-700">@((_pagedResult.CurrentPage - 1) * _pagedResult.PageSize + 1)</span>
|
| 118 |
+
–
|
| 119 |
+
<span class="font-medium text-slate-700">@(Math.Min(_pagedResult.CurrentPage * _pagedResult.PageSize, _pagedResult.TotalCount))</span>
|
| 120 |
+
of <span class="font-medium text-slate-700">@_pagedResult.TotalCount</span> members
|
| 121 |
+
</p>
|
| 122 |
+
<nav class="flex items-center gap-1" aria-label="Pagination">
|
| 123 |
+
@* Previous *@
|
| 124 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage - 1)" disabled="@(!_pagedResult.HasPreviousPage)"
|
| 125 |
+
class="inline-flex items-center px-3 py-1.5 rounded-md border @(_pagedResult.HasPreviousPage ? "border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900" : "border-slate-100 text-slate-300 cursor-not-allowed") text-sm font-medium transition-colors">
|
| 126 |
+
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 127 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
| 128 |
+
</svg>
|
| 129 |
+
Prev
|
| 130 |
+
</button>
|
| 131 |
+
|
| 132 |
+
@* Page numbers *@
|
| 133 |
+
@for (int p = 1; p <= _pagedResult.TotalPages; p++)
|
| 134 |
+
{
|
| 135 |
+
var pageNumber = p;
|
| 136 |
+
if (p == _pagedResult.CurrentPage)
|
| 137 |
+
{
|
| 138 |
+
<span class="inline-flex items-center justify-center w-8 h-8 rounded-md bg-slate-900 text-white text-sm font-semibold">@p</span>
|
| 139 |
+
}
|
| 140 |
+
else if (p == 1 || p == _pagedResult.TotalPages || (p >= _pagedResult.CurrentPage - 2 && p <= _pagedResult.CurrentPage + 2))
|
| 141 |
+
{
|
| 142 |
+
<button @onclick="() => ChangePage(pageNumber)"
|
| 143 |
+
class="inline-flex items-center justify-center w-8 h-8 rounded-md border border-slate-200 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">@p</button>
|
| 144 |
+
}
|
| 145 |
+
else if (p == _pagedResult.CurrentPage - 3 || p == _pagedResult.CurrentPage + 3)
|
| 146 |
+
{
|
| 147 |
+
<span class="inline-flex items-center justify-center w-8 h-8 text-sm text-slate-400">…</span>
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@* Next *@
|
| 152 |
+
<button @onclick="() => ChangePage(_pagedResult.CurrentPage + 1)" disabled="@(!_pagedResult.HasNextPage)"
|
| 153 |
+
class="inline-flex items-center px-3 py-1.5 rounded-md border @(_pagedResult.HasNextPage ? "border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900" : "border-slate-100 text-slate-300 cursor-not-allowed") text-sm font-medium transition-colors">
|
| 154 |
+
Next
|
| 155 |
+
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 156 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
| 157 |
+
</svg>
|
| 158 |
+
</button>
|
| 159 |
+
</nav>
|
| 160 |
+
</div>
|
| 161 |
+
}
|
| 162 |
+
</div>
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
else if (_currentView == ViewMode.Details && _selectedUser != null)
|
| 166 |
+
{
|
| 167 |
+
<div class="animate-in fade-in slide-in-from-left-4 duration-500">
|
| 168 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-slate-500 hover:text-slate-900 mb-6 group">
|
| 169 |
+
<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>
|
| 170 |
+
Back to Directory
|
| 171 |
+
</button>
|
| 172 |
+
|
| 173 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
| 174 |
+
<div class="lg:col-span-1">
|
| 175 |
+
<div class="aspect-square w-full rounded-2xl bg-slate-900 flex flex-col items-center justify-center text-white p-6 shadow-xl">
|
| 176 |
+
<div class="text-6xl font-black mb-4 tracking-tighter">
|
| 177 |
+
@(_selectedUser.User.FullName?.Substring(0, Math.Min(2, _selectedUser.User.FullName.Length)).ToUpper() ?? "U")
|
| 178 |
+
</div>
|
| 179 |
+
<h2 class="text-xl font-bold text-center">@_selectedUser.User.FullName</h2>
|
| 180 |
+
<div class="mt-2 inline-flex rounded-full px-3 py-1 text-xs font-semibold leading-5 @(_selectedUser.User.Role == "Admin" ? "bg-amber-500/20 text-amber-200" : "bg-blue-500/20 text-blue-200")">
|
| 181 |
+
@_selectedUser.User.Role
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div class="lg:col-span-2 space-y-8">
|
| 187 |
+
<div>
|
| 188 |
+
<h1 class="text-4xl font-extrabold text-slate-900 tracking-tight mb-2">Member Profile</h1>
|
| 189 |
+
<div class="flex items-center gap-3">
|
| 190 |
+
@if (_selectedUser.User.BanStatus == true)
|
| 191 |
+
{
|
| 192 |
+
<span class="inline-flex items-center rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-800 tracking-wide uppercase">Suspended</span>
|
| 193 |
+
}
|
| 194 |
+
else if (_selectedUser.User.IsActive)
|
| 195 |
+
{
|
| 196 |
+
<span class="inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800 tracking-wide uppercase">Active</span>
|
| 197 |
+
}
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
|
| 201 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8 border-y border-slate-100 py-6">
|
| 202 |
+
<div>
|
| 203 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Email</h4>
|
| 204 |
+
<p class="text-sm font-semibold text-slate-900">@_selectedUser.User.Email</p>
|
| 205 |
+
</div>
|
| 206 |
+
<div>
|
| 207 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Phone</h4>
|
| 208 |
+
<p class="text-sm font-semibold text-slate-900">@(string.IsNullOrEmpty(_selectedUser.User.PhoneNumber) ? "-" : _selectedUser.User.PhoneNumber)</p>
|
| 209 |
+
</div>
|
| 210 |
+
<div>
|
| 211 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Student ID</h4>
|
| 212 |
+
<p class="text-sm font-semibold text-slate-900">@(string.IsNullOrEmpty(_selectedUser.User.StudentId) ? "-" : _selectedUser.User.StudentId)</p>
|
| 213 |
+
</div>
|
| 214 |
+
<div>
|
| 215 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Joined</h4>
|
| 216 |
+
<p class="text-sm font-semibold text-slate-900">@_selectedUser.User.CreatedAt.ToString("MMM dd, yyyy")</p>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="sm:col-span-2">
|
| 219 |
+
<h4 class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Address</h4>
|
| 220 |
+
<p class="text-sm font-semibold text-slate-900">@(string.IsNullOrEmpty(_selectedUser.User.Address) ? "-" : _selectedUser.User.Address)</p>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
<div class="flex items-center gap-4 pt-4 flex-wrap">
|
| 225 |
+
<button @onclick="() => ShowEdit(_selectedUser.User.Id)" class="btn-primary py-3 px-8 text-base shadow-lg shadow-slate-200">Edit Details</button>
|
| 226 |
+
|
| 227 |
+
<button @onclick="UpdateRole" class="btn-secondary py-3 px-8 text-base">
|
| 228 |
+
Make @(_selectedUser.User.Role == "Admin" ? "Member" : "Admin")
|
| 229 |
+
</button>
|
| 230 |
+
|
| 231 |
+
<button @onclick="DeleteUser" class="p-3 text-rose-600 hover:text-rose-700 hover:bg-rose-50 rounded-xl transition-colors">
|
| 232 |
+
<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>
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
<!-- Subscription Management -->
|
| 237 |
+
<div class="mt-8 pt-8 border-t border-slate-100">
|
| 238 |
+
<h3 class="text-2xl font-bold text-slate-900 mb-6">Manage Subscription</h3>
|
| 239 |
+
|
| 240 |
+
@if (_selectedUser.CurrentSubscription != null && _selectedUser.CurrentSubscription.IsActive)
|
| 241 |
+
{
|
| 242 |
+
<div class="bg-green-50 border border-green-200 rounded-xl p-4 mb-6">
|
| 243 |
+
<div class="flex justify-between items-center">
|
| 244 |
+
<div>
|
| 245 |
+
<span class="text-sm text-green-800 font-semibold uppercase tracking-wider">Active Plan</span>
|
| 246 |
+
<h4 class="text-xl font-bold text-green-900">@_selectedUser.CurrentSubscription.MembershipType</h4>
|
| 247 |
+
</div>
|
| 248 |
+
<div class="text-right">
|
| 249 |
+
<div class="text-xs text-green-700">Expires On</div>
|
| 250 |
+
<div class="font-bold text-green-900">@_selectedUser.CurrentSubscription.ExpiryDate.ToString("MMM dd, yyyy")</div>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
}
|
| 255 |
+
else
|
| 256 |
+
{
|
| 257 |
+
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-6">
|
| 258 |
+
<p class="text-slate-600 font-medium">No active subscription for this member.</p>
|
| 259 |
+
</div>
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
<h4 class="text-lg font-semibold text-slate-900 mb-4">Assign New Plan</h4>
|
| 263 |
+
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
| 264 |
+
@foreach (var plan in _selectedUser.AvailableMemberships)
|
| 265 |
+
{
|
| 266 |
+
var isCurrent = _selectedUser.CurrentSubscription?.IsActive == true && _selectedUser.CurrentSubscription.MembershipId == plan.Id;
|
| 267 |
+
<div class="card p-4 relative @(isCurrent ? "ring-2 ring-slate-900 border-slate-900" : "")">
|
| 268 |
+
@if (isCurrent)
|
| 269 |
+
{
|
| 270 |
+
<div class="absolute top-0 right-0 -mt-2 -mr-2 bg-slate-900 text-white text-xs font-bold px-2 py-1 rounded-md">Current</div>
|
| 271 |
+
}
|
| 272 |
+
<h5 class="font-bold text-slate-900 text-lg mb-1">@plan.Type</h5>
|
| 273 |
+
<p class="text-slate-500 font-medium text-sm mb-4">@plan.Price.ToString("N0") MMK / @plan.DurationMonths mo</p>
|
| 274 |
+
|
| 275 |
+
@if (!isCurrent)
|
| 276 |
+
{
|
| 277 |
+
<button @onclick="() => AssignSubscription(plan.Id, plan.Type)" class="w-full btn-secondary text-sm">Assign Plan</button>
|
| 278 |
+
}
|
| 279 |
+
else
|
| 280 |
+
{
|
| 281 |
+
<button disabled class="w-full btn-secondary text-sm opacity-50 cursor-not-allowed">Current Plan</button>
|
| 282 |
+
}
|
| 283 |
+
</div>
|
| 284 |
+
}
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
}
|
| 291 |
+
else if (_currentView == ViewMode.Create || _currentView == ViewMode.Edit)
|
| 292 |
+
{
|
| 293 |
+
<div class="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
| 294 |
+
<button @onclick="BackToList" class="inline-flex items-center text-sm font-medium text-slate-500 hover:text-slate-900 mb-6 group">
|
| 295 |
+
<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>
|
| 296 |
+
Back to Directory
|
| 297 |
+
</button>
|
| 298 |
+
|
| 299 |
+
<div class="max-w-3xl mx-auto">
|
| 300 |
+
<div class="mb-8">
|
| 301 |
+
<h1 class="text-3xl font-bold tracking-tight text-slate-900">@(_currentView == ViewMode.Create ? "Add New Member" : "Edit Member Details")</h1>
|
| 302 |
+
<p class="mt-2 text-slate-500">@(_currentView == ViewMode.Create ? "Register a new member to the library system." : $"Update details for {_editUser?.FullName}.")</p>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<div class="card p-6 sm:p-8">
|
| 306 |
+
@if (_currentView == ViewMode.Create)
|
| 307 |
+
{
|
| 308 |
+
<EditForm Model="@_createRequest" OnValidSubmit="HandleCreate" class="space-y-6">
|
| 309 |
+
<DataAnnotationsValidator />
|
| 310 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 311 |
+
<div class="sm:col-span-2">
|
| 312 |
+
<label class="block text-sm font-medium text-slate-700">Full Name <span class="text-rose-500">*</span></label>
|
| 313 |
+
<div class="mt-1">
|
| 314 |
+
<InputText @bind-Value="_createRequest.FullName" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="John Doe" />
|
| 315 |
+
<ValidationMessage For="@(() => _createRequest.FullName)" class="mt-1 text-xs text-rose-500" />
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
<div>
|
| 319 |
+
<label class="block text-sm font-medium text-slate-700">Email Address <span class="text-rose-500">*</span></label>
|
| 320 |
+
<div class="mt-1">
|
| 321 |
+
<InputText @bind-Value="_createRequest.Email" type="email" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="you@domain.com" />
|
| 322 |
+
<ValidationMessage For="@(() => _createRequest.Email)" class="mt-1 text-xs text-rose-500" />
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
<div>
|
| 326 |
+
<label class="block text-sm font-medium text-slate-700">Password <span class="text-rose-500">*</span></label>
|
| 327 |
+
<div class="mt-1">
|
| 328 |
+
<InputText @bind-Value="_createRequest.Password" type="password" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" />
|
| 329 |
+
<ValidationMessage For="@(() => _createRequest.Password)" class="mt-1 text-xs text-rose-500" />
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
<div>
|
| 333 |
+
<label class="block text-sm font-medium text-slate-700">Phone Number</label>
|
| 334 |
+
<div class="mt-1">
|
| 335 |
+
<InputText @bind-Value="_createRequest.PhoneNumber" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="+123456789" />
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
<div>
|
| 339 |
+
<label class="block text-sm font-medium text-slate-700">Student ID</label>
|
| 340 |
+
<div class="mt-1">
|
| 341 |
+
<InputText @bind-Value="_createRequest.StudentId" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="Optional" />
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div class="sm:col-span-2">
|
| 345 |
+
<label class="block text-sm font-medium text-slate-700">Address</label>
|
| 346 |
+
<div class="mt-1">
|
| 347 |
+
<InputTextArea @bind-Value="_createRequest.Address" rows="3" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" placeholder="Full address details" />
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
<div class="pt-5 border-t border-slate-100 flex justify-end gap-3">
|
| 352 |
+
<button type="button" @onclick="BackToList" class="btn-secondary">Cancel</button>
|
| 353 |
+
<button type="submit" class="btn-primary">Create Member</button>
|
| 354 |
+
</div>
|
| 355 |
+
</EditForm>
|
| 356 |
+
}
|
| 357 |
+
else if (_currentView == ViewMode.Edit)
|
| 358 |
+
{
|
| 359 |
+
<EditForm Model="@_updateRequest" OnValidSubmit="HandleUpdate" class="space-y-6">
|
| 360 |
+
<DataAnnotationsValidator />
|
| 361 |
+
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
| 362 |
+
<div class="sm:col-span-2">
|
| 363 |
+
<label class="block text-sm font-medium text-slate-700">Full Name <span class="text-rose-500">*</span></label>
|
| 364 |
+
<div class="mt-1">
|
| 365 |
+
<InputText @bind-Value="_updateRequest.FullName" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" />
|
| 366 |
+
<ValidationMessage For="@(() => _updateRequest.FullName)" class="mt-1 text-xs text-rose-500" />
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
<div>
|
| 370 |
+
<label class="block text-sm font-medium text-slate-700">Phone Number</label>
|
| 371 |
+
<div class="mt-1">
|
| 372 |
+
<InputText @bind-Value="_updateRequest.PhoneNumber" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" />
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
<div>
|
| 376 |
+
<label class="block text-sm font-medium text-slate-700">Student ID</label>
|
| 377 |
+
<div class="mt-1">
|
| 378 |
+
<InputText @bind-Value="_updateRequest.StudentId" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" />
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
<div class="sm:col-span-2">
|
| 382 |
+
<label class="block text-sm font-medium text-slate-700">Address</label>
|
| 383 |
+
<div class="mt-1">
|
| 384 |
+
<InputTextArea @bind-Value="_updateRequest.Address" rows="3" class="block w-full rounded-md border-slate-300 shadow-sm focus:border-slate-900 focus:ring-slate-900 sm:text-sm px-3 py-2 border bg-white" />
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="pt-5 border-t border-slate-100 flex justify-end gap-3">
|
| 389 |
+
<button type="button" @onclick="BackToList" class="btn-secondary">Cancel</button>
|
| 390 |
+
<button type="submit" class="btn-primary">Update Member</button>
|
| 391 |
+
</div>
|
| 392 |
+
</EditForm>
|
| 393 |
+
}
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
@code {
|
| 400 |
+
private enum ViewMode { List, Details, Create, Edit }
|
| 401 |
+
private ViewMode _currentView = ViewMode.List;
|
| 402 |
+
|
| 403 |
+
private IEnumerable<UserDto>? _allUsers;
|
| 404 |
+
private PagedResult<UserDto>? _pagedResult;
|
| 405 |
+
private bool _isLoading = true;
|
| 406 |
+
private const int PageSize = 10;
|
| 407 |
+
|
| 408 |
+
private MemberDetailsViewModel? _selectedUser;
|
| 409 |
+
private UserCreateRequest _createRequest = new();
|
| 410 |
+
private UserUpdateRequest _updateRequest = new();
|
| 411 |
+
private UserDto? _editUser;
|
| 412 |
+
|
| 413 |
+
protected override async Task OnInitializedAsync()
|
| 414 |
+
{
|
| 415 |
+
await LoadUsers();
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
private async Task LoadUsers()
|
| 419 |
+
{
|
| 420 |
+
_isLoading = true;
|
| 421 |
+
try
|
| 422 |
+
{
|
| 423 |
+
_allUsers = await ApiClient.GetUsersAsync();
|
| 424 |
+
ApplyPagination(1);
|
| 425 |
+
}
|
| 426 |
+
catch (Exception ex)
|
| 427 |
+
{
|
| 428 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error loading users: " + ex.Message, "error");
|
| 429 |
+
}
|
| 430 |
+
finally
|
| 431 |
+
{
|
| 432 |
+
_isLoading = false;
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
private void ApplyPagination(int page)
|
| 437 |
+
{
|
| 438 |
+
if (_allUsers == null) return;
|
| 439 |
+
|
| 440 |
+
var totalCount = _allUsers.Count();
|
| 441 |
+
var totalPages = (int)Math.Ceiling(totalCount / (double)PageSize);
|
| 442 |
+
page = Math.Max(1, Math.Min(page, Math.Max(1, totalPages)));
|
| 443 |
+
|
| 444 |
+
_pagedResult = new PagedResult<UserDto>
|
| 445 |
+
{
|
| 446 |
+
Items = _allUsers.Skip((page - 1) * PageSize).Take(PageSize),
|
| 447 |
+
CurrentPage = page,
|
| 448 |
+
TotalPages = totalPages,
|
| 449 |
+
TotalCount = totalCount,
|
| 450 |
+
PageSize = PageSize
|
| 451 |
+
};
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
private void ChangePage(int page)
|
| 455 |
+
{
|
| 456 |
+
ApplyPagination(page);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
private async Task ShowDetails(Guid userId)
|
| 460 |
+
{
|
| 461 |
+
_isLoading = true;
|
| 462 |
+
try
|
| 463 |
+
{
|
| 464 |
+
var user = await ApiClient.GetUserAsync(userId);
|
| 465 |
+
if (user == null) return;
|
| 466 |
+
|
| 467 |
+
var memberships = await ApiClient.GetMembershipsAsync();
|
| 468 |
+
var subscription = await ApiClient.GetUserSubscriptionAsync(userId);
|
| 469 |
+
|
| 470 |
+
_selectedUser = new MemberDetailsViewModel
|
| 471 |
+
{
|
| 472 |
+
User = user,
|
| 473 |
+
AvailableMemberships = memberships.ToList(),
|
| 474 |
+
CurrentSubscription = subscription
|
| 475 |
+
};
|
| 476 |
+
_currentView = ViewMode.Details;
|
| 477 |
+
}
|
| 478 |
+
catch (Exception ex)
|
| 479 |
+
{
|
| 480 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error loading member details: " + ex.Message, "error");
|
| 481 |
+
}
|
| 482 |
+
finally
|
| 483 |
+
{
|
| 484 |
+
_isLoading = false;
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
private void ShowCreate()
|
| 489 |
+
{
|
| 490 |
+
_createRequest = new();
|
| 491 |
+
_currentView = ViewMode.Create;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
private async Task ShowEdit(Guid userId)
|
| 495 |
+
{
|
| 496 |
+
_isLoading = true;
|
| 497 |
+
try
|
| 498 |
+
{
|
| 499 |
+
_editUser = _allUsers?.FirstOrDefault(u => u.Id == userId);
|
| 500 |
+
if (_editUser == null)
|
| 501 |
+
{
|
| 502 |
+
_editUser = await ApiClient.GetUserAsync(userId);
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
if (_editUser != null)
|
| 506 |
+
{
|
| 507 |
+
_updateRequest = new UserUpdateRequest
|
| 508 |
+
{
|
| 509 |
+
FullName = _editUser.FullName,
|
| 510 |
+
PhoneNumber = _editUser.PhoneNumber,
|
| 511 |
+
StudentId = _editUser.StudentId,
|
| 512 |
+
Address = _editUser.Address
|
| 513 |
+
};
|
| 514 |
+
_currentView = ViewMode.Edit;
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
finally
|
| 518 |
+
{
|
| 519 |
+
_isLoading = false;
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
private void BackToList()
|
| 524 |
+
{
|
| 525 |
+
_currentView = ViewMode.List;
|
| 526 |
+
_selectedUser = null;
|
| 527 |
+
_editUser = null;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
private async Task HandleCreate()
|
| 531 |
+
{
|
| 532 |
+
try
|
| 533 |
+
{
|
| 534 |
+
var result = await ApiClient.CreateUserAsync(_createRequest);
|
| 535 |
+
if (result != null)
|
| 536 |
+
{
|
| 537 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Member created successfully!", "success");
|
| 538 |
+
await LoadUsers();
|
| 539 |
+
BackToList();
|
| 540 |
+
}
|
| 541 |
+
else
|
| 542 |
+
{
|
| 543 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Failed to create member.", "error");
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
catch (Exception ex)
|
| 547 |
+
{
|
| 548 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
private async Task HandleUpdate()
|
| 553 |
+
{
|
| 554 |
+
if (_editUser == null) return;
|
| 555 |
+
try
|
| 556 |
+
{
|
| 557 |
+
var result = await ApiClient.UpdateUserAsync(_editUser.Id, _updateRequest);
|
| 558 |
+
if (result != null)
|
| 559 |
+
{
|
| 560 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Member updated successfully!", "success");
|
| 561 |
+
await LoadUsers();
|
| 562 |
+
BackToList();
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
catch (Exception ex)
|
| 566 |
+
{
|
| 567 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
private async Task DeleteUser()
|
| 572 |
+
{
|
| 573 |
+
if (_selectedUser == null) return;
|
| 574 |
+
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Are you sure you want to completely remove this member? This action cannot be undone.");
|
| 575 |
+
if (!confirmed) return;
|
| 576 |
+
|
| 577 |
+
try
|
| 578 |
+
{
|
| 579 |
+
var success = await ApiClient.DeleteUserAsync(_selectedUser.User.Id);
|
| 580 |
+
if (success)
|
| 581 |
+
{
|
| 582 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Member removed.", "success");
|
| 583 |
+
await LoadUsers();
|
| 584 |
+
BackToList();
|
| 585 |
+
}
|
| 586 |
+
}
|
| 587 |
+
catch (Exception ex)
|
| 588 |
+
{
|
| 589 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 590 |
+
}
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
private async Task UpdateRole()
|
| 594 |
+
{
|
| 595 |
+
if (_selectedUser == null) return;
|
| 596 |
+
var newRole = _selectedUser.User.Role == "Admin" ? "Member" : "Admin";
|
| 597 |
+
try
|
| 598 |
+
{
|
| 599 |
+
var success = await ApiClient.UpdateUserRoleAsync(_selectedUser.User.Id, newRole);
|
| 600 |
+
if (success)
|
| 601 |
+
{
|
| 602 |
+
await JSRuntime.InvokeVoidAsync("showToast", $"Role updated to {newRole}.", "success");
|
| 603 |
+
await ShowDetails(_selectedUser.User.Id); // Refresh details
|
| 604 |
+
}
|
| 605 |
+
}
|
| 606 |
+
catch (Exception ex)
|
| 607 |
+
{
|
| 608 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
private async Task AssignSubscription(int membershipId, string planName)
|
| 613 |
+
{
|
| 614 |
+
if (_selectedUser == null) return;
|
| 615 |
+
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Assign {planName} plan to this member?");
|
| 616 |
+
if (!confirmed) return;
|
| 617 |
+
|
| 618 |
+
try
|
| 619 |
+
{
|
| 620 |
+
var request = new AdminSubscribeRequest { UserId = _selectedUser.User.Id, MembershipId = membershipId };
|
| 621 |
+
var result = await ApiClient.AdminSubscribeAsync(request);
|
| 622 |
+
if (result != null)
|
| 623 |
+
{
|
| 624 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Subscription assigned successfully.", "success");
|
| 625 |
+
await ShowDetails(_selectedUser.User.Id); // Refresh details
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
catch (Exception ex)
|
| 629 |
+
{
|
| 630 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + ex.Message, "error");
|
| 631 |
+
}
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
|
|
|
|
|
|
|
|
|
BlazorFrontend/Components/Pages/Membership.razor
CHANGED
|
@@ -1,12 +1,320 @@
|
|
| 1 |
@page "/Membership"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
<PageTitle>My Membership</PageTitle>
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
<
|
| 8 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
<div class="card p-6">
|
| 11 |
-
<p class="text-slate-600">Membership details will be displayed here.</p>
|
| 12 |
-
</div>
|
|
|
|
| 1 |
@page "/Membership"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using BlazorFrontend.Models
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JSRuntime
|
| 7 |
+
@inject NavigationManager Navigation
|
| 8 |
|
| 9 |
<PageTitle>My Membership</PageTitle>
|
| 10 |
|
| 11 |
+
@if (_isLoading)
|
| 12 |
+
{
|
| 13 |
+
<div class="flex items-center justify-center min-h-[400px]">
|
| 14 |
+
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-900"></div>
|
| 15 |
+
</div>
|
| 16 |
+
}
|
| 17 |
+
else if (_viewModel != null)
|
| 18 |
+
{
|
| 19 |
+
var hasActiveSub = _viewModel.CurrentSubscription != null && _viewModel.CurrentSubscription.IsActive;
|
| 20 |
+
|
| 21 |
+
<div class="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
| 22 |
+
<!-- Header with Loyalty Summary -->
|
| 23 |
+
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 bg-white p-8 rounded-2xl shadow-sm border border-slate-100">
|
| 24 |
+
<div>
|
| 25 |
+
<h1 class="text-3xl font-extrabold text-slate-900 tracking-tight">Membership</h1>
|
| 26 |
+
<p class="text-slate-500 mt-1">Manage your subscription and library benefits</p>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
@if (_viewModel.LoyaltyAccount != null)
|
| 30 |
+
{
|
| 31 |
+
<div class="flex items-center gap-4 bg-slate-50 px-6 py-4 rounded-xl border border-slate-200/60">
|
| 32 |
+
<div class="bg-amber-100 p-2.5 rounded-lg text-amber-600">
|
| 33 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg>
|
| 34 |
+
</div>
|
| 35 |
+
<div>
|
| 36 |
+
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">Available Points</div>
|
| 37 |
+
<div class="text-2xl font-black text-slate-900 leading-none">@_viewModel.LoyaltyAccount.CurrentBalance.ToString("N0")</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
}
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 44 |
+
<!-- Current Subscription Card -->
|
| 45 |
+
<div class="lg:col-span-2 space-y-8">
|
| 46 |
+
<section>
|
| 47 |
+
<h2 class="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
| 48 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-slate-400"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>
|
| 49 |
+
Active Plan
|
| 50 |
+
</h2>
|
| 51 |
+
|
| 52 |
+
@if (hasActiveSub && _viewModel.CurrentSubscription != null)
|
| 53 |
+
{
|
| 54 |
+
<div class="relative overflow-hidden bg-slate-900 rounded-3xl p-8 text-white group shadow-xl">
|
| 55 |
+
<!-- Decorative element -->
|
| 56 |
+
<div class="absolute -right-20 -top-20 w-64 h-64 bg-white/5 rounded-full blur-3xl group-hover:bg-white/10 transition-colors duration-500"></div>
|
| 57 |
+
|
| 58 |
+
<div class="relative z-10">
|
| 59 |
+
<div class="flex justify-between items-start mb-10">
|
| 60 |
+
<div>
|
| 61 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-green-500/20 text-green-400 border border-green-500/30 mb-3 uppercase tracking-wider">Active Status</span>
|
| 62 |
+
<h3 class="text-4xl font-black tracking-tight">@_viewModel.CurrentSubscription.MembershipType</h3>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="h-14 w-14 bg-white/10 rounded-2xl flex items-center justify-center backdrop-blur-md">
|
| 65 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<div class="grid grid-cols-2 gap-8 py-8 border-t border-white/10">
|
| 70 |
+
<div>
|
| 71 |
+
<div class="text-slate-400 text-sm font-medium mb-1">Start Date</div>
|
| 72 |
+
<div class="text-lg font-bold">@_viewModel.CurrentSubscription.StartDate.ToString("MMM dd, yyyy")</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div>
|
| 75 |
+
<div class="text-slate-400 text-sm font-medium mb-1">Expiry Date</div>
|
| 76 |
+
<div class="text-lg font-bold">@_viewModel.CurrentSubscription.ExpiryDate.ToString("MMM dd, yyyy")</div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="pt-6 flex items-center gap-2 text-slate-400 text-sm italic">
|
| 81 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
| 82 |
+
Pro-tip: Maintain your subscription to earn loyalty points every month!
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
}
|
| 87 |
+
else
|
| 88 |
+
{
|
| 89 |
+
<div class="bg-white border-2 border-dashed border-slate-200 rounded-3xl p-12 text-center">
|
| 90 |
+
<div class="bg-slate-50 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 text-slate-400">
|
| 91 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
| 92 |
+
</div>
|
| 93 |
+
<h3 class="text-xl font-bold text-slate-900">No Active Subscription</h3>
|
| 94 |
+
<p class="text-slate-500 mt-2 mb-8 max-w-xs mx-auto">Get a membership to borrow books and unlock premium library features.</p>
|
| 95 |
+
<a href="Membership#plans" class="btn-primary px-8 py-3 rounded-xl shadow-lg shadow-slate-900/10">Browse Plans</a>
|
| 96 |
+
</div>
|
| 97 |
+
}
|
| 98 |
+
</section>
|
| 99 |
+
|
| 100 |
+
<!-- Queued Memberships -->
|
| 101 |
+
@if (_viewModel.QueuedMemberships.Any())
|
| 102 |
+
{
|
| 103 |
+
<section>
|
| 104 |
+
<h2 class="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
| 105 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-amber-500"><path d="M10 10 2.5 2.5"/><path d="M21.5 2.5 14 10"/><path d="m21.5 21.5-7.5-7.5"/><path d="M10 14 2.5 21.5"/><circle cx="12" cy="12" r="3"/></svg>
|
| 106 |
+
Queued Memberships (@_viewModel.QueuedMemberships.Count)
|
| 107 |
+
</h2>
|
| 108 |
+
<div class="space-y-3">
|
| 109 |
+
@foreach (var queued in _viewModel.QueuedMemberships)
|
| 110 |
+
{
|
| 111 |
+
<div class="flex items-center justify-between bg-amber-50 border border-amber-100 p-5 rounded-2xl shadow-sm">
|
| 112 |
+
<div class="flex items-center gap-4">
|
| 113 |
+
<div class="h-12 w-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shadow-sm border border-amber-100">
|
| 114 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
| 115 |
+
</div>
|
| 116 |
+
<div>
|
| 117 |
+
<div class="font-bold text-slate-900">@queued.MembershipType Membership</div>
|
| 118 |
+
<div class="text-sm text-slate-500">
|
| 119 |
+
Activates on @queued.StartDate.ToString("MMM dd, yyyy") • Expires @queued.ExpiryDate.ToString("MMM dd, yyyy")
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="text-right">
|
| 124 |
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-700 border border-amber-200 uppercase tracking-wider">Queued</span>
|
| 125 |
+
<div class="text-xs text-slate-400 mt-1">Will activate automatically</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
}
|
| 129 |
+
</div>
|
| 130 |
+
</section>
|
| 131 |
+
}
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<!-- Right Side Panel: Benefits -->
|
| 135 |
+
<div class="space-y-8">
|
| 136 |
+
<div class="bg-white border border-slate-100 rounded-3xl p-8 shadow-sm">
|
| 137 |
+
<h3 class="text-lg font-bold text-slate-900 mb-6">Your Benefits</h3>
|
| 138 |
+
<ul class="space-y-6">
|
| 139 |
+
@{
|
| 140 |
+
var currentPlan = _viewModel.AvailableMemberships.FirstOrDefault(m => hasActiveSub && _viewModel.CurrentSubscription != null && m.Type == _viewModel.CurrentSubscription.MembershipType);
|
| 141 |
+
}
|
| 142 |
+
<li class="flex gap-4">
|
| 143 |
+
<div class="shrink-0 h-10 w-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center font-bold">@((currentPlan?.MaxBooks ?? 1))</div>
|
| 144 |
+
<div>
|
| 145 |
+
<div class="font-bold text-slate-900">Max Books</div>
|
| 146 |
+
<div class="text-sm text-slate-500">Number of books you can borrow simultaneously.</div>
|
| 147 |
+
</div>
|
| 148 |
+
</li>
|
| 149 |
+
<li class="flex gap-4">
|
| 150 |
+
<div class="shrink-0 h-10 w-10 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center font-bold">@((currentPlan?.BorrowingDays ?? 14))</div>
|
| 151 |
+
<div>
|
| 152 |
+
<div class="font-bold text-slate-900">Borrowing Days</div>
|
| 153 |
+
<div class="text-sm text-slate-500">Duration you can keep each borrowed book.</div>
|
| 154 |
+
</div>
|
| 155 |
+
</li>
|
| 156 |
+
<li class="flex gap-4">
|
| 157 |
+
<div class="shrink-0 h-10 w-10 bg-violet-50 text-violet-600 rounded-xl flex items-center justify-center">
|
| 158 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
| 159 |
+
</div>
|
| 160 |
+
<div>
|
| 161 |
+
<div class="font-bold text-slate-900">Digital Access</div>
|
| 162 |
+
<div class="text-sm text-slate-500">Browse and search the full library catalog online.</div>
|
| 163 |
+
</div>
|
| 164 |
+
</li>
|
| 165 |
+
</ul>
|
| 166 |
+
|
| 167 |
+
<div class="mt-10 p-5 bg-slate-900 rounded-2xl text-white text-center">
|
| 168 |
+
<p class="text-sm font-medium mb-3">Want more books?</p>
|
| 169 |
+
<a href="Membership#plans" class="text-sm font-bold text-indigo-400 hover:text-indigo-300 transition-colors">Upgrade Plan →</a>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<!-- Explore All Plans Section -->
|
| 176 |
+
<section id="plans" class="pt-10">
|
| 177 |
+
<div class="text-center mb-10">
|
| 178 |
+
<h2 class="text-3xl font-black text-slate-900">Explore Membership Plans</h2>
|
| 179 |
+
<p class="text-slate-500 mt-2">Find the perfect library experience for your reading habits</p>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 183 |
+
@foreach (var plan in _viewModel.AvailableMemberships)
|
| 184 |
+
{
|
| 185 |
+
var isCurrent = hasActiveSub && _viewModel.CurrentSubscription != null && plan.Type == _viewModel.CurrentSubscription.MembershipType;
|
| 186 |
+
|
| 187 |
+
<div class="group relative bg-white border @(isCurrent ? "border-slate-900 ring-2 ring-slate-900 ring-offset-2" : "border-slate-200") rounded-3xl p-8 transition-all duration-300 hover:shadow-2xl hover:-translate-y-2">
|
| 188 |
+
@if (isCurrent)
|
| 189 |
+
{
|
| 190 |
+
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest shadow-xl">Current Plan</div>
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
<div class="mb-8">
|
| 194 |
+
<h3 class="text-sm font-black text-slate-400 uppercase tracking-widest mb-2">@plan.Type</h3>
|
| 195 |
+
<div class="flex items-baseline">
|
| 196 |
+
<span class="text-3xl font-black text-slate-900">@plan.Price.ToString("N0")</span>
|
| 197 |
+
<span class="text-slate-500 font-bold ml-1 text-sm uppercase">MMK</span>
|
| 198 |
+
</div>
|
| 199 |
+
<div class="text-slate-400 text-xs font-bold mt-1">/ @(plan.DurationMonths == 1 ? "Month" : $"{plan.DurationMonths} Months")</div>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<ul class="space-y-4 mb-10">
|
| 203 |
+
<li class="flex items-center gap-3 text-sm">
|
| 204 |
+
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 205 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 206 |
+
</div>
|
| 207 |
+
<span class="text-slate-600 font-bold">@plan.MaxBooks Books limit</span>
|
| 208 |
+
</li>
|
| 209 |
+
<li class="flex items-center gap-3 text-sm">
|
| 210 |
+
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 211 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 212 |
+
</div>
|
| 213 |
+
<span class="text-slate-600 font-bold">@plan.BorrowingDays Days keep</span>
|
| 214 |
+
</li>
|
| 215 |
+
@if (!string.IsNullOrEmpty(plan.RewardId) && _viewModel.RewardPointCosts.TryGetValue(plan.RewardId, out var pointCost))
|
| 216 |
+
{
|
| 217 |
+
<li class="flex items-center gap-3 text-sm text-amber-600 font-black">
|
| 218 |
+
<div class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center text-amber-600">
|
| 219 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/></svg>
|
| 220 |
+
</div>
|
| 221 |
+
<span>@pointCost.ToString("N0") Points</span>
|
| 222 |
+
</li>
|
| 223 |
+
}
|
| 224 |
+
</ul>
|
| 225 |
+
|
| 226 |
+
@if (isCurrent)
|
| 227 |
+
{
|
| 228 |
+
<button disabled class="w-full py-4 rounded-2xl bg-slate-100 text-slate-400 font-bold text-sm cursor-not-allowed">Active Now</button>
|
| 229 |
+
}
|
| 230 |
+
else
|
| 231 |
+
{
|
| 232 |
+
<div class="space-y-3">
|
| 233 |
+
<a href="Borrowings" class="block w-full py-4 rounded-2xl bg-white border-2 border-slate-900 text-slate-900 text-center font-bold text-sm transition-all duration-200 hover:bg-slate-900 hover:text-white">Pay Cash</a>
|
| 234 |
+
|
| 235 |
+
@if (!string.IsNullOrEmpty(plan.RewardId) && _viewModel.RewardPointCosts.ContainsKey(plan.RewardId))
|
| 236 |
+
{
|
| 237 |
+
<button @onclick="() => RedeemMembership(plan.RewardId, plan.Type, _viewModel.RewardPointCosts[plan.RewardId])"
|
| 238 |
+
class="w-full py-4 rounded-2xl bg-amber-400 text-slate-900 font-black text-sm hover:bg-amber-500 transition-all shadow-lg shadow-amber-400/20">
|
| 239 |
+
Redeem with Points
|
| 240 |
+
</button>
|
| 241 |
+
}
|
| 242 |
+
</div>
|
| 243 |
+
}
|
| 244 |
+
</div>
|
| 245 |
+
}
|
| 246 |
+
</div>
|
| 247 |
+
</section>
|
| 248 |
+
</div>
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
@code {
|
| 252 |
+
private MembershipViewModel? _viewModel;
|
| 253 |
+
private bool _isLoading = true;
|
| 254 |
+
|
| 255 |
+
protected override async Task OnInitializedAsync()
|
| 256 |
+
{
|
| 257 |
+
try
|
| 258 |
+
{
|
| 259 |
+
var allSubscriptions = await ApiClient.GetMyAllSubscriptionsAsync();
|
| 260 |
+
var memberships = await ApiClient.GetMembershipsAsync();
|
| 261 |
+
var loyaltyAccount = await ApiClient.GetMyLoyaltyAccountAsync();
|
| 262 |
+
var activeRewards = await ApiClient.GetActiveRewardsAsync();
|
| 263 |
+
|
| 264 |
+
var now = DateTime.UtcNow;
|
| 265 |
+
|
| 266 |
+
var currentSubscription = allSubscriptions
|
| 267 |
+
.Where(s => s.StartDate <= now && s.ExpiryDate > now)
|
| 268 |
+
.OrderByDescending(s => s.StartDate)
|
| 269 |
+
.FirstOrDefault();
|
| 270 |
+
|
| 271 |
+
var queuedMemberships = allSubscriptions
|
| 272 |
+
.Where(s => s.StartDate > now)
|
| 273 |
+
.OrderBy(s => s.StartDate)
|
| 274 |
+
.ToList();
|
| 275 |
+
|
| 276 |
+
_viewModel = new MembershipViewModel
|
| 277 |
+
{
|
| 278 |
+
CurrentSubscription = currentSubscription,
|
| 279 |
+
AvailableMemberships = memberships.ToList(),
|
| 280 |
+
QueuedMemberships = queuedMemberships,
|
| 281 |
+
LoyaltyAccount = loyaltyAccount,
|
| 282 |
+
RewardPointCosts = activeRewards.ToDictionary(r => r.Id, r => r.PointCost)
|
| 283 |
+
};
|
| 284 |
+
}
|
| 285 |
+
catch (Exception ex)
|
| 286 |
+
{
|
| 287 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error loading membership data: " + ex.Message, "error");
|
| 288 |
+
}
|
| 289 |
+
finally
|
| 290 |
+
{
|
| 291 |
+
_isLoading = false;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
private async Task RedeemMembership(string rewardId, string planName, double cost)
|
| 296 |
+
{
|
| 297 |
+
bool confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to redeem {planName} for {cost:N0} points?");
|
| 298 |
+
if (!confirmed) return;
|
| 299 |
+
|
| 300 |
+
try
|
| 301 |
+
{
|
| 302 |
+
var (success, message) = await ApiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Membership Page");
|
| 303 |
+
|
| 304 |
+
if (success)
|
| 305 |
+
{
|
| 306 |
+
await JSRuntime.InvokeVoidAsync("showToast", message, "success");
|
| 307 |
+
await OnInitializedAsync(); // Refresh data
|
| 308 |
+
}
|
| 309 |
+
else
|
| 310 |
+
{
|
| 311 |
+
await JSRuntime.InvokeVoidAsync("showToast", "Error: " + message, "error");
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
catch (Exception ex)
|
| 315 |
+
{
|
| 316 |
+
await JSRuntime.InvokeVoidAsync("showToast", "An unexpected error occurred: " + ex.Message, "error");
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
|
|
|
|
|
|
|
|
|
BlazorFrontend/Components/Pages/Rewards.razor
CHANGED
|
@@ -1,12 +1,295 @@
|
|
| 1 |
@page "/Rewards"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
<PageTitle>Rewards</PageTitle>
|
| 4 |
|
| 5 |
-
<div class="mb-6">
|
| 6 |
-
<
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</div>
|
| 9 |
|
| 10 |
-
<div class="
|
| 11 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@page "/Rewards"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@inject LibraryApiClient ApiClient
|
| 5 |
+
@inject IJSRuntime JS
|
| 6 |
|
| 7 |
+
<PageTitle>My Rewards</PageTitle>
|
| 8 |
|
| 9 |
+
<div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 10 |
+
<div>
|
| 11 |
+
<h1 class="text-3xl font-extrabold tracking-tight text-slate-900">Loyalty Rewards</h1>
|
| 12 |
+
<p class="text-slate-500 mt-1">Earn points, redeem rewards, and track your loyalty journey.</p>
|
| 13 |
+
</div>
|
| 14 |
+
@if (_account != null)
|
| 15 |
+
{
|
| 16 |
+
<div class="flex items-center gap-4">
|
| 17 |
+
<div class="text-center bg-amber-50 border border-amber-200 px-6 py-3 rounded-2xl shadow-sm">
|
| 18 |
+
<p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Balance</p>
|
| 19 |
+
<p class="text-2xl font-black text-slate-900">@_account.CurrentBalance.ToString("N0")</p>
|
| 20 |
+
<p class="text-xs text-amber-600 font-semibold">Points</p>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="text-center bg-slate-50 border border-slate-200 px-6 py-3 rounded-2xl shadow-sm">
|
| 23 |
+
<p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tier</p>
|
| 24 |
+
<p class="text-lg font-black text-slate-900">@_account.Tier</p>
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
}
|
| 28 |
</div>
|
| 29 |
|
| 30 |
+
<div class="mb-6 border-b border-slate-200">
|
| 31 |
+
<nav class="-mb-px flex gap-6">
|
| 32 |
+
<button @onclick='() => _activeTab = "redeem"'
|
| 33 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "redeem" ? "border-slate-900 text-slate-900" : "border-transparent text-slate-400 hover:text-slate-700")'>
|
| 34 |
+
<span class="inline-flex items-center gap-1.5">
|
| 35 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 36 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12v7a2 2 0 01-2 2H6a2 2 0 01-2-2v-7m16 0H4m16 0l-2.586-2.586a2 2 0 00-2.828 0L12 12m0 0L9.414 9.414a2 2 0 00-2.828 0L4 12m8 0V5" />
|
| 37 |
+
</svg>
|
| 38 |
+
Available Rewards
|
| 39 |
+
</span>
|
| 40 |
+
</button>
|
| 41 |
+
<button @onclick='() => _activeTab = "pending"'
|
| 42 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "pending" ? "border-slate-900 text-slate-900" : "border-transparent text-slate-400 hover:text-slate-700")'>
|
| 43 |
+
<span class="inline-flex items-center gap-1.5">
|
| 44 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 45 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 46 |
+
</svg>
|
| 47 |
+
Pending
|
| 48 |
+
</span>
|
| 49 |
+
@if (_pendingRedemptions.Any())
|
| 50 |
+
{
|
| 51 |
+
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-black rounded-full bg-amber-400 text-slate-900">@_pendingRedemptions.Count()</span>
|
| 52 |
+
}
|
| 53 |
+
</button>
|
| 54 |
+
<button @onclick='() => _activeTab = "history"'
|
| 55 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "history" ? "border-slate-900 text-slate-900" : "border-transparent text-slate-400 hover:text-slate-700")'>
|
| 56 |
+
<span class="inline-flex items-center gap-1.5">
|
| 57 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 58 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-6m4 6V7m4 10v-3M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
| 59 |
+
</svg>
|
| 60 |
+
Points History
|
| 61 |
+
</span>
|
| 62 |
+
</button>
|
| 63 |
+
</nav>
|
| 64 |
</div>
|
| 65 |
+
|
| 66 |
+
@if (_isLoading)
|
| 67 |
+
{
|
| 68 |
+
<div class="py-20 flex justify-center">
|
| 69 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-slate-900"></div>
|
| 70 |
+
</div>
|
| 71 |
+
}
|
| 72 |
+
else
|
| 73 |
+
{
|
| 74 |
+
@if (_activeTab == "redeem")
|
| 75 |
+
{
|
| 76 |
+
<div class="animate-in fade-in duration-300">
|
| 77 |
+
@if (!_availableRewards.Any())
|
| 78 |
+
{
|
| 79 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
|
| 80 |
+
<p class="text-slate-400 font-medium">No active rewards available. Check back soon!</p>
|
| 81 |
+
</div>
|
| 82 |
+
}
|
| 83 |
+
else
|
| 84 |
+
{
|
| 85 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 86 |
+
@foreach (var reward in _availableRewards)
|
| 87 |
+
{
|
| 88 |
+
var canAfford = _account != null && _account.CurrentBalance >= reward.PointCost;
|
| 89 |
+
<div class="group relative bg-white border border-slate-200 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 overflow-hidden">
|
| 90 |
+
<div class="h-1.5 @(canAfford ? "bg-gradient-to-r from-amber-400 to-orange-400" : "bg-slate-200")"></div>
|
| 91 |
+
<div class="p-6">
|
| 92 |
+
<div class="flex justify-between items-start mb-4">
|
| 93 |
+
<div class="p-3 @(canAfford ? "bg-amber-50 text-amber-600" : "bg-slate-100 text-slate-400") rounded-xl">
|
| 94 |
+
<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="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path></svg>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="text-right">
|
| 97 |
+
<span class="text-2xl font-black text-slate-900">@reward.PointCost.ToString("N0")</span>
|
| 98 |
+
<span class="text-[10px] block font-bold text-slate-400 uppercase">pts required</span>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<h3 class="text-base font-bold text-slate-900 mb-1 truncate">@reward.Name</h3>
|
| 102 |
+
<p class="text-sm text-slate-500 mb-5 line-clamp-2 h-10">@reward.Description</p>
|
| 103 |
+
<div class="flex items-center justify-between pt-4 border-t border-slate-100">
|
| 104 |
+
<span class="text-xs font-semibold text-slate-500">@reward.StockQuantity in stock</span>
|
| 105 |
+
@if (canAfford)
|
| 106 |
+
{
|
| 107 |
+
<button @onclick="() => RedeemAsync(reward)"
|
| 108 |
+
class="bg-slate-900 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-amber-500 transition-all active:scale-95">
|
| 109 |
+
Redeem
|
| 110 |
+
</button>
|
| 111 |
+
}
|
| 112 |
+
else
|
| 113 |
+
{
|
| 114 |
+
<span class="text-xs font-bold text-slate-400 bg-slate-100 px-3 py-2 rounded-xl">Need more pts</span>
|
| 115 |
+
}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
@if (reward.StockQuantity < 10)
|
| 119 |
+
{
|
| 120 |
+
<span class="absolute top-3 right-3 animate-pulse bg-rose-500 text-white text-[9px] font-black px-2 py-0.5 rounded-full uppercase">Limited</span>
|
| 121 |
+
}
|
| 122 |
+
</div>
|
| 123 |
+
}
|
| 124 |
+
</div>
|
| 125 |
+
}
|
| 126 |
+
<div class="mt-8 p-7 bg-slate-900 rounded-3xl text-white flex flex-col md:flex-row items-center justify-between gap-5">
|
| 127 |
+
<div>
|
| 128 |
+
<h2 class="text-lg font-bold mb-1">How to earn more points?</h2>
|
| 129 |
+
<p class="text-slate-400 text-sm">Borrow books, subscribe, and return on time to earn points!</p>
|
| 130 |
+
</div>
|
| 131 |
+
<a href="Books" class="bg-amber-400 text-slate-900 px-6 py-3 rounded-2xl font-black hover:bg-amber-300 transition-all text-sm whitespace-nowrap">Browse Books →</a>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
}
|
| 135 |
+
else if (_activeTab == "pending")
|
| 136 |
+
{
|
| 137 |
+
<div class="animate-in fade-in duration-300">
|
| 138 |
+
@if (!_pendingRedemptions.Any())
|
| 139 |
+
{
|
| 140 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
|
| 141 |
+
<p class="text-slate-400 font-medium">No pending redemptions — you're all clear!</p>
|
| 142 |
+
</div>
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
<div class="space-y-3">
|
| 147 |
+
@foreach (var r in _pendingRedemptions)
|
| 148 |
+
{
|
| 149 |
+
<div class="flex items-center justify-between bg-white border border-amber-100 rounded-2xl px-6 py-4 shadow-sm">
|
| 150 |
+
<div class="flex items-center gap-4">
|
| 151 |
+
<div class="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center text-amber-500 flex-shrink-0">
|
| 152 |
+
<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 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>
|
| 153 |
+
</div>
|
| 154 |
+
<div>
|
| 155 |
+
<p class="font-bold text-slate-900 text-sm">@r.RewardName</p>
|
| 156 |
+
<p class="text-xs text-slate-400">Claimed @r.RedeemedAt.ToString("MMM dd, yyyy") · @r.PointCost.ToString("N0") pts</p>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-700">
|
| 160 |
+
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
| 161 |
+
Awaiting Fulfillment
|
| 162 |
+
</span>
|
| 163 |
+
</div>
|
| 164 |
+
}
|
| 165 |
+
</div>
|
| 166 |
+
}
|
| 167 |
+
</div>
|
| 168 |
+
}
|
| 169 |
+
else if (_activeTab == "history")
|
| 170 |
+
{
|
| 171 |
+
<div class="animate-in fade-in duration-300">
|
| 172 |
+
@if (!_pointsHistory.Any())
|
| 173 |
+
{
|
| 174 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
|
| 175 |
+
<p class="text-slate-400 font-medium">No points history yet. Start borrowing books to earn points!</p>
|
| 176 |
+
</div>
|
| 177 |
+
}
|
| 178 |
+
else
|
| 179 |
+
{
|
| 180 |
+
<div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
|
| 181 |
+
<table class="min-w-full divide-y divide-slate-100">
|
| 182 |
+
<thead class="bg-slate-50">
|
| 183 |
+
<tr>
|
| 184 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Date</th>
|
| 185 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Type</th>
|
| 186 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Details</th>
|
| 187 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Spent on</th>
|
| 188 |
+
<th class="px-6 py-3 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">Points</th>
|
| 189 |
+
</tr>
|
| 190 |
+
</thead>
|
| 191 |
+
<tbody class="divide-y divide-slate-50">
|
| 192 |
+
@foreach (var h in _pointsHistory.OrderByDescending(x => x.CreatedAt))
|
| 193 |
+
{
|
| 194 |
+
var isPos = h.PointDelta >= 0;
|
| 195 |
+
var label = PrettyEvent(h.EventKey, h.PointDelta);
|
| 196 |
+
string? spentOn = h.RewardName;
|
| 197 |
+
|
| 198 |
+
<tr class="hover:bg-slate-50 transition-colors">
|
| 199 |
+
<td class="px-6 py-3 text-sm text-slate-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
|
| 200 |
+
<td class="px-6 py-3">
|
| 201 |
+
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold @(isPos ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-700")">@label</span>
|
| 202 |
+
</td>
|
| 203 |
+
<td class="px-6 py-3 text-sm text-slate-600 max-w-xs truncate">@h.Description</td>
|
| 204 |
+
<td class="px-6 py-3 text-sm text-slate-600 max-w-xs truncate">
|
| 205 |
+
@if (!string.IsNullOrWhiteSpace(spentOn))
|
| 206 |
+
{
|
| 207 |
+
<span class="font-semibold text-slate-700">@spentOn</span>
|
| 208 |
+
}
|
| 209 |
+
else
|
| 210 |
+
{
|
| 211 |
+
<span class="text-slate-400">—</span>
|
| 212 |
+
}
|
| 213 |
+
</td>
|
| 214 |
+
<td class="px-6 py-3 text-right font-black text-base @(isPos ? "text-emerald-600" : "text-rose-600")">
|
| 215 |
+
@(isPos ? "+" : "")@h.PointDelta.ToString("N0")
|
| 216 |
+
</td>
|
| 217 |
+
</tr>
|
| 218 |
+
}
|
| 219 |
+
</tbody>
|
| 220 |
+
</table>
|
| 221 |
+
</div>
|
| 222 |
+
}
|
| 223 |
+
</div>
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
@code {
|
| 228 |
+
private string _activeTab = "redeem";
|
| 229 |
+
private bool _isLoading = true;
|
| 230 |
+
private LoyaltyAccountDto? _account;
|
| 231 |
+
private IEnumerable<LoyaltyRewardDto> _availableRewards = Enumerable.Empty<LoyaltyRewardDto>();
|
| 232 |
+
private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 233 |
+
private IEnumerable<PointHistoryEntryDto> _pointsHistory = Enumerable.Empty<PointHistoryEntryDto>();
|
| 234 |
+
|
| 235 |
+
protected override async Task OnInitializedAsync()
|
| 236 |
+
{
|
| 237 |
+
await LoadDataAsync();
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
private async Task LoadDataAsync()
|
| 241 |
+
{
|
| 242 |
+
_isLoading = true;
|
| 243 |
+
try
|
| 244 |
+
{
|
| 245 |
+
_account = await ApiClient.GetMyLoyaltyAccountAsync();
|
| 246 |
+
_availableRewards = await ApiClient.GetActiveRewardsAsync();
|
| 247 |
+
_pendingRedemptions = await ApiClient.GetMyPendingRedemptionsAsync();
|
| 248 |
+
|
| 249 |
+
if (_account != null)
|
| 250 |
+
{
|
| 251 |
+
_pointsHistory = await ApiClient.GetPointsHistoryAsync(_account.AccountId);
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
catch (Exception ex)
|
| 255 |
+
{
|
| 256 |
+
Console.WriteLine($"Error loading rewards: {ex.Message}");
|
| 257 |
+
}
|
| 258 |
+
finally
|
| 259 |
+
{
|
| 260 |
+
_isLoading = false;
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
private async Task RedeemAsync(LoyaltyRewardDto reward)
|
| 265 |
+
{
|
| 266 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Redeem \"{reward.Name}\" for {reward.PointCost:N0} points?");
|
| 267 |
+
if (!confirmed) return;
|
| 268 |
+
|
| 269 |
+
var result = await ApiClient.ClaimRewardAsync(reward.Id);
|
| 270 |
+
if (result.Success)
|
| 271 |
+
{
|
| 272 |
+
await JS.InvokeVoidAsync("showToast", result.Message, "success");
|
| 273 |
+
await LoadDataAsync();
|
| 274 |
+
}
|
| 275 |
+
else
|
| 276 |
+
{
|
| 277 |
+
await JS.InvokeVoidAsync("showToast", result.Message, "error");
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
private string PrettyEvent(string? eventKey, double points)
|
| 282 |
+
{
|
| 283 |
+
var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
|
| 284 |
+
|
| 285 |
+
return key switch
|
| 286 |
+
{
|
| 287 |
+
"RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
|
| 288 |
+
"SIGNUP" or "REGISTER" => "SIGNUP",
|
| 289 |
+
"SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
|
| 290 |
+
"REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
|
| 291 |
+
_ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
|
| 292 |
+
};
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
BlazorFrontend/Components/Pages/RewardsManage.razor
CHANGED
|
@@ -1,12 +1,314 @@
|
|
| 1 |
@page "/Rewards/Manage"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
<PageTitle>
|
| 4 |
|
| 5 |
-
<div class="mb-
|
| 6 |
-
<
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</div>
|
| 9 |
|
| 10 |
-
<div class="
|
| 11 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@page "/Rewards/Manage"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject IJSRuntime JS
|
| 7 |
+
@attribute [Authorize(Roles = "Librarian")]
|
| 8 |
|
| 9 |
+
<PageTitle>Rewards Management</PageTitle>
|
| 10 |
|
| 11 |
+
<div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
| 12 |
+
<div>
|
| 13 |
+
<h1 class="text-3xl font-extrabold tracking-tight text-slate-900">Rewards Management</h1>
|
| 14 |
+
<p class="text-slate-500 mt-1">Fulfill pending redemptions and review all members' loyalty activity.</p>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="flex items-center gap-4">
|
| 17 |
+
<div class="bg-amber-50 border border-amber-200 px-5 py-2.5 rounded-xl text-center shadow-sm">
|
| 18 |
+
<p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Pending</p>
|
| 19 |
+
<p class="text-2xl font-black text-slate-900">@_pendingRedemptions.Count()</p>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="bg-slate-50 border border-slate-200 px-5 py-2.5 rounded-xl text-center shadow-sm">
|
| 22 |
+
<p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Members w/ History</p>
|
| 23 |
+
<p class="text-2xl font-black text-slate-900">@_allMembersHistory.Count()</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
</div>
|
| 27 |
|
| 28 |
+
<div class="mb-6 border-b border-slate-200">
|
| 29 |
+
<nav class="-mb-px flex gap-6">
|
| 30 |
+
<button @onclick='() => _activeTab = "pending"'
|
| 31 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "pending" ? "border-slate-900 text-slate-900" : "border-transparent text-slate-400 hover:text-slate-700")'>
|
| 32 |
+
<span class="inline-flex items-center gap-1.5">
|
| 33 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 34 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 35 |
+
</svg>
|
| 36 |
+
Pending Redemptions
|
| 37 |
+
</span>
|
| 38 |
+
@if (_pendingRedemptions.Any())
|
| 39 |
+
{
|
| 40 |
+
<span class="ml-1 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[10px] font-black rounded-full bg-rose-500 text-white">@_pendingRedemptions.Count()</span>
|
| 41 |
+
}
|
| 42 |
+
</button>
|
| 43 |
+
<button @onclick='() => _activeTab = "history"'
|
| 44 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "history" ? "border-slate-900 text-slate-900" : "border-transparent text-slate-400 hover:text-slate-700")'>
|
| 45 |
+
<span class="inline-flex items-center gap-1.5">
|
| 46 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 47 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-6m4 6V7m4 10v-3M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
| 48 |
+
</svg>
|
| 49 |
+
All Members' Points History
|
| 50 |
+
</span>
|
| 51 |
+
</button>
|
| 52 |
+
</nav>
|
| 53 |
</div>
|
| 54 |
+
|
| 55 |
+
@if (_isLoading)
|
| 56 |
+
{
|
| 57 |
+
<div class="py-20 flex justify-center">
|
| 58 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-slate-900"></div>
|
| 59 |
+
</div>
|
| 60 |
+
}
|
| 61 |
+
else
|
| 62 |
+
{
|
| 63 |
+
@if (_activeTab == "pending")
|
| 64 |
+
{
|
| 65 |
+
<div class="animate-in fade-in duration-300">
|
| 66 |
+
@if (!_pendingRedemptions.Any())
|
| 67 |
+
{
|
| 68 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
|
| 69 |
+
<svg class="w-16 h-16 mx-auto opacity-20 text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 70 |
+
<p class="text-slate-500 font-medium">No pending redemptions. All caught up!</p>
|
| 71 |
+
</div>
|
| 72 |
+
}
|
| 73 |
+
else
|
| 74 |
+
{
|
| 75 |
+
<div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
|
| 76 |
+
<table class="min-w-full divide-y divide-slate-100">
|
| 77 |
+
<thead class="bg-slate-50">
|
| 78 |
+
<tr>
|
| 79 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Member</th>
|
| 80 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Reward</th>
|
| 81 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Points</th>
|
| 82 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Claimed</th>
|
| 83 |
+
<th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Status</th>
|
| 84 |
+
<th class="px-6 py-3 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">Action</th>
|
| 85 |
+
</tr>
|
| 86 |
+
</thead>
|
| 87 |
+
<tbody class="divide-y divide-slate-100">
|
| 88 |
+
@foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt))
|
| 89 |
+
{
|
| 90 |
+
<tr class="hover:bg-amber-50/40 transition-colors">
|
| 91 |
+
<td class="px-6 py-4">
|
| 92 |
+
<div class="flex items-center gap-3">
|
| 93 |
+
<div class="w-8 h-8 rounded-full bg-slate-900 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
| 94 |
+
@(r.ExternalUserId.Length >= 2 ? r.ExternalUserId.Substring(0, 2).ToUpper() : "U")
|
| 95 |
+
</div>
|
| 96 |
+
<span class="text-sm font-medium text-slate-700 font-mono text-xs">@r.ExternalUserId</span>
|
| 97 |
+
</div>
|
| 98 |
+
</td>
|
| 99 |
+
<td class="px-6 py-4">
|
| 100 |
+
<p class="text-sm font-bold text-slate-900">@r.RewardName</p>
|
| 101 |
+
<p class="text-xs text-slate-400">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)...</p>
|
| 102 |
+
</td>
|
| 103 |
+
<td class="px-6 py-4 text-sm font-bold text-slate-700">@r.PointCost.ToString("N0") pts</td>
|
| 104 |
+
<td class="px-6 py-4 text-sm text-slate-500 whitespace-nowrap">@r.RedeemedAt.ToString("MMM dd, yyyy")</td>
|
| 105 |
+
<td class="px-6 py-4">
|
| 106 |
+
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-700">
|
| 107 |
+
<span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
| 108 |
+
Pending
|
| 109 |
+
</span>
|
| 110 |
+
</td>
|
| 111 |
+
<td class="px-6 py-4 text-right">
|
| 112 |
+
<button @onclick="() => FulfillAsync(r)"
|
| 113 |
+
class="inline-flex items-center gap-1.5 bg-emerald-600 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-emerald-700 transition-all active:scale-95">
|
| 114 |
+
<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>
|
| 115 |
+
Fulfill
|
| 116 |
+
</button>
|
| 117 |
+
</td>
|
| 118 |
+
</tr>
|
| 119 |
+
}
|
| 120 |
+
</tbody>
|
| 121 |
+
</table>
|
| 122 |
+
</div>
|
| 123 |
+
}
|
| 124 |
+
</div>
|
| 125 |
+
}
|
| 126 |
+
else if (_activeTab == "history")
|
| 127 |
+
{
|
| 128 |
+
<div class="animate-in fade-in duration-300">
|
| 129 |
+
@if (!_allMembersHistory.Any())
|
| 130 |
+
{
|
| 131 |
+
<div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
|
| 132 |
+
<p class="text-slate-400 font-medium">No loyalty history found for any members yet.</p>
|
| 133 |
+
</div>
|
| 134 |
+
}
|
| 135 |
+
else
|
| 136 |
+
{
|
| 137 |
+
<!-- Search bar -->
|
| 138 |
+
<div class="mb-4">
|
| 139 |
+
<input type="text" placeholder="Filter by member name or email..."
|
| 140 |
+
class="w-full max-w-sm px-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 bg-white"
|
| 141 |
+
@bind="_searchQuery" @bind:event="oninput" />
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div class="space-y-4">
|
| 145 |
+
@foreach (var member in FilteredMembers)
|
| 146 |
+
{
|
| 147 |
+
<div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
|
| 148 |
+
<!-- Member header -->
|
| 149 |
+
<div class="flex items-center justify-between px-6 py-4 bg-slate-50 border-b border-slate-100 cursor-pointer"
|
| 150 |
+
@onclick="() => ToggleMember(member.AccountId)">
|
| 151 |
+
<div class="flex items-center gap-4">
|
| 152 |
+
<div class="w-10 h-10 rounded-full bg-slate-900 flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
| 153 |
+
@(member.UserName.Length >= 2 ? member.UserName.Substring(0, 2).ToUpper() : "?")
|
| 154 |
+
</div>
|
| 155 |
+
<div>
|
| 156 |
+
<p class="font-bold text-slate-900 text-sm">@member.UserName</p>
|
| 157 |
+
<p class="text-xs text-slate-400">@member.UserEmail</p>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
<div class="flex items-center gap-6">
|
| 161 |
+
<div class="text-right">
|
| 162 |
+
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Balance</p>
|
| 163 |
+
<p class="font-black text-slate-900">@member.CurrentBalance.ToString("N0") pts</p>
|
| 164 |
+
</div>
|
| 165 |
+
<div class="text-right">
|
| 166 |
+
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Tier</p>
|
| 167 |
+
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 text-slate-700">@member.Tier</span>
|
| 168 |
+
</div>
|
| 169 |
+
<div class="text-right">
|
| 170 |
+
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Transactions</p>
|
| 171 |
+
<p class="font-black text-slate-900">@member.History.Count()</p>
|
| 172 |
+
</div>
|
| 173 |
+
<svg class="w-4 h-4 text-slate-400 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>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- Collapsible history table -->
|
| 178 |
+
@if (_expandedMembers.Contains(member.AccountId))
|
| 179 |
+
{
|
| 180 |
+
<div class="overflow-x-auto animate-in slide-in-from-top-2 duration-300">
|
| 181 |
+
@if (!member.History.Any())
|
| 182 |
+
{
|
| 183 |
+
<p class="px-6 py-4 text-sm text-slate-400 italic">No transactions found for this member.</p>
|
| 184 |
+
}
|
| 185 |
+
else
|
| 186 |
+
{
|
| 187 |
+
<table class="min-w-full divide-y divide-slate-50">
|
| 188 |
+
<thead>
|
| 189 |
+
<tr class="bg-slate-50/50">
|
| 190 |
+
<th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Date</th>
|
| 191 |
+
<th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Type</th>
|
| 192 |
+
<th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Details</th>
|
| 193 |
+
<th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Spent on</th>
|
| 194 |
+
<th class="px-6 py-2 text-right text-[10px] font-bold text-slate-400 uppercase tracking-wider">Points</th>
|
| 195 |
+
</tr>
|
| 196 |
+
</thead>
|
| 197 |
+
<tbody class="divide-y divide-slate-50">
|
| 198 |
+
@foreach (var h in member.History.OrderByDescending(x => x.CreatedAt))
|
| 199 |
+
{
|
| 200 |
+
var isPos = h.PointDelta >= 0;
|
| 201 |
+
var label = PrettyEvent(h.EventKey, h.PointDelta);
|
| 202 |
+
string? spentOn = h.RewardName;
|
| 203 |
+
|
| 204 |
+
<tr class="hover:bg-slate-50 transition-colors">
|
| 205 |
+
<td class="px-6 py-2.5 text-xs text-slate-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
|
| 206 |
+
<td class="px-6 py-2.5">
|
| 207 |
+
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold @(isPos ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-700")">@label</span>
|
| 208 |
+
</td>
|
| 209 |
+
<td class="px-6 py-2.5 text-xs text-slate-600 max-w-xs truncate">@h.Description</td>
|
| 210 |
+
<td class="px-6 py-2.5 text-xs text-slate-600 max-w-xs truncate">
|
| 211 |
+
@if (!string.IsNullOrWhiteSpace(spentOn))
|
| 212 |
+
{
|
| 213 |
+
<span class="font-semibold text-slate-700">@spentOn</span>
|
| 214 |
+
}
|
| 215 |
+
else
|
| 216 |
+
{
|
| 217 |
+
<span class="text-slate-400">—</span>
|
| 218 |
+
}
|
| 219 |
+
</td>
|
| 220 |
+
<td class="px-6 py-2.5 text-right font-black text-sm @(isPos ? "text-emerald-600" : "text-rose-600")">
|
| 221 |
+
@(isPos ? "+" : "")@h.PointDelta.ToString("N0")
|
| 222 |
+
</td>
|
| 223 |
+
</tr>
|
| 224 |
+
}
|
| 225 |
+
</tbody>
|
| 226 |
+
</table>
|
| 227 |
+
}
|
| 228 |
+
</div>
|
| 229 |
+
}
|
| 230 |
+
</div>
|
| 231 |
+
}
|
| 232 |
+
</div>
|
| 233 |
+
}
|
| 234 |
+
</div>
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
@code {
|
| 239 |
+
private string _activeTab = "pending";
|
| 240 |
+
private bool _isLoading = true;
|
| 241 |
+
private string _searchQuery = "";
|
| 242 |
+
private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 243 |
+
private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>();
|
| 244 |
+
private HashSet<string> _expandedMembers = new();
|
| 245 |
+
|
| 246 |
+
protected override async Task OnInitializedAsync()
|
| 247 |
+
{
|
| 248 |
+
await LoadDataAsync();
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
private async Task LoadDataAsync()
|
| 252 |
+
{
|
| 253 |
+
_isLoading = true;
|
| 254 |
+
try
|
| 255 |
+
{
|
| 256 |
+
_pendingRedemptions = await ApiClient.GetPendingRedemptionsAsync();
|
| 257 |
+
_allMembersHistory = await ApiClient.GetAllMembersPointsHistoryAsync();
|
| 258 |
+
}
|
| 259 |
+
catch (Exception ex)
|
| 260 |
+
{
|
| 261 |
+
Console.WriteLine($"Error loading management data: {ex.Message}");
|
| 262 |
+
}
|
| 263 |
+
finally
|
| 264 |
+
{
|
| 265 |
+
_isLoading = false;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
private IEnumerable<UserPointsHistoryDto> FilteredMembers =>
|
| 270 |
+
string.IsNullOrWhiteSpace(_searchQuery)
|
| 271 |
+
? _allMembersHistory
|
| 272 |
+
: _allMembersHistory.Where(m => m.UserName.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
| 273 |
+
m.UserEmail.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
|
| 274 |
+
|
| 275 |
+
private void ToggleMember(string accountId)
|
| 276 |
+
{
|
| 277 |
+
if (_expandedMembers.Contains(accountId))
|
| 278 |
+
_expandedMembers.Remove(accountId);
|
| 279 |
+
else
|
| 280 |
+
_expandedMembers.Add(accountId);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
private async Task FulfillAsync(LoyaltyRedemptionDto redemption)
|
| 284 |
+
{
|
| 285 |
+
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Fulfill this redemption and grant membership to the member?");
|
| 286 |
+
if (!confirmed) return;
|
| 287 |
+
|
| 288 |
+
var result = await ApiClient.FulfillRedemptionAsync(redemption.Id);
|
| 289 |
+
if (result.Success)
|
| 290 |
+
{
|
| 291 |
+
await JS.InvokeVoidAsync("showToast", result.Message, "success");
|
| 292 |
+
await LoadDataAsync();
|
| 293 |
+
}
|
| 294 |
+
else
|
| 295 |
+
{
|
| 296 |
+
await JS.InvokeVoidAsync("showToast", result.Message, "error");
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
private string PrettyEvent(string? eventKey, double points)
|
| 301 |
+
{
|
| 302 |
+
var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
|
| 303 |
+
|
| 304 |
+
return key switch
|
| 305 |
+
{
|
| 306 |
+
"RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
|
| 307 |
+
"SIGNUP" or "REGISTER" => "SIGNUP",
|
| 308 |
+
"SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
|
| 309 |
+
"REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
|
| 310 |
+
_ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
|
| 311 |
+
};
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
BlazorFrontend/Models/Dtos/LibraryDtos.cs
CHANGED
|
@@ -41,6 +41,7 @@ namespace BlazorFrontend.Models.Dtos
|
|
| 41 |
public int AvailableCopies { get; set; }
|
| 42 |
public DateTime CreatedAt { get; set; }
|
| 43 |
public DateTime? UpdatedAt { get; set; }
|
|
|
|
| 44 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 45 |
}
|
| 46 |
|
|
@@ -57,6 +58,7 @@ namespace BlazorFrontend.Models.Dtos
|
|
| 57 |
public string Author { get; set; } = string.Empty;
|
| 58 |
public string? Description { get; set; }
|
| 59 |
public int TotalCopies { get; set; }
|
|
|
|
| 60 |
public List<int>? CategoryIds { get; set; }
|
| 61 |
}
|
| 62 |
|
|
@@ -66,6 +68,7 @@ namespace BlazorFrontend.Models.Dtos
|
|
| 66 |
public string Author { get; set; } = string.Empty;
|
| 67 |
public string? Description { get; set; }
|
| 68 |
public int TotalCopies { get; set; }
|
|
|
|
| 69 |
public List<int>? CategoryIds { get; set; }
|
| 70 |
}
|
| 71 |
|
|
|
|
| 41 |
public int AvailableCopies { get; set; }
|
| 42 |
public DateTime CreatedAt { get; set; }
|
| 43 |
public DateTime? UpdatedAt { get; set; }
|
| 44 |
+
public string? CoverUrl { get; set; }
|
| 45 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 46 |
}
|
| 47 |
|
|
|
|
| 58 |
public string Author { get; set; } = string.Empty;
|
| 59 |
public string? Description { get; set; }
|
| 60 |
public int TotalCopies { get; set; }
|
| 61 |
+
public string? CoverUrl { get; set; }
|
| 62 |
public List<int>? CategoryIds { get; set; }
|
| 63 |
}
|
| 64 |
|
|
|
|
| 68 |
public string Author { get; set; } = string.Empty;
|
| 69 |
public string? Description { get; set; }
|
| 70 |
public int TotalCopies { get; set; }
|
| 71 |
+
public string? CoverUrl { get; set; }
|
| 72 |
public List<int>? CategoryIds { get; set; }
|
| 73 |
}
|
| 74 |
|
BlazorFrontend/Providers/JwtAuthenticationStateProvider.cs
CHANGED
|
@@ -27,7 +27,16 @@ public class JwtAuthenticationStateProvider : AuthenticationStateProvider
|
|
| 27 |
var handler = new JwtSecurityTokenHandler();
|
| 28 |
var jwt = handler.ReadJwtToken(Token);
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
var user = new ClaimsPrincipal(identity);
|
| 32 |
|
| 33 |
return Task.FromResult(new AuthenticationState(user));
|
|
|
|
| 27 |
var handler = new JwtSecurityTokenHandler();
|
| 28 |
var jwt = handler.ReadJwtToken(Token);
|
| 29 |
|
| 30 |
+
// JwtSecurityTokenHandler maps standard ClaimTypes to short names (unique_name, role) in the JWT payload.
|
| 31 |
+
// When reading back raw claims, we need to specify which claim types to use for the Identity properties.
|
| 32 |
+
var identity = new ClaimsIdentity(jwt.Claims, "jwt", "unique_name", "role");
|
| 33 |
+
|
| 34 |
+
// If unique_name is not present, fallback to "name" or default ClaimTypes.Name
|
| 35 |
+
if (identity.Name == null)
|
| 36 |
+
{
|
| 37 |
+
identity = new ClaimsIdentity(jwt.Claims, "jwt", "name", "role");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
var user = new ClaimsPrincipal(identity);
|
| 41 |
|
| 42 |
return Task.FromResult(new AuthenticationState(user));
|
Frontend/Models/Dtos/LibraryDtos.cs
CHANGED
|
@@ -41,6 +41,7 @@ namespace Frontend.Models.Dtos
|
|
| 41 |
public int AvailableCopies { get; set; }
|
| 42 |
public DateTime CreatedAt { get; set; }
|
| 43 |
public DateTime? UpdatedAt { get; set; }
|
|
|
|
| 44 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 45 |
}
|
| 46 |
|
|
|
|
| 41 |
public int AvailableCopies { get; set; }
|
| 42 |
public DateTime CreatedAt { get; set; }
|
| 43 |
public DateTime? UpdatedAt { get; set; }
|
| 44 |
+
public string? CoverUrl { get; set; }
|
| 45 |
public List<BookCategoryDto> Categories { get; set; } = new();
|
| 46 |
}
|
| 47 |
|
scratch/check_jwt.cs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System;
|
| 2 |
+
using System.IdentityModel.Tokens.Jwt;
|
| 3 |
+
using System.Security.Claims;
|
| 4 |
+
using System.Collections.Generic;
|
| 5 |
+
using System.Linq;
|
| 6 |
+
|
| 7 |
+
var handler = new JwtSecurityTokenHandler();
|
| 8 |
+
var claims = new List<Claim>
|
| 9 |
+
{
|
| 10 |
+
new Claim(ClaimTypes.Name, "John Doe"),
|
| 11 |
+
new Claim(ClaimTypes.Role, "Admin")
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
var tokenDescriptor = new Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor
|
| 15 |
+
{
|
| 16 |
+
Subject = new ClaimsIdentity(claims)
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
var token = handler.CreateToken(tokenDescriptor);
|
| 20 |
+
var tokenString = handler.WriteToken(token);
|
| 21 |
+
|
| 22 |
+
Console.WriteLine("Token JSON payload:");
|
| 23 |
+
var jwt = handler.ReadJwtToken(tokenString);
|
| 24 |
+
foreach (var claim in jwt.Claims)
|
| 25 |
+
{
|
| 26 |
+
Console.WriteLine($"{claim.Type}: {claim.Value}");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
var identity = new ClaimsIdentity(jwt.Claims, "jwt");
|
| 30 |
+
Console.WriteLine($"Identity Name: {identity.Name ?? "NULL"}");
|
| 31 |
+
Console.WriteLine($"Identity NameClaimType: {identity.NameClaimType}");
|
| 32 |
+
|
| 33 |
+
var identity2 = new ClaimsIdentity(jwt.Claims, "jwt", "unique_name", "role");
|
| 34 |
+
Console.WriteLine($"Identity2 Name: {identity2.Name ?? "NULL"}");
|