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 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
- @inject HttpClient Http
5
-
 
 
6
 
7
  <PageTitle>Books Catalog</PageTitle>
8
 
9
- <div class="mb-6">
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="card p-6 text-center">
17
- <p class="text-slate-600">Loading books...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </div>
19
- }
20
- else if (!_books.Any())
21
- {
22
- <div class="card p-6 text-center">
23
- <p class="text-slate-600">No books found in the catalog.</p>
 
 
 
 
 
 
 
 
 
24
  </div>
25
- }
26
- else
27
- {
28
- <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
29
- @foreach (var book in _books)
30
- {
31
- <div class="card flex flex-col h-full hover:shadow-md transition-shadow">
32
- <div class="p-5 flex-grow">
33
- <h3 class="font-semibold text-lg text-slate-900 line-clamp-2">@book.Title</h3>
34
- <p class="text-sm text-slate-500 mt-1">By @book.Author</p>
35
- <div class="mt-4 flex flex-wrap gap-2">
36
- @if (book.Categories != null && book.Categories.Any())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  {
38
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800">
39
- @string.Join(", ", book.Categories.Select(c => c.Name))
40
- </span>
41
  }
42
- @if (book.AvailableCopies > 0)
43
  {
44
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
45
- Available (@book.AvailableCopies)
46
- </span>
 
47
  }
48
- else
 
49
  {
50
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
51
- Checked Out
52
- </span>
53
  }
54
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  </div>
56
- <div class="px-5 py-4 border-t border-slate-100 mt-auto">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="w-full btn-secondary py-2 block text-center">Sign In to Borrow</a>
65
  </NotAuthorized>
66
  </AuthorizeView>
67
  </div>
68
  </div>
69
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  </div>
71
  }
72
 
73
  @code {
74
- private IEnumerable<BookDto>? _books;
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  protected override async Task OnInitializedAsync()
77
  {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  try
79
  {
80
- _books = await Http.GetFromJsonAsync<IEnumerable<BookDto>>("api/books");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
82
  catch (Exception ex)
83
  {
84
- Console.WriteLine("Error fetching books: " + ex.Message);
85
- _books = new List<BookDto>();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Active Loans</PageTitle>
4
 
5
- <div class="mb-6">
6
- <h1 class="text-2xl font-bold text-slate-900">Active Loans</h1>
7
- <p class="text-sm text-slate-500 mt-1">View your current borrowed books and due dates.</p>
8
  </div>
9
 
10
- <div class="card p-6">
11
- <p class="text-slate-600">Borrowings list will be displayed here.</p>
12
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
6
- <h1 class="text-2xl font-bold text-slate-900">Manage Borrowings</h1>
7
- <p class="text-sm text-slate-500 mt-1">Librarian view to manage all borrowings.</p>
 
 
 
 
 
 
 
 
 
 
8
  </div>
9
 
10
- <div class="card p-6">
11
- <p class="text-slate-600">Borrowings management will be displayed here.</p>
12
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- @inject HttpClient Http
 
 
 
5
 
6
  <PageTitle>Dashboard</PageTitle>
7
 
8
- <div class="mb-6">
9
- <h1 class="text-2xl font-bold text-slate-900">Dashboard</h1>
10
- <p class="text-sm text-slate-500 mt-1">Welcome back to the Library Management System.</p>
11
  </div>
12
 
13
- <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
14
- <div class="card p-6">
15
- <h3 class="text-lg font-medium text-slate-900">Total Books</h3>
16
- <p class="text-3xl font-bold text-slate-700 mt-2">@_totalBooks</p>
17
  </div>
18
- <div class="card p-6">
19
- <h3 class="text-lg font-medium text-slate-900">Categories</h3>
20
- <p class="text-3xl font-bold text-slate-700 mt-2">@_totalCategories</p>
21
- </div>
22
- <AuthorizeView Roles="Librarian">
23
  <Authorized>
24
- <div class="card p-6">
25
- <h3 class="text-lg font-medium text-slate-900">Total Users</h3>
26
- <p class="text-3xl font-bold text-slate-700 mt-2">@_totalUsers</p>
27
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  @code {
39
- private string _totalBooks = "--";
40
- private string _totalCategories = "--";
41
- private string _totalUsers = "--";
42
- private string _myLoans = "--";
 
 
 
43
 
44
  protected override async Task OnInitializedAsync()
45
  {
46
  try
47
  {
48
- var books = await Http.GetFromJsonAsync<IEnumerable<BookDto>>("api/books");
49
- _totalBooks = books?.Count().ToString() ?? "0";
 
 
50
 
51
- var categories = await Http.GetFromJsonAsync<IEnumerable<CategoryDto>>("api/categories");
52
- _totalCategories = categories?.Count().ToString() ?? "0";
 
53
 
54
- // These endpoints might require auth, we catch exceptions if the user is unauthenticated or unauthorized
55
- try
56
  {
57
- var users = await Http.GetFromJsonAsync<IEnumerable<UserDto>>("api/users");
58
- _totalUsers = users?.Count().ToString() ?? "0";
59
- }
60
- catch { }
 
 
 
 
61
 
62
- try
63
- {
64
- var loans = await Http.GetFromJsonAsync<IEnumerable<BorrowingDto>>("api/borrowings/me");
65
- _myLoans = loans?.Count().ToString() ?? "0";
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
67
- catch { }
68
  }
69
  catch (Exception ex)
70
  {
71
- Console.WriteLine("Error fetching dashboard data: " + ex.Message);
 
 
 
 
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
- <div class="mb-6">
6
- <h1 class="text-2xl font-bold text-slate-900">Members</h1>
7
- <p class="text-sm text-slate-500 mt-1">Manage library members.</p>
8
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div class="mb-6">
6
- <h1 class="text-2xl font-bold text-slate-900">My Membership</h1>
7
- <p class="text-sm text-slate-500 mt-1">Manage your library membership details.</p>
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") &bull; 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 &rarr;</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
- <h1 class="text-2xl font-bold text-slate-900">Rewards</h1>
7
- <p class="text-sm text-slate-500 mt-1">View your earned rewards and points.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  </div>
9
 
10
- <div class="card p-6">
11
- <p class="text-slate-600">Rewards details will be displayed here.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Manage Rewards</PageTitle>
4
 
5
- <div class="mb-6">
6
- <h1 class="text-2xl font-bold text-slate-900">Manage Rewards</h1>
7
- <p class="text-sm text-slate-500 mt-1">Librarian view to manage all rewards.</p>
 
 
 
 
 
 
 
 
 
 
 
 
8
  </div>
9
 
10
- <div class="card p-6">
11
- <p class="text-slate-600">Rewards management will be displayed here.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- var identity = new ClaimsIdentity(jwt.Claims, "jwt");
 
 
 
 
 
 
 
 
 
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"}");