Yuyuqt commited on
Commit
ec76476
·
1 Parent(s): 313d29f

add: small fixes, UI designing

Browse files
Backend/Features/Loyalty/LoyaltyController.cs CHANGED
@@ -119,7 +119,34 @@ namespace Backend.Features.Loyalty
119
  [Authorize(Roles = "Librarian")]
120
  public async Task<IActionResult> GetPendingRedemptions()
121
  {
122
- var redemptions = await _loyaltyService.GetPendingRedemptionsAsync();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  return Ok(redemptions);
124
  }
125
 
 
119
  [Authorize(Roles = "Librarian")]
120
  public async Task<IActionResult> GetPendingRedemptions()
121
  {
122
+ var redemptions = (await _loyaltyService.GetPendingRedemptionsAsync()).ToList();
123
+
124
+ // Map names
125
+ var guidList = redemptions
126
+ .Where(r => !string.IsNullOrEmpty(r.ExternalUserId) && Guid.TryParse(r.ExternalUserId, out _))
127
+ .Select(r => Guid.Parse(r.ExternalUserId))
128
+ .Distinct()
129
+ .ToList();
130
+
131
+ var users = await _context.Users
132
+ .Where(u => guidList.Contains(u.Id))
133
+ .Select(u => new { u.Id, u.FullName })
134
+ .ToListAsync();
135
+
136
+ var userMap = users.ToDictionary(u => u.Id.ToString(), u => u.FullName);
137
+
138
+ foreach (var r in redemptions)
139
+ {
140
+ if (!string.IsNullOrEmpty(r.ExternalUserId) && userMap.TryGetValue(r.ExternalUserId, out var name))
141
+ {
142
+ r.UserName = name;
143
+ }
144
+ else
145
+ {
146
+ r.UserName = "Unknown Member";
147
+ }
148
+ }
149
+
150
  return Ok(redemptions);
151
  }
152
 
Backend/Features/Loyalty/LoyaltyService.cs CHANGED
@@ -35,19 +35,20 @@ namespace Backend.Features.Loyalty
35
  {
36
  _httpClient = httpClient;
37
  _logger = logger;
38
- if (!_httpClient.DefaultRequestHeaders.Contains("x-system-id"))
39
- {
40
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
41
- }
 
 
 
 
42
  }
43
 
44
  public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
45
  {
46
  try
47
  {
48
- _httpClient.DefaultRequestHeaders.Clear();
49
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
50
-
51
  var payload = new
52
  {
53
  externalUserId = externalUserId,
@@ -70,9 +71,6 @@ namespace Backend.Features.Loyalty
70
  {
71
  try
72
  {
73
- _httpClient.DefaultRequestHeaders.Clear();
74
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
75
-
76
  var payload = new
77
  {
78
  systemId = SystemId,
@@ -99,9 +97,6 @@ namespace Backend.Features.Loyalty
99
  {
100
  try
101
  {
102
- _httpClient.DefaultRequestHeaders.Clear();
103
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
104
-
105
  var response = await _httpClient.GetAsync($"/api/v1/accounts/lookup/{externalUserId}?systemId={SystemId}");
106
 
107
  if (response.IsSuccessStatusCode)
@@ -137,7 +132,7 @@ namespace Backend.Features.Loyalty
137
  notes = notes
138
  };
139
 
140
- var response = await _httpClient.PostAsJsonAsync($"/api/v1/redemption/claim?systemId={SystemId}", payload);
141
  if (response.IsSuccessStatusCode) return (true, "Reward claimed successfully!");
142
 
143
  var error = await response.Content.ReadAsStringAsync();
@@ -154,50 +149,62 @@ namespace Backend.Features.Loyalty
154
  {
155
  try
156
  {
157
- _httpClient.DefaultRequestHeaders.Clear();
158
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
159
-
160
- var response = await _httpClient.GetAsync($"/api/v1/redemption/history/{externalUserId}?systemId={SystemId}");
161
  if (response.IsSuccessStatusCode)
162
  {
163
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
164
- return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
 
165
  }
166
  return Enumerable.Empty<LoyaltyRedemptionDto>();
167
  }
168
- catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
 
 
 
 
169
  }
170
 
171
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
172
  {
173
  try
174
  {
175
- _httpClient.DefaultRequestHeaders.Clear();
176
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
177
-
178
  var response = await _httpClient.GetAsync($"/api/v1/admin/redemptions/pending?systemId={SystemId}");
179
  if (response.IsSuccessStatusCode)
180
  {
181
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
182
- return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
 
 
 
 
 
 
183
  }
184
  return Enumerable.Empty<LoyaltyRedemptionDto>();
185
  }
186
- catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
 
 
 
 
187
  }
188
 
189
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync()
190
  {
191
  try
192
  {
193
- _httpClient.DefaultRequestHeaders.Clear();
194
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
195
-
196
  var response = await _httpClient.GetAsync($"/api/v1/admin/redemptions/history?systemId={SystemId}");
197
  if (response.IsSuccessStatusCode)
198
  {
199
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
200
- return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
 
 
 
 
 
 
201
  }
202
  return Enumerable.Empty<LoyaltyRedemptionDto>();
203
  }
@@ -212,22 +219,25 @@ namespace Backend.Features.Loyalty
212
  {
213
  try
214
  {
215
- _httpClient.DefaultRequestHeaders.Clear();
216
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
217
-
218
  var response = await _httpClient.PutAsJsonAsync($"/api/v1/admin/redemptions/{redemptionId}/status?systemId={SystemId}", new { systemId = SystemId, status });
 
 
 
 
 
219
  return response.IsSuccessStatusCode;
220
  }
221
- catch { return false; }
 
 
 
 
222
  }
223
 
224
  public async Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId)
225
  {
226
  try
227
  {
228
- _httpClient.DefaultRequestHeaders.Clear();
229
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
230
-
231
  var response = await _httpClient.GetAsync($"/api/v1/accounts/{accountId}/history?systemId={SystemId}");
232
  if (response.IsSuccessStatusCode)
233
  {
@@ -247,9 +257,6 @@ namespace Backend.Features.Loyalty
247
  {
248
  try
249
  {
250
- _httpClient.DefaultRequestHeaders.Clear();
251
- _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
252
-
253
  var response = await _httpClient.GetAsync($"/api/v1/rewards/active?systemId={SystemId}");
254
 
255
  if (response.IsSuccessStatusCode)
 
35
  {
36
  _httpClient = httpClient;
37
  _logger = logger;
38
+ _httpClient.DefaultRequestHeaders.Remove("x-system-id");
39
+ _httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
40
+ }
41
+
42
+ private class LoyaltyResponseWrapper<T>
43
+ {
44
+ [JsonPropertyName("items")]
45
+ public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
46
  }
47
 
48
  public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
49
  {
50
  try
51
  {
 
 
 
52
  var payload = new
53
  {
54
  externalUserId = externalUserId,
 
71
  {
72
  try
73
  {
 
 
 
74
  var payload = new
75
  {
76
  systemId = SystemId,
 
97
  {
98
  try
99
  {
 
 
 
100
  var response = await _httpClient.GetAsync($"/api/v1/accounts/lookup/{externalUserId}?systemId={SystemId}");
101
 
102
  if (response.IsSuccessStatusCode)
 
132
  notes = notes
133
  };
134
 
135
+ var response = await _httpClient.PostAsJsonAsync($"/api/v1/redemptions/claim?systemId={SystemId}", payload);
136
  if (response.IsSuccessStatusCode) return (true, "Reward claimed successfully!");
137
 
138
  var error = await response.Content.ReadAsStringAsync();
 
149
  {
150
  try
151
  {
152
+ var response = await _httpClient.GetAsync($"/api/v1/redemptions/history/{externalUserId}?systemId={SystemId}");
 
 
 
153
  if (response.IsSuccessStatusCode)
154
  {
155
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
156
+ var wrapper = await response.Content.ReadFromJsonAsync<LoyaltyResponseWrapper<LoyaltyRedemptionDto>>(options);
157
+ return wrapper?.Items ?? Enumerable.Empty<LoyaltyRedemptionDto>();
158
  }
159
  return Enumerable.Empty<LoyaltyRedemptionDto>();
160
  }
161
+ catch (Exception ex)
162
+ {
163
+ _logger.LogError(ex, $"Error fetching user redemptions for {externalUserId}.");
164
+ return Enumerable.Empty<LoyaltyRedemptionDto>();
165
+ }
166
  }
167
 
168
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
169
  {
170
  try
171
  {
 
 
 
172
  var response = await _httpClient.GetAsync($"/api/v1/admin/redemptions/pending?systemId={SystemId}");
173
  if (response.IsSuccessStatusCode)
174
  {
175
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
176
+ var wrapper = await response.Content.ReadFromJsonAsync<LoyaltyResponseWrapper<LoyaltyRedemptionDto>>(options);
177
+ return wrapper?.Items ?? Enumerable.Empty<LoyaltyRedemptionDto>();
178
+ }
179
+ else
180
+ {
181
+ var content = await response.Content.ReadAsStringAsync();
182
+ _logger.LogWarning($"Failed to fetch pending redemptions. Status: {response.StatusCode}, Content: {content}");
183
  }
184
  return Enumerable.Empty<LoyaltyRedemptionDto>();
185
  }
186
+ catch (Exception ex)
187
+ {
188
+ _logger.LogError(ex, "Error fetching pending redemptions.");
189
+ return Enumerable.Empty<LoyaltyRedemptionDto>();
190
+ }
191
  }
192
 
193
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync()
194
  {
195
  try
196
  {
 
 
 
197
  var response = await _httpClient.GetAsync($"/api/v1/admin/redemptions/history?systemId={SystemId}");
198
  if (response.IsSuccessStatusCode)
199
  {
200
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, NumberHandling = JsonNumberHandling.AllowReadingFromString };
201
+ var wrapper = await response.Content.ReadFromJsonAsync<LoyaltyResponseWrapper<LoyaltyRedemptionDto>>(options);
202
+ return wrapper?.Items ?? Enumerable.Empty<LoyaltyRedemptionDto>();
203
+ }
204
+ else
205
+ {
206
+ var content = await response.Content.ReadAsStringAsync();
207
+ _logger.LogWarning($"Failed to fetch redemption history. Status: {response.StatusCode}, Content: {content}");
208
  }
209
  return Enumerable.Empty<LoyaltyRedemptionDto>();
210
  }
 
219
  {
220
  try
221
  {
 
 
 
222
  var response = await _httpClient.PutAsJsonAsync($"/api/v1/admin/redemptions/{redemptionId}/status?systemId={SystemId}", new { systemId = SystemId, status });
223
+ if (!response.IsSuccessStatusCode)
224
+ {
225
+ var content = await response.Content.ReadAsStringAsync();
226
+ _logger.LogWarning($"Failed to update redemption status. Status: {response.StatusCode}, Content: {content}");
227
+ }
228
  return response.IsSuccessStatusCode;
229
  }
230
+ catch (Exception ex)
231
+ {
232
+ _logger.LogError(ex, $"Error updating redemption status for {redemptionId}.");
233
+ return false;
234
+ }
235
  }
236
 
237
  public async Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId)
238
  {
239
  try
240
  {
 
 
 
241
  var response = await _httpClient.GetAsync($"/api/v1/accounts/{accountId}/history?systemId={SystemId}");
242
  if (response.IsSuccessStatusCode)
243
  {
 
257
  {
258
  try
259
  {
 
 
 
260
  var response = await _httpClient.GetAsync($"/api/v1/rewards/active?systemId={SystemId}");
261
 
262
  if (response.IsSuccessStatusCode)
Backend/Features/Subscriptions/SubscriptionService.cs CHANGED
@@ -12,7 +12,7 @@ namespace Backend.Features.Subscriptions
12
  Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId);
13
  Task<IEnumerable<SubscriptionDto>> GetUserAllSubscriptionsAsync(Guid userId);
14
  Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
15
- Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId);
16
  Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
17
  }
18
 
@@ -71,19 +71,14 @@ namespace Backend.Features.Subscriptions
71
  {
72
  try
73
  {
74
- // Load all memberships into memory (small collection)
75
  var allMemberships = await _context.Memberships.ToListAsync();
76
-
77
  Membership? membership = null;
78
 
79
- // 1. First try exact match by RewardId (most precise)
80
  if (!string.IsNullOrEmpty(rewardId))
81
  {
82
  membership = allMemberships.FirstOrDefault(m => m.RewardId == rewardId);
83
  }
84
 
85
- // 2. Fallback: in-memory string matching on rewardName vs membership Type
86
- // e.g. "Free Monthly Basic Membership" contains words "Basic" and "Monthly"
87
  if (membership == null && !string.IsNullOrEmpty(rewardName))
88
  {
89
  var normalizedRewardName = rewardName.ToLowerInvariant();
@@ -100,11 +95,7 @@ namespace Backend.Features.Subscriptions
100
  return false;
101
  }
102
 
103
- System.Diagnostics.Debug.WriteLine($"Matched membership '{membership.Type}' (Id={membership.Id}) for rewardName='{rewardName}'");
104
-
105
- // 3. Grant the membership (will be queued if they already have one)
106
- await SubscribeUserAsync(userId, membership.Id);
107
-
108
  return true;
109
  }
110
  catch (Exception ex)
@@ -114,21 +105,17 @@ namespace Backend.Features.Subscriptions
114
  }
115
  }
116
 
117
- public async Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId)
118
  {
119
  var membership = await _context.Memberships.FindAsync(membershipId);
120
  if (membership == null) throw new Exception("Membership plan not found.");
121
 
122
- // Find the latest expiry date for this user to enable queuing
123
- // We look at all active or future subscriptions
124
  var latestSubscription = await _context.UserSubscriptions
125
  .Where(s => s.UserId == userId && s.IsActive)
126
  .OrderByDescending(s => s.ExpiryDate)
127
  .FirstOrDefaultAsync();
128
 
129
  DateTime startDate = DateTime.UtcNow;
130
-
131
- // If there's an existing subscription that expires in the future, start after it
132
  if (latestSubscription != null && latestSubscription.ExpiryDate > startDate)
133
  {
134
  startDate = latestSubscription.ExpiryDate;
@@ -140,29 +127,25 @@ namespace Backend.Features.Subscriptions
140
  MembershipId = membershipId,
141
  StartDate = startDate,
142
  ExpiryDate = startDate.AddMonths(membership.DurationMonths),
143
- IsActive = true
 
 
144
  };
145
 
146
  _context.UserSubscriptions.Add(subscription);
147
  await _context.SaveChangesAsync();
148
 
149
- // Explicitly load context for DTO
150
  await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
151
  await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
152
 
153
- // Loyalty Integration: Send SUBSCRIBE event
154
- string externalUserId = userId.ToString();
155
- string userMobile = subscription.User?.PhoneNumber ?? "0000000000";
156
- string userEmail = subscription.User?.Email ?? "No Email";
157
-
158
  await _loyaltyService.ProcessEventAsync(
159
- externalUserId: externalUserId,
160
  eventKey: "SUBSCRIBE",
161
  eventValue: (double)membership.Price,
162
  referenceId: $"SUB-{subscription.Id}",
163
  description: $"Purchased Membership: {membership.Type}",
164
- email: userEmail,
165
- mobile: userMobile
166
  );
167
 
168
  return MapToDto(subscription);
@@ -191,9 +174,9 @@ namespace Backend.Features.Subscriptions
191
  UserName = subscription.User?.FullName ?? "Unknown",
192
  StartDate = subscription.StartDate,
193
  ExpiryDate = subscription.ExpiryDate,
194
- IsActive = subscription.IsActive
 
195
  };
196
  }
197
  }
198
  }
199
-
 
12
  Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId);
13
  Task<IEnumerable<SubscriptionDto>> GetUserAllSubscriptionsAsync(Guid userId);
14
  Task<bool> HandleLoyaltyRedemptionAsync(Guid userId, string rewardId, string rewardName, string redemptionId);
15
+ Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null);
16
  Task<IEnumerable<SubscriptionDto>> GetAllSubscriptionsAsync();
17
  }
18
 
 
71
  {
72
  try
73
  {
 
74
  var allMemberships = await _context.Memberships.ToListAsync();
 
75
  Membership? membership = null;
76
 
 
77
  if (!string.IsNullOrEmpty(rewardId))
78
  {
79
  membership = allMemberships.FirstOrDefault(m => m.RewardId == rewardId);
80
  }
81
 
 
 
82
  if (membership == null && !string.IsNullOrEmpty(rewardName))
83
  {
84
  var normalizedRewardName = rewardName.ToLowerInvariant();
 
95
  return false;
96
  }
97
 
98
+ await SubscribeUserAsync(userId, membership.Id, redemptionId);
 
 
 
 
99
  return true;
100
  }
101
  catch (Exception ex)
 
105
  }
106
  }
107
 
108
+ public async Task<SubscriptionDto> SubscribeUserAsync(Guid userId, int membershipId, string? redemptionId = null)
109
  {
110
  var membership = await _context.Memberships.FindAsync(membershipId);
111
  if (membership == null) throw new Exception("Membership plan not found.");
112
 
 
 
113
  var latestSubscription = await _context.UserSubscriptions
114
  .Where(s => s.UserId == userId && s.IsActive)
115
  .OrderByDescending(s => s.ExpiryDate)
116
  .FirstOrDefaultAsync();
117
 
118
  DateTime startDate = DateTime.UtcNow;
 
 
119
  if (latestSubscription != null && latestSubscription.ExpiryDate > startDate)
120
  {
121
  startDate = latestSubscription.ExpiryDate;
 
127
  MembershipId = membershipId,
128
  StartDate = startDate,
129
  ExpiryDate = startDate.AddMonths(membership.DurationMonths),
130
+ IsActive = true,
131
+ ExternalRedemptionId = redemptionId,
132
+ Status = "Active"
133
  };
134
 
135
  _context.UserSubscriptions.Add(subscription);
136
  await _context.SaveChangesAsync();
137
 
 
138
  await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
139
  await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
140
 
 
 
 
 
 
141
  await _loyaltyService.ProcessEventAsync(
142
+ externalUserId: userId.ToString(),
143
  eventKey: "SUBSCRIBE",
144
  eventValue: (double)membership.Price,
145
  referenceId: $"SUB-{subscription.Id}",
146
  description: $"Purchased Membership: {membership.Type}",
147
+ email: subscription.User?.Email ?? "No Email",
148
+ mobile: subscription.User?.PhoneNumber ?? "0000000000"
149
  );
150
 
151
  return MapToDto(subscription);
 
174
  UserName = subscription.User?.FullName ?? "Unknown",
175
  StartDate = subscription.StartDate,
176
  ExpiryDate = subscription.ExpiryDate,
177
+ IsActive = subscription.IsActive,
178
+ ExternalRedemptionId = subscription.ExternalRedemptionId
179
  };
180
  }
181
  }
182
  }
 
BlazorWebAssembly/Layout/MainLayout.razor CHANGED
@@ -33,7 +33,7 @@
33
  </div>
34
  <div class="flex flex-col min-w-0">
35
  <span class="text-xs font-bold truncate">@context.User.Identity?.Name</span>
36
- <span class="text-[10px] text-muted font-medium uppercase tracking-wider">Member</span>
37
  </div>
38
  </div>
39
  </div>
 
33
  </div>
34
  <div class="flex flex-col min-w-0">
35
  <span class="text-xs font-bold truncate">@context.User.Identity?.Name</span>
36
+ <span class="text-[10px] text-muted font-medium uppercase tracking-wider">@(context.User.IsInRole("Librarian") ? "Librarian" : "Member")</span>
37
  </div>
38
  </div>
39
  </div>
BlazorWebAssembly/Pages/About.razor CHANGED
@@ -19,12 +19,28 @@
19
  </div>
20
  </div>
21
  </div>
22
- <div class="relative">
23
- <div class="bg-card aspect-square rounded-[4rem] border border-border shadow-2xl flex items-center justify-center p-12 relative z-10 overflow-hidden">
24
- <div class="absolute inset-0 bg-gradient-to-br from-primary/10 to-emerald-500/10"></div>
25
- <svg class="w-48 h-48 text-primary opacity-20 relative z-20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
26
  </div>
27
- <div class="absolute -bottom-10 -right-10 w-64 h-64 bg-primary/5 rounded-full blur-3xl"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </div>
29
  </div>
30
 
 
19
  </div>
20
  </div>
21
  </div>
22
+ <div class="relative w-full max-w-sm mx-auto aspect-[3/4] group perspective-1000">
23
+ <!-- Book 4 (Furthest Back) -->
24
+ <div class="absolute inset-0 bg-card rounded-2xl border border-border shadow-lg transform transition-all duration-700 origin-bottom-left -rotate-12 group-hover:-rotate-[25deg] group-hover:-translate-x-16 group-hover:-translate-y-8 overflow-hidden">
25
+ <img src="https://covers.openlibrary.org/b/isbn/9780062316097-L.jpg" class="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" />
26
  </div>
27
+
28
+ <!-- Book 3 -->
29
+ <div class="absolute inset-0 bg-card rounded-2xl border border-border shadow-xl transform transition-all duration-700 origin-bottom-left -rotate-6 group-hover:-rotate-[12deg] group-hover:-translate-x-8 group-hover:-translate-y-4 overflow-hidden">
30
+ <img src="https://covers.openlibrary.org/b/isbn/9780060850524-L.jpg" class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" />
31
+ </div>
32
+
33
+ <!-- Book 2 -->
34
+ <div class="absolute inset-0 bg-card rounded-2xl border border-border shadow-2xl transform transition-all duration-700 origin-bottom-right rotate-6 group-hover:rotate-[12deg] group-hover:translate-x-8 group-hover:-translate-y-4 overflow-hidden z-10">
35
+ <img src="https://covers.openlibrary.org/b/id/14828198-L.jpg" class="w-full h-full object-cover opacity-95 group-hover:opacity-100 transition-opacity" />
36
+ </div>
37
+
38
+ <!-- Book 1 (Front) -->
39
+ <div class="absolute inset-0 bg-card rounded-2xl border border-border shadow-2xl transform transition-all duration-700 origin-bottom-right rotate-12 group-hover:rotate-[25deg] group-hover:translate-x-16 group-hover:-translate-y-8 overflow-hidden z-20">
40
+ <img src="https://covers.openlibrary.org/b/isbn/9780743273565-L.jpg" class="w-full h-full object-cover" />
41
+ </div>
42
+
43
+ <div class="absolute -bottom-10 -right-10 w-64 h-64 bg-primary/5 rounded-full blur-3xl -z-10"></div>
44
  </div>
45
  </div>
46
 
BlazorWebAssembly/Pages/Books.razor CHANGED
@@ -50,24 +50,35 @@
50
  </button>
51
  </div>
52
 
53
- <!-- Category Filter Dropdown equivalent -->
54
- @if (_categories.Any())
55
- {
56
- <div class="flex flex-wrap gap-2 mb-10">
57
- <button @onclick="() => FilterByCategory(null)"
58
- class='px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == null ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
59
- All Categories
60
- </button>
61
- @foreach (var cat in _categories)
62
  {
63
- <button @onclick="() => FilterByCategory(cat.Id)"
64
- class='px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == cat.Id ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
65
- @cat.Name
66
- </button>
67
  }
68
- </div>
69
- }
70
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  <!-- Loading State -->
72
  @if (_isLoading)
73
  {
@@ -391,12 +402,17 @@
391
 
392
  // Form Model
393
  private BookFormModel _formModel = new();
 
 
 
394
 
395
  protected override async Task OnInitializedAsync()
396
  {
397
  await LoadDataAsync();
398
  }
399
 
 
 
400
  private async Task LoadDataAsync()
401
  {
402
  _isLoading = true;
 
50
  </button>
51
  </div>
52
 
53
+ <div class="mb-10 animate-fade">
54
+ <button @onclick="ToggleCategoryDropdown" class="group inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-muted hover:text-primary transition-colors">
55
+ <div class="w-6 h-6 rounded-lg bg-card border border-border flex items-center justify-center group-hover:bg-primary/5 transition-colors">
56
+ <svg class="w-3 transition-transform duration-300 @(_isCategoryDropdownOpen ? "rotate-90" : "")" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"></path></svg>
57
+ </div>
58
+ <span>Browse by Category</span>
59
+ @if (_selectedCategoryId != null)
 
 
60
  {
61
+ <span class="ml-2 px-2 py-0.5 bg-primary text-white text-[9px] rounded-full">@SelectedCategoryName</span>
 
 
 
62
  }
63
+ </button>
64
+
65
+ @if (_isCategoryDropdownOpen)
66
+ {
67
+ <div class="flex flex-wrap items-center gap-2 mt-6 animate-slide-down">
68
+ <button @onclick="() => FilterByCategory(null)"
69
+ class='inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == null ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
70
+ All
71
+ </button>
72
+ @foreach (var cat in _categories)
73
+ {
74
+ <button @onclick="() => FilterByCategory(cat.Id)"
75
+ class='inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == cat.Id ? "bg-primary text-white border-primary shadow-sm" : "bg-card text-muted border-border hover:border-primary hover:text-primary")'>
76
+ @cat.Name
77
+ </button>
78
+ }
79
+ </div>
80
+ }
81
+ </div>
82
  <!-- Loading State -->
83
  @if (_isLoading)
84
  {
 
402
 
403
  // Form Model
404
  private BookFormModel _formModel = new();
405
+ private bool _isCategoryDropdownOpen = false;
406
+
407
+ private string SelectedCategoryName => _categories.FirstOrDefault(c => c.Id == _selectedCategoryId)?.Name ?? "";
408
 
409
  protected override async Task OnInitializedAsync()
410
  {
411
  await LoadDataAsync();
412
  }
413
 
414
+ private void ToggleCategoryDropdown() => _isCategoryDropdownOpen = !_isCategoryDropdownOpen;
415
+
416
  private async Task LoadDataAsync()
417
  {
418
  _isLoading = true;
BlazorWebAssembly/Pages/Features.razor CHANGED
@@ -12,11 +12,35 @@
12
  <!-- Feature Block 1: Loyalty -->
13
  <div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
14
  <div class="order-2 lg:order-1">
15
- <div class="bg-card aspect-video rounded-[3rem] border border-border shadow-2xl flex items-center justify-center relative overflow-hidden">
16
- <div class="absolute inset-0 bg-primary opacity-5"></div>
17
- <div class="relative z-10 flex flex-col items-center gap-6">
18
- <div class="w-24 h-24 bg-primary rounded-3xl shadow-2xl shadow-primary/30 flex items-center justify-center text-white text-4xl font-black">L</div>
19
- <span class="text-[10px] font-black text-muted uppercase tracking-[0.4em]">Loyalty Dashboard</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  </div>
21
  </div>
22
  </div>
@@ -60,14 +84,30 @@
60
  </div>
61
  </div>
62
  </div>
63
- <div class="bg-card aspect-video rounded-[3rem] border border-border shadow-2xl flex items-center justify-center relative overflow-hidden">
64
- <div class="absolute inset-0 bg-emerald-500 opacity-5"></div>
65
- <div class="relative z-10 flex flex-col items-center gap-6">
66
- <div class="w-16 h-16 bg-emerald-500 rounded-2xl shadow-xl shadow-emerald-500/20 flex items-center justify-center text-white">
67
- <svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
 
 
 
 
 
68
  </div>
69
- <span class="text-[10px] font-black text-muted uppercase tracking-[0.4em]">Real-time Infrastructure</span>
70
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
71
  </div>
72
  </div>
73
 
@@ -77,11 +117,81 @@
77
  <p class="text-xl text-muted max-w-3xl mx-auto mb-12">
78
  Our intelligent algorithms analyze your reading history and preferences to suggest your next favorite book. The more you read, the smarter it gets.
79
  </p>
80
- <div class="flex flex-wrap justify-center gap-8 opacity-40 grayscale">
81
- <div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
82
- <div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
83
- <div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
84
- <div class="w-32 h-44 bg-body rounded-xl border border-border"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  </div>
86
  </section>
87
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  <!-- Feature Block 1: Loyalty -->
13
  <div class="grid grid-cols-1 lg:grid-cols-2 gap-24 items-center">
14
  <div class="order-2 lg:order-1">
15
+ <div class="relative w-full max-w-sm mx-auto aspect-[4/5] group perspective-1000">
16
+ <!-- Back Card (Reward 2) -->
17
+ <div class="absolute inset-0 bg-card rounded-3xl border border-border p-8 shadow-xl flex flex-col transform transition-all duration-500 origin-bottom-left -rotate-6 group-hover:-rotate-12 group-hover:-translate-x-8 group-hover:-translate-y-4">
18
+ <div class="flex justify-between items-start mb-6">
19
+ <div class="w-12 h-12 bg-body rounded-xl flex items-center justify-center border border-border">
20
+ <svg class="w-6 h-6 text-primary" 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>
21
+ </div>
22
+ <span class="bg-primary/10 text-primary px-3 py-1 rounded-full text-xs font-black">1100 pts</span>
23
+ </div>
24
+ <h3 class="text-xl font-bold text-main mb-2">Free Yearly Basic Membership</h3>
25
+ <p class="text-sm text-muted leading-relaxed mb-6 flex-grow">Maxed books: 3 <br/>Maxed borrowing days: 14</p>
26
+ <button disabled class="w-full py-3 rounded-xl font-bold bg-body text-muted border border-border cursor-not-allowed">
27
+ Claim Reward
28
+ </button>
29
+ </div>
30
+
31
+ <!-- Front Card (Reward 1) -->
32
+ <div class="absolute inset-0 bg-card rounded-3xl border border-border p-8 shadow-2xl flex flex-col transform transition-all duration-500 origin-bottom-right rotate-6 group-hover:rotate-12 group-hover:translate-x-8 group-hover:-translate-y-2 z-10">
33
+ <div class="flex justify-between items-start mb-6">
34
+ <div class="w-12 h-12 bg-body rounded-xl flex items-center justify-center border border-border">
35
+ <svg class="w-6 h-6 text-primary" 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>
36
+ </div>
37
+ <span class="bg-primary/10 text-primary px-3 py-1 rounded-full text-xs font-black">100 pts</span>
38
+ </div>
39
+ <h3 class="text-xl font-bold text-main mb-2">Free Monthly Basic Membership</h3>
40
+ <p class="text-sm text-muted leading-relaxed mb-6 flex-grow">Maxed books: 3 <br/>Maxed borrowing days: 14</p>
41
+ <button disabled class="w-full py-3 rounded-xl font-bold bg-primary text-white shadow-lg shadow-primary/20 opacity-90 cursor-default">
42
+ Claim Reward
43
+ </button>
44
  </div>
45
  </div>
46
  </div>
 
84
  </div>
85
  </div>
86
  </div>
87
+ <div class="aspect-video rounded-[3rem] shadow-2xl relative overflow-hidden group flex flex-col items-center justify-center cursor-pointer bg-card"
88
+ @onmouseenter="HandleNotifHover" @onmouseleave="HandleNotifLeave">
89
+ <div class="absolute inset-0 bg-primary opacity-5"></div>
90
+
91
+ <!-- Bell Icon -->
92
+ <div class="relative z-10 transition-transform duration-500 group-hover:-translate-y-8">
93
+ <div class="relative origin-top animate-vibrate group-hover:animate-none group-hover:scale-105 transition-all text-primary">
94
+ <svg class="w-20 h-20 drop-shadow-xl" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
95
+ <!-- Badge -->
96
+ <span class="absolute top-1 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs font-black shadow-md border-2 border-card">1</span>
97
  </div>
 
98
  </div>
99
+
100
+ <!-- Notification Card (hidden by default, slides up on hover) -->
101
+ <div class="absolute bottom-0 left-0 w-full p-8 transform translate-y-full opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-500 z-20">
102
+ <div class="bg-body rounded-2xl p-6 shadow-2xl relative overflow-hidden">
103
+ @if (_isNotifHovered)
104
+ {
105
+ <h4 class="text-primary font-black text-xl mb-2">Book Available!</h4>
106
+ <p class="text-sm text-main font-medium leading-relaxed min-h-[40px]">@_notificationText<span class="inline-block w-1.5 h-4 bg-primary ml-1 animate-pulse"></span></p>
107
+ }
108
+ </div>
109
+ </div>
110
+
111
  </div>
112
  </div>
113
 
 
117
  <p class="text-xl text-muted max-w-3xl mx-auto mb-12">
118
  Our intelligent algorithms analyze your reading history and preferences to suggest your next favorite book. The more you read, the smarter it gets.
119
  </p>
120
+ <div class="flex flex-wrap justify-center gap-8 md:gap-12 mt-8">
121
+ <div class="flex flex-col items-center group cursor-pointer w-48">
122
+ <div class="w-40 h-56 rounded-2xl border border-border relative overflow-hidden shadow-lg transition-all duration-500 group-hover:-translate-y-4 group-hover:shadow-2xl group-hover:border-primary/50">
123
+ <img src="https://covers.openlibrary.org/b/isbn/9780743273565-L.jpg" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
124
+ </div>
125
+ <div class="h-12 mt-6 flex items-start justify-center">
126
+ <span class="text-primary text-sm font-black text-center opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0 leading-tight">Because you liked Productivity books</span>
127
+ </div>
128
+ </div>
129
+ <div class="flex flex-col items-center group cursor-pointer w-48">
130
+ <div class="w-40 h-56 rounded-2xl border border-border relative overflow-hidden shadow-lg transition-all duration-500 group-hover:-translate-y-4 group-hover:shadow-2xl group-hover:border-primary/50">
131
+ <img src="https://covers.openlibrary.org/b/id/14828198-L.jpg" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
132
+ </div>
133
+ <div class="h-12 mt-6 flex items-start justify-center">
134
+ <span class="text-primary text-sm font-black text-center opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0 leading-tight">Trending among readers</span>
135
+ </div>
136
+ </div>
137
+ <div class="flex flex-col items-center group cursor-pointer w-48">
138
+ <div class="w-40 h-56 rounded-2xl border border-border relative overflow-hidden shadow-lg transition-all duration-500 group-hover:-translate-y-4 group-hover:shadow-2xl group-hover:border-primary/50">
139
+ <img src="https://covers.openlibrary.org/b/isbn/9780060850524-L.jpg" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
140
+ </div>
141
+ <div class="h-12 mt-6 flex items-start justify-center">
142
+ <span class="text-primary text-sm font-black text-center opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0 leading-tight">Popular this week</span>
143
+ </div>
144
+ </div>
145
+ <div class="flex flex-col items-center group cursor-pointer w-48">
146
+ <div class="w-40 h-56 rounded-2xl border border-border relative overflow-hidden shadow-lg transition-all duration-500 group-hover:-translate-y-4 group-hover:shadow-2xl group-hover:border-primary/50">
147
+ <img src="https://covers.openlibrary.org/b/isbn/9780062316097-L.jpg" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" />
148
+ </div>
149
+ <div class="h-12 mt-6 flex items-start justify-center">
150
+ <span class="text-primary text-sm font-black text-center opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0 leading-tight">Based on your recent reads</span>
151
+ </div>
152
+ </div>
153
  </div>
154
  </section>
155
  </div>
156
+
157
+ <style>
158
+ @@keyframes vibrate {
159
+ 0%, 100% { transform: rotate(0deg); }
160
+ 25% { transform: rotate(6deg); }
161
+ 50% { transform: rotate(0deg); }
162
+ 75% { transform: rotate(-6deg); }
163
+ }
164
+ .animate-vibrate {
165
+ animation: vibrate 0.15s linear infinite;
166
+ transform-origin: top center;
167
+ }
168
+ </style>
169
+
170
+ @code {
171
+ private bool _isNotifHovered = false;
172
+ private string _notificationText = "";
173
+ private string _fullText = "Good news! Your wished book is now available for borrowing. Grab it before someone else does!";
174
+
175
+ private async Task HandleNotifHover()
176
+ {
177
+ if (_isNotifHovered) return;
178
+ _isNotifHovered = true;
179
+ _notificationText = "";
180
+
181
+ await Task.Delay(300); // Wait for the card to slide up
182
+
183
+ for (int i = 0; i < _fullText.Length; i++)
184
+ {
185
+ if (!_isNotifHovered) break; // Stop if mouse left
186
+ _notificationText += _fullText[i];
187
+ StateHasChanged();
188
+ await Task.Delay(25); // Typing speed
189
+ }
190
+ }
191
+
192
+ private void HandleNotifLeave()
193
+ {
194
+ _isNotifHovered = false;
195
+ _notificationText = "";
196
+ }
197
+ }
BlazorWebAssembly/Pages/Home.razor CHANGED
@@ -99,9 +99,16 @@
99
  {
100
  <a href="/books" class="group cursor-pointer">
101
  <div class="bg-card aspect-[2/3] rounded-2xl border border-border overflow-hidden relative mb-2 group-hover:border-primary/50 transition-colors">
102
- <div class="absolute inset-0 flex items-center justify-center text-primary/20 group-hover:text-primary/40 transition-colors">
103
- <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
104
- </div>
 
 
 
 
 
 
 
105
  </div>
106
  <h3 class="text-xs font-bold text-main truncate">@book.Title</h3>
107
  <p class="text-[10px] text-muted truncate">@book.Author</p>
 
99
  {
100
  <a href="/books" class="group cursor-pointer">
101
  <div class="bg-card aspect-[2/3] rounded-2xl border border-border overflow-hidden relative mb-2 group-hover:border-primary/50 transition-colors">
102
+ @if (!string.IsNullOrEmpty(book.CoverUrl))
103
+ {
104
+ <img src="@book.CoverUrl" alt="@book.Title" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
105
+ }
106
+ else
107
+ {
108
+ <div class="absolute inset-0 flex items-center justify-center text-primary/20 group-hover:text-primary/40 transition-colors">
109
+ <svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
110
+ </div>
111
+ }
112
  </div>
113
  <h3 class="text-xs font-bold text-main truncate">@book.Title</h3>
114
  <p class="text-[10px] text-muted truncate">@book.Author</p>
BlazorWebAssembly/Pages/Membership.razor CHANGED
@@ -31,8 +31,11 @@
31
  <Authorized>
32
  @if (_currentSub != null)
33
  {
34
- <div class="bg-primary/5 border border-primary/20 rounded-[2.5rem] p-10 mb-20 flex flex-col md:flex-row items-center justify-between gap-8 shadow-sm">
35
- <div class="flex items-center gap-8">
 
 
 
36
  <div class="w-20 h-20 bg-primary rounded-3xl flex items-center justify-center text-white text-4xl font-black shadow-lg shadow-primary/20">
37
  @(_currentSub.MembershipType?[..1].ToUpper())
38
  </div>
@@ -41,7 +44,7 @@
41
  <p class="text-muted font-medium">Active until <span class="text-main font-bold">@_currentSub.ExpiryDate.ToString("MMMM dd, yyyy")</span></p>
42
  </div>
43
  </div>
44
- <div class="flex items-center gap-6">
45
  <div class="flex flex-col items-end">
46
  <span class="px-4 py-1.5 bg-green-500/10 text-green-600 rounded-full text-[10px] font-black uppercase tracking-widest mb-2">Active Status</span>
47
  <span class="text-xs text-muted font-bold uppercase tracking-widest">Auto-renews monthly</span>
@@ -49,6 +52,44 @@
49
  </div>
50
  </div>
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </Authorized>
53
  </AuthorizeView>
54
 
@@ -134,6 +175,7 @@
134
  private bool _isLoading = true;
135
  private List<MembershipDto> _memberships = new();
136
  private SubscriptionDto? _currentSub;
 
137
 
138
  protected override async Task OnInitializedAsync()
139
  {
@@ -147,6 +189,12 @@
147
  {
148
  _memberships = (await ApiClient.GetMembershipsAsync()).ToList();
149
  _currentSub = await ApiClient.GetMySubscriptionAsync();
 
 
 
 
 
 
150
  }
151
  catch { }
152
  finally { _isLoading = false; }
 
31
  <Authorized>
32
  @if (_currentSub != null)
33
  {
34
+ <div class="bg-primary/5 border border-primary/20 rounded-[2.5rem] p-10 mb-12 flex flex-col md:flex-row items-center justify-between gap-8 shadow-sm relative overflow-hidden group">
35
+ <div class="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
36
+ <svg class="w-32 h-32" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
37
+ </div>
38
+ <div class="flex items-center gap-8 relative z-10">
39
  <div class="w-20 h-20 bg-primary rounded-3xl flex items-center justify-center text-white text-4xl font-black shadow-lg shadow-primary/20">
40
  @(_currentSub.MembershipType?[..1].ToUpper())
41
  </div>
 
44
  <p class="text-muted font-medium">Active until <span class="text-main font-bold">@_currentSub.ExpiryDate.ToString("MMMM dd, yyyy")</span></p>
45
  </div>
46
  </div>
47
+ <div class="flex items-center gap-6 relative z-10">
48
  <div class="flex flex-col items-end">
49
  <span class="px-4 py-1.5 bg-green-500/10 text-green-600 rounded-full text-[10px] font-black uppercase tracking-widest mb-2">Active Status</span>
50
  <span class="text-xs text-muted font-bold uppercase tracking-widest">Auto-renews monthly</span>
 
52
  </div>
53
  </div>
54
  }
55
+
56
+ @if (_queuedRewards.Any())
57
+ {
58
+ <div class="mb-20 animate-fade-in-down">
59
+ <div class="flex items-center gap-3 mb-8">
60
+ <div class="h-px flex-grow bg-border"></div>
61
+ <h3 class="text-xs font-black text-amber-500 uppercase tracking-[0.4em] px-4 whitespace-nowrap">Queued Rewards</h3>
62
+ <div class="h-px flex-grow bg-border"></div>
63
+ </div>
64
+
65
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
66
+ @foreach (var sub in _queuedRewards)
67
+ {
68
+ <div class="bg-card border border-amber-500/20 rounded-3xl p-8 flex items-center justify-between shadow-sm hover:shadow-lg transition-all border-l-4 border-l-amber-500 relative overflow-hidden group">
69
+ <div class="absolute top-0 right-0 p-4 opacity-[0.03] group-hover:opacity-[0.07] transition-opacity">
70
+ <svg class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
71
+ </div>
72
+ <div class="flex items-center gap-6 relative z-10">
73
+ <div class="w-14 h-14 bg-amber-500/10 rounded-2xl flex items-center justify-center text-amber-500">
74
+ <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
75
+ </div>
76
+ <div>
77
+ <h4 class="text-xl font-black text-main uppercase">@sub.MembershipType</h4>
78
+ <p class="text-sm text-muted font-medium">Starts on @sub.StartDate.ToString("MMM dd, yyyy")</p>
79
+ </div>
80
+ </div>
81
+ <div class="text-right relative z-10">
82
+ <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-green-500/10 text-green-500">
83
+ <svg class="w-3 h-3" 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>
84
+ Approved
85
+ </span>
86
+ <p class="text-[10px] text-muted font-bold uppercase tracking-widest mt-2">Queued in Pipeline</p>
87
+ </div>
88
+ </div>
89
+ }
90
+ </div>
91
+ </div>
92
+ }
93
  </Authorized>
94
  </AuthorizeView>
95
 
 
175
  private bool _isLoading = true;
176
  private List<MembershipDto> _memberships = new();
177
  private SubscriptionDto? _currentSub;
178
+ private List<SubscriptionDto> _queuedRewards = new();
179
 
180
  protected override async Task OnInitializedAsync()
181
  {
 
189
  {
190
  _memberships = (await ApiClient.GetMembershipsAsync()).ToList();
191
  _currentSub = await ApiClient.GetMySubscriptionAsync();
192
+
193
+ var allSubs = await ApiClient.GetMyAllSubscriptionsAsync();
194
+ _queuedRewards = allSubs
195
+ .Where(s => !string.IsNullOrEmpty(s.ExternalRedemptionId) && s.StartDate > DateTime.UtcNow)
196
+ .OrderBy(s => s.StartDate)
197
+ .ToList();
198
  }
199
  catch { }
200
  finally { _isLoading = false; }
BlazorWebAssembly/Pages/Rewards.razor CHANGED
@@ -58,7 +58,7 @@
58
 
59
  <button @onclick="() => ClaimAsync(reward)"
60
  disabled="@((_account?.CurrentBalance ?? 0) < reward.PointCost)"
61
- class='w-full py-4 rounded-xl font-bold transition-all @((_account?.CurrentBalance ?? 0) >= reward.PointCost ? "bg-main text-bg-body hover:bg-primary" : "bg-body text-muted cursor-not-allowed border border-border")'>
62
  @((_account?.CurrentBalance ?? 0) >= reward.PointCost ? "Claim Reward" : "Insufficient Points")
63
  </button>
64
  </div>
 
58
 
59
  <button @onclick="() => ClaimAsync(reward)"
60
  disabled="@((_account?.CurrentBalance ?? 0) < reward.PointCost)"
61
+ class='w-full py-4 rounded-xl font-bold transition-all @((_account?.CurrentBalance ?? 0) >= reward.PointCost ? "bg-primary text-white hover:bg-hover shadow-lg shadow-primary/20" : "bg-body text-muted cursor-not-allowed border border-border")'>
62
  @((_account?.CurrentBalance ?? 0) >= reward.PointCost ? "Claim Reward" : "Insufficient Points")
63
  </button>
64
  </div>
BlazorWebAssembly/Pages/RewardsManage.razor CHANGED
@@ -1,56 +1,48 @@
1
  @page "/rewards/manage"
2
- @using BlazorWebAssembly.Services
3
  @using LibraryManagement.Shared.Models
 
4
  @using Microsoft.AspNetCore.Authorization
 
5
  @inject LibraryApiClient ApiClient
6
  @inject IJSRuntime JS
7
- @attribute [Authorize(Roles = "Librarian")]
8
 
9
- <div class="max-w-7xl mx-auto py-10 px-6 animate-fade">
10
- <div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-6">
11
- <div>
12
- <h1 class="text-4xl font-extrabold tracking-tight text-main mb-2">Rewards Management</h1>
13
- <p class="text-muted text-lg font-medium">Fulfill pending redemptions and review all members' loyalty activity.</p>
14
- </div>
15
- <div class="flex items-center gap-4">
16
- <div class="bg-card px-6 py-3 flex items-center gap-4 border border-border rounded-xl shadow-sm">
17
- <div class="text-center">
18
- <p class="text-[10px] font-black text-amber-500 uppercase tracking-widest">Pending</p>
19
- <p class="text-2xl font-black text-main">@_pendingRedemptions.Count()</p>
20
- </div>
21
- <div class="w-px h-8 bg-border"></div>
22
- <div class="text-center">
23
- <p class="text-[10px] font-black text-muted uppercase tracking-widest">Members</p>
24
- <p class="text-2xl font-black text-main">@_allMembersHistory.Count()</p>
25
- </div>
26
- </div>
27
- </div>
28
  </div>
29
 
30
- <div class="mb-8 border-b border-border flex gap-8">
 
31
  <button @onclick='() => _activeTab = "pending"'
32
- class='pb-4 text-sm font-bold border-b-2 transition-all relative @(_activeTab == "pending" ? "border-primary text-main" : "border-transparent text-muted hover:text-main")'>
33
- <span class="flex items-center gap-2">
34
- Pending Redemptions
35
- @if (_pendingRedemptions.Any())
36
- {
37
- <span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[10px] font-black rounded-full bg-red-500 text-white">@_pendingRedemptions.Count()</span>
38
- }
39
- </span>
 
 
40
  </button>
41
  <button @onclick='() => _activeTab = "history"'
42
- class='pb-4 text-sm font-bold border-b-2 transition-all @(_activeTab == "history" ? "border-primary text-main" : "border-transparent text-muted hover:text-main")'>
43
  Members Points History
 
 
 
 
44
  </button>
45
  </div>
46
 
47
  @if (_isLoading)
48
  {
49
- <div class="space-y-4">
50
- @for (int i = 0; i < 5; i++)
51
- {
52
- <div class="animate-pulse bg-card rounded-xl border border-border h-20 w-full"></div>
53
- }
54
  </div>
55
  }
56
  else
@@ -61,59 +53,60 @@
61
  @if (!_pendingRedemptions.Any())
62
  {
63
  <div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm">
64
- <p class="text-muted font-bold text-lg mb-2">No pending redemptions</p>
65
- <p class="text-muted/70 text-sm">All member requests have been fulfilled.</p>
66
  </div>
67
  }
68
  else
69
  {
70
- <div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
71
- <table class="w-full text-left border-collapse min-w-max whitespace-nowrap">
72
- <thead class="bg-body border-b border-border">
73
- <tr>
74
- <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
75
- <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Reward</th>
76
- <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Points</th>
77
- <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Claimed Date</th>
78
- <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th>
79
- <th class="px-6 py-4 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Action</th>
80
- </tr>
81
- </thead>
82
- <tbody class="divide-y divide-border">
83
- @foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt))
84
- {
85
- <tr class="hover:bg-body transition-colors">
86
- <td class="px-6 py-4">
87
- <div class="flex items-center gap-3">
88
- <div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">
89
- @(r.ExternalUserId.Length >= 2 ? r.ExternalUserId.Substring(0, 2).ToUpper() : "U")
90
- </div>
91
- <span class="text-sm font-bold text-main">@r.ExternalUserId</span>
92
- </div>
93
- </td>
94
- <td class="px-6 py-4">
95
- <p class="text-sm font-bold text-main">@r.RewardName</p>
96
- <p class="text-[10px] text-muted font-bold tracking-widest uppercase">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)</p>
97
- </td>
98
- <td class="px-6 py-4 text-sm font-black text-primary">@r.PointCost.ToString("N0") pts</td>
99
- <td class="px-6 py-4 text-sm font-medium text-muted">@r.RedeemedAt.ToString("MMM dd, yyyy")</td>
100
- <td class="px-6 py-4">
101
- <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-amber-500/10 text-amber-500">
102
- <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
103
- Pending
104
- </span>
105
- </td>
106
- <td class="px-6 py-4 text-right">
107
- <button @onclick="() => FulfillAsync(r)"
108
- class="inline-flex items-center gap-1.5 bg-green-500/10 text-green-500 border border-green-500/20 px-4 py-2 rounded-lg text-xs font-bold hover:bg-green-500 hover:text-white transition-all shadow-sm active:scale-95">
109
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path></svg>
110
- Fulfill
111
- </button>
112
- </td>
113
  </tr>
114
- }
115
- </tbody>
116
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  </div>
118
  }
119
  </div>
@@ -150,75 +143,70 @@
150
  <p class="text-xs font-medium text-muted">@member.UserEmail</p>
151
  </div>
152
  </div>
153
- <div class="flex items-center gap-6 w-full sm:w-auto justify-between sm:justify-end">
154
- <div class="text-right">
155
- <p class="text-[10px] font-black text-muted uppercase tracking-widest">Balance</p>
156
- <p class="font-black text-main text-lg">@member.CurrentBalance.ToString("N0") pts</p>
157
  </div>
158
- <div class="text-right hidden md:block">
159
- <p class="text-[10px] font-black text-muted uppercase tracking-widest">Tier</p>
160
- <span class="text-xs font-bold text-primary">@member.Tier</span>
161
  </div>
162
- <div class="text-right hidden sm:block">
163
- <p class="text-[10px] font-black text-muted uppercase tracking-widest">History</p>
164
- <p class="font-bold text-main text-sm">@member.History.Count() items</p>
165
  </div>
166
- <svg class="w-5 h-5 text-muted transition-transform @(_expandedMembers.Contains(member.AccountId) ? "rotate-180" : "")" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
167
  </div>
168
  </div>
169
-
170
- @if (_expandedMembers.Contains(member.AccountId))
171
  {
172
- <div class="border-t border-border bg-body animate-fade-in-down">
173
- @if (!member.History.Any())
174
- {
175
- <p class="px-6 py-6 text-sm text-muted font-medium text-center">No transactions found for this member.</p>
176
- }
177
- else
178
- {
179
- <div class="overflow-x-auto">
180
- <table class="min-w-full text-left min-w-max whitespace-nowrap">
181
- <thead>
182
- <tr class="bg-card">
183
- <th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Date</th>
184
- <th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Type</th>
185
- <th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Details</th>
186
- <th class="px-6 py-3 text-[10px] font-bold text-muted uppercase tracking-widest">Subject</th>
187
- <th class="px-6 py-3 text-right text-[10px] font-bold text-muted uppercase tracking-widest">Points</th>
188
- </tr>
189
- </thead>
190
- <tbody class="divide-y divide-border">
191
- @foreach (var h in member.History.OrderByDescending(x => x.CreatedAt))
192
- {
193
- var isPos = h.PointDelta >= 0;
194
- var label = PrettyEvent(h.EventKey, h.PointDelta);
195
- string? spentOn = h.RewardName;
196
-
197
- <tr class="hover:bg-card transition-colors">
198
- <td class="px-6 py-3 text-xs font-medium text-muted whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
199
- <td class="px-6 py-3">
200
- <span class="px-2 py-0.5 rounded-sm text-[9px] font-black uppercase tracking-widest border @(isPos ? "bg-green-500/10 text-green-500 border-green-500/20" : "bg-red-500/10 text-red-500 border-red-500/20")">@label</span>
201
- </td>
202
- <td class="px-6 py-3 text-xs font-medium text-main max-w-xs truncate">@h.Description</td>
203
- <td class="px-6 py-3 text-xs font-bold text-muted max-w-xs truncate">
204
- @if (!string.IsNullOrWhiteSpace(spentOn))
205
- {
206
- <span class="text-main">@spentOn</span>
207
- }
208
- else
209
- {
210
- <span>—</span>
211
- }
212
- </td>
213
- <td class="px-6 py-3 text-right font-black text-sm @(isPos ? "text-green-500" : "text-red-500")">
214
- @(isPos ? "+" : "")@h.PointDelta.ToString("N0")
215
- </td>
216
- </tr>
217
- }
218
- </tbody>
219
- </table>
220
  </div>
221
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  </div>
223
  }
224
  </div>
@@ -233,10 +221,10 @@
233
  @code {
234
  private string _activeTab = "pending";
235
  private bool _isLoading = true;
236
- private string _searchQuery = "";
237
  private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
238
  private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>();
239
- private HashSet<string> _expandedMembers = new();
 
240
 
241
  protected override async Task OnInitializedAsync()
242
  {
@@ -253,7 +241,7 @@
253
  }
254
  catch (Exception ex)
255
  {
256
- await JS.InvokeVoidAsync("alert", $"Error loading management data: {ex.Message}");
257
  }
258
  finally
259
  {
@@ -265,44 +253,30 @@
265
  string.IsNullOrWhiteSpace(_searchQuery)
266
  ? _allMembersHistory
267
  : _allMembersHistory.Where(m => m.UserName.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
268
- m.UserEmail.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
269
 
270
  private void ToggleMember(string accountId)
271
  {
272
- if (_expandedMembers.Contains(accountId))
273
- _expandedMembers.Remove(accountId);
274
  else
275
- _expandedMembers.Add(accountId);
276
  }
277
 
278
  private async Task FulfillAsync(LoyaltyRedemptionDto redemption)
279
  {
280
- bool confirmed = await JS.InvokeAsync<bool>("confirm", "Fulfill this redemption and grant membership to the member?");
281
  if (!confirmed) return;
282
 
283
  var result = await ApiClient.FulfillRedemptionAsync(redemption.Id);
284
  if (result.Success)
285
  {
286
- await JS.InvokeVoidAsync("alert", result.Message);
287
  await LoadDataAsync();
 
288
  }
289
  else
290
  {
291
  await JS.InvokeVoidAsync("alert", result.Message);
292
  }
293
  }
294
-
295
- private string PrettyEvent(string? eventKey, double points)
296
- {
297
- var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
298
-
299
- return key switch
300
- {
301
- "RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
302
- "SIGNUP" or "REGISTER" => "SIGNUP",
303
- "SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
304
- "REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
305
- _ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
306
- };
307
- }
308
  }
 
1
  @page "/rewards/manage"
 
2
  @using LibraryManagement.Shared.Models
3
+ @using BlazorWebAssembly.Services
4
  @using Microsoft.AspNetCore.Authorization
5
+ @attribute [Authorize(Roles = "Librarian")]
6
  @inject LibraryApiClient ApiClient
7
  @inject IJSRuntime JS
 
8
 
9
+ <div class="max-w-7xl mx-auto py-12 px-6">
10
+ <!-- Header -->
11
+ <div class="mb-12">
12
+ <h1 class="text-5xl font-black tracking-tight text-main mb-4">Rewards Management</h1>
13
+ <p class="text-lg text-muted max-w-2xl">
14
+ Review and fulfill loyalty reward claims from library members.
15
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
16
  </div>
17
 
18
+ <!-- Tabs -->
19
+ <div class="flex items-center gap-8 border-b border-border mb-12">
20
  <button @onclick='() => _activeTab = "pending"'
21
+ class='pb-4 text-sm font-bold tracking-widest uppercase transition-all relative @(_activeTab == "pending" ? "text-primary" : "text-muted hover:text-main")'>
22
+ Pending Redemptions
23
+ @if (_pendingRedemptions.Any())
24
+ {
25
+ <span class="ml-2 bg-primary text-white text-[10px] px-2 py-0.5 rounded-full">@_pendingRedemptions.Count()</span>
26
+ }
27
+ @if (_activeTab == "pending")
28
+ {
29
+ <div class="absolute bottom-0 left-0 w-full h-0.5 bg-primary animate-scale-x"></div>
30
+ }
31
  </button>
32
  <button @onclick='() => _activeTab = "history"'
33
+ class='pb-4 text-sm font-bold tracking-widest uppercase transition-all relative @(_activeTab == "history" ? "text-primary" : "text-muted hover:text-main")'>
34
  Members Points History
35
+ @if (_activeTab == "history")
36
+ {
37
+ <div class="absolute bottom-0 left-0 w-full h-0.5 bg-primary animate-scale-x"></div>
38
+ }
39
  </button>
40
  </div>
41
 
42
  @if (_isLoading)
43
  {
44
+ <div class="flex justify-center py-20">
45
+ <div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
 
 
 
46
  </div>
47
  }
48
  else
 
53
  @if (!_pendingRedemptions.Any())
54
  {
55
  <div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm">
56
+ <p class="text-muted font-bold text-lg">No pending redemptions at the moment.</p>
 
57
  </div>
58
  }
59
  else
60
  {
61
+ <div class="max-w-6xl">
62
+ <div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
63
+ <table class="w-full text-left border-collapse whitespace-nowrap">
64
+ <thead class="bg-body border-b border-border">
65
+ <tr>
66
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th>
67
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Reward</th>
68
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Points</th>
69
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Claimed Date</th>
70
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th>
71
+ <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Action</th>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  </tr>
73
+ </thead>
74
+ <tbody class="divide-y divide-border">
75
+ @foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt))
76
+ {
77
+ <tr class="hover:bg-body transition-colors">
78
+ <td class="px-6 py-4">
79
+ <div class="flex items-center gap-3">
80
+ <div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">
81
+ @(string.IsNullOrEmpty(r.UserName) || r.UserName == "Unknown Member" ? "U" : r.UserName.Substring(0, 1).ToUpper())
82
+ </div>
83
+ <span class="text-sm font-bold text-main">@(r.UserName ?? "Unknown Member")</span>
84
+ </div>
85
+ </td>
86
+ <td class="px-6 py-4">
87
+ <p class="text-sm font-bold text-main">@r.RewardName</p>
88
+ <p class="text-[10px] text-muted font-bold tracking-widest uppercase">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)</p>
89
+ </td>
90
+ <td class="px-6 py-4 text-sm font-black text-primary">@r.PointCost.ToString("N0") pts</td>
91
+ <td class="px-6 py-4 text-sm font-medium text-muted">@r.RedeemedAt.ToString("MMM dd, yyyy")</td>
92
+ <td class="px-4 py-4">
93
+ <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-amber-500/10 text-amber-500">
94
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
95
+ Pending
96
+ </span>
97
+ </td>
98
+ <td class="px-4 py-4">
99
+ <button @onclick="() => FulfillAsync(r)"
100
+ class="inline-flex items-center gap-1.5 bg-green-500/10 text-green-500 border border-green-500/20 px-4 py-2 rounded-lg text-xs font-bold hover:bg-green-500 hover:text-white transition-all shadow-sm active:scale-95">
101
+ <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>
102
+ Fulfill
103
+ </button>
104
+ </td>
105
+ </tr>
106
+ }
107
+ </tbody>
108
+ </table>
109
+ </div>
110
  </div>
111
  }
112
  </div>
 
143
  <p class="text-xs font-medium text-muted">@member.UserEmail</p>
144
  </div>
145
  </div>
146
+ <div class="flex items-center gap-12">
147
+ <div class="text-center sm:text-right">
148
+ <p class="text-[10px] text-muted font-bold tracking-widest uppercase mb-1">Current Balance</p>
149
+ <p class="text-2xl font-black text-primary">@member.CurrentBalance.ToString("N0") <span class="text-xs">PTS</span></p>
150
  </div>
151
+ <div class="text-center sm:text-right">
152
+ <p class="text-[10px] text-muted font-bold tracking-widest uppercase mb-1">Tier</p>
153
+ <span class="inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-primary text-white shadow-sm">@member.Tier</span>
154
  </div>
155
+ <div class="w-8 h-8 rounded-full bg-body flex items-center justify-center transition-transform @(_expandedMemberId == member.AccountId ? "rotate-180" : "")">
156
+ <svg class="w-5 h-5 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"></path></svg>
 
157
  </div>
 
158
  </div>
159
  </div>
160
+
161
+ @if (_expandedMemberId == member.AccountId)
162
  {
163
+ <div class="border-t border-border bg-body/30 p-6 animate-slide-down">
164
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
165
+ <!-- Points History -->
166
+ <div>
167
+ <h4 class="text-xs font-bold text-muted uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
168
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
169
+ Points History
170
+ </h4>
171
+ <div class="space-y-2 max-h-64 overflow-y-auto pr-2 custom-scrollbar">
172
+ @foreach (var entry in member.History.OrderByDescending(x => x.CreatedAt))
173
+ {
174
+ <div class="flex items-center justify-between p-3 bg-card rounded-xl border border-border shadow-sm">
175
+ <div>
176
+ <p class="text-sm font-bold text-main">@entry.Description</p>
177
+ <p class="text-[10px] text-muted">@entry.CreatedAt.ToString("MMM dd, yyyy HH:mm")</p>
178
+ </div>
179
+ <div class='text-sm font-black @(entry.PointDelta >= 0 ? "text-green-500" : "text-red-500")'>
180
+ @(entry.PointDelta >= 0 ? "+" : "")@entry.PointDelta
181
+ </div>
182
+ </div>
183
+ }
184
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </div>
186
+
187
+ <!-- Redemptions -->
188
+ <div>
189
+ <h4 class="text-xs font-bold text-muted uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
190
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
191
+ Redemption History
192
+ </h4>
193
+ <div class="space-y-2 max-h-64 overflow-y-auto pr-2 custom-scrollbar">
194
+ @foreach (var red in member.Redemptions.OrderByDescending(x => x.RedeemedAt))
195
+ {
196
+ <div class="flex items-center justify-between p-3 bg-card rounded-xl border border-border shadow-sm">
197
+ <div>
198
+ <p class="text-sm font-bold text-main">@red.RewardName</p>
199
+ <p class="text-[10px] text-muted">@red.RedeemedAt.ToString("MMM dd, yyyy")</p>
200
+ </div>
201
+ <div class="text-right">
202
+ <p class="text-xs font-black text-primary">@red.PointCost pts</p>
203
+ <span class='text-[9px] font-bold uppercase tracking-widest @(red.Status == "Fulfilled" ? "text-green-500" : "text-amber-500")'>@red.Status</span>
204
+ </div>
205
+ </div>
206
+ }
207
+ </div>
208
+ </div>
209
+ </div>
210
  </div>
211
  }
212
  </div>
 
221
  @code {
222
  private string _activeTab = "pending";
223
  private bool _isLoading = true;
 
224
  private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
225
  private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>();
226
+ private string _searchQuery = "";
227
+ private string? _expandedMemberId;
228
 
229
  protected override async Task OnInitializedAsync()
230
  {
 
241
  }
242
  catch (Exception ex)
243
  {
244
+ Console.WriteLine($"Error loading rewards manage data: {ex.Message}");
245
  }
246
  finally
247
  {
 
253
  string.IsNullOrWhiteSpace(_searchQuery)
254
  ? _allMembersHistory
255
  : _allMembersHistory.Where(m => m.UserName.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
256
+ m.UserEmail.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
257
 
258
  private void ToggleMember(string accountId)
259
  {
260
+ if (_expandedMemberId == accountId)
261
+ _expandedMemberId = null;
262
  else
263
+ _expandedMemberId = accountId;
264
  }
265
 
266
  private async Task FulfillAsync(LoyaltyRedemptionDto redemption)
267
  {
268
+ bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Fulfill {redemption.RewardName} for {redemption.UserName}?");
269
  if (!confirmed) return;
270
 
271
  var result = await ApiClient.FulfillRedemptionAsync(redemption.Id);
272
  if (result.Success)
273
  {
 
274
  await LoadDataAsync();
275
+ await JS.InvokeVoidAsync("alert", result.Message);
276
  }
277
  else
278
  {
279
  await JS.InvokeVoidAsync("alert", result.Message);
280
  }
281
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
LibraryManagement.Shared/Models/LibraryDtos.cs CHANGED
@@ -129,6 +129,7 @@ namespace LibraryManagement.Shared.Models
129
  public DateTime StartDate { get; set; }
130
  public DateTime ExpiryDate { get; set; }
131
  public bool IsActive { get; set; }
 
132
  public bool IsExpired => DateTime.UtcNow > ExpiryDate;
133
  }
134
 
@@ -217,6 +218,8 @@ namespace LibraryManagement.Shared.Models
217
 
218
  [JsonPropertyName("redeemedAt")]
219
  public DateTime RedeemedAt { get; set; }
 
 
220
  }
221
 
222
  // User DTOs
 
129
  public DateTime StartDate { get; set; }
130
  public DateTime ExpiryDate { get; set; }
131
  public bool IsActive { get; set; }
132
+ public string? ExternalRedemptionId { get; set; }
133
  public bool IsExpired => DateTime.UtcNow > ExpiryDate;
134
  }
135
 
 
218
 
219
  [JsonPropertyName("redeemedAt")]
220
  public DateTime RedeemedAt { get; set; }
221
+
222
+ public string? UserName { get; set; }
223
  }
224
 
225
  // User DTOs