Yuyuqt commited on
Commit
ad67295
·
1 Parent(s): da13799

add: points history

Browse files
Backend/Features/Loyalty/LoyaltyAccountsController.cs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System.Security.Claims;
2
+ using Microsoft.AspNetCore.Authorization;
3
+ using Microsoft.AspNetCore.Mvc;
4
+
5
+ namespace Backend.Features.Loyalty
6
+ {
7
+ [ApiController]
8
+ [Route("api/v1/accounts")]
9
+ [Authorize]
10
+ public class LoyaltyAccountsController : ControllerBase
11
+ {
12
+ private readonly ILoyaltyService _loyaltyService;
13
+
14
+ public LoyaltyAccountsController(ILoyaltyService loyaltyService)
15
+ {
16
+ _loyaltyService = loyaltyService;
17
+ }
18
+
19
+ [HttpGet("{accountId}/history")]
20
+ public async Task<IActionResult> GetAccountHistory(string accountId)
21
+ {
22
+ // Librarians may view any account history; members may only view their own.
23
+ if (!User.IsInRole("Librarian"))
24
+ {
25
+ var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
26
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
27
+
28
+ var myAccount = await _loyaltyService.GetUserAccountAsync(userIdStr);
29
+ if (myAccount == null) return NotFound("Loyalty account not found.");
30
+ if (!string.Equals(myAccount.Id, accountId, StringComparison.OrdinalIgnoreCase))
31
+ return Forbid();
32
+ }
33
+
34
+ var history = await _loyaltyService.GetPointsHistoryAsync(accountId);
35
+ return Ok(history);
36
+ }
37
+ }
38
+ }
39
+
Backend/Features/Loyalty/LoyaltyController.cs CHANGED
@@ -1,9 +1,9 @@
1
  using Backend.Features.Subscriptions;
 
2
  using Microsoft.AspNetCore.Authorization;
3
  using Microsoft.AspNetCore.Mvc;
 
4
  using System.Security.Claims;
5
- using System.Threading.Tasks;
6
- using System.Linq;
7
 
8
  namespace Backend.Features.Loyalty
9
  {
@@ -14,28 +14,25 @@ namespace Backend.Features.Loyalty
14
  {
15
  private readonly ILoyaltyService _loyaltyService;
16
  private readonly ISubscriptionService _subscriptionService;
 
17
 
18
- public LoyaltyController(ILoyaltyService loyaltyService, ISubscriptionService subscriptionService)
19
  {
20
  _loyaltyService = loyaltyService;
21
  _subscriptionService = subscriptionService;
 
22
  }
23
 
 
 
24
  [HttpGet("my-account")]
25
  public async Task<IActionResult> GetMyAccount()
26
  {
27
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
28
- if (string.IsNullOrEmpty(userIdStr))
29
- {
30
- return Unauthorized();
31
- }
32
 
33
  var account = await _loyaltyService.GetUserAccountAsync(userIdStr);
34
- if (account == null)
35
- {
36
- return NotFound("Loyalty account not found.");
37
- }
38
-
39
  return Ok(account);
40
  }
41
 
@@ -43,34 +40,50 @@ namespace Backend.Features.Loyalty
43
  public async Task<IActionResult> GetMyRedemptions()
44
  {
45
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
46
- if (string.IsNullOrEmpty(userIdStr))
47
- {
48
- return Unauthorized();
49
- }
50
 
51
- var redemptions = await _loyaltyService.GetRedemptionsHistoryAsync();
 
52
  return Ok(redemptions);
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  [HttpPost("claim")]
56
  public async Task<IActionResult> ClaimReward([FromBody] ClaimRewardRequestDto request)
57
  {
58
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
59
- if (string.IsNullOrEmpty(userIdStr))
60
- {
61
- return Unauthorized();
62
- }
63
 
64
  var (success, message) = await _loyaltyService.ClaimRewardAsync(userIdStr, request.RewardId, request.Notes ?? "Redeemed via Library Web Application");
65
-
66
- if (success)
67
- {
68
- return Ok(new { message });
69
- }
70
-
71
  return BadRequest(new { message });
72
  }
73
 
 
 
74
  [HttpGet("admin/redemptions/pending")]
75
  [Authorize(Roles = "Librarian")]
76
  public async Task<IActionResult> GetPendingRedemptions()
@@ -79,27 +92,61 @@ namespace Backend.Features.Loyalty
79
  return Ok(redemptions);
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  [HttpPost("admin/redemptions/{id}/fulfill")]
83
  [Authorize(Roles = "Librarian")]
84
  public async Task<IActionResult> FulfillRedemption(string id)
85
  {
86
- // 1. Get all pending redemptions to find the details of the one we want
87
  var pending = await _loyaltyService.GetPendingRedemptionsAsync();
88
  var redemption = pending.FirstOrDefault(r => r.Id == id);
89
 
90
  if (redemption == null)
91
- {
92
  return NotFound("Redemption record not found or already processed.");
93
- }
94
 
95
- // 2. Call the external loyalty system to mark as fulfilled
96
  var success = await _loyaltyService.UpdateRedemptionStatusAsync(id, "Fulfilled");
97
  if (!success)
98
- {
99
  return BadRequest("Failed to update status in the loyalty system.");
100
- }
101
 
102
- // 3. side effect: Grant membership in the library system
103
  bool membershipGranted = false;
104
  if (Guid.TryParse(redemption.ExternalUserId?.Trim(), out Guid userId))
105
  {
@@ -110,11 +157,11 @@ namespace Backend.Features.Loyalty
110
  if (!membershipGranted)
111
  {
112
  var errorMsg = $"Redemption fulfilled in loyalty system, but failed to grant library membership. RewardName='{redemption.RewardName}', RewardId='{redemption.RewardId}' (Redemption ID: {id}).";
113
- System.Diagnostics.Debug.WriteLine(errorMsg);
114
  return BadRequest(new { message = errorMsg });
115
  }
116
 
117
  return Ok(new { message = $"Redemption fulfilled and {redemption.RewardName} membership granted successfully." });
118
  }
119
  }
 
120
  }
 
1
  using Backend.Features.Subscriptions;
2
+ using DbConnect.Data;
3
  using Microsoft.AspNetCore.Authorization;
4
  using Microsoft.AspNetCore.Mvc;
5
+ using Microsoft.EntityFrameworkCore;
6
  using System.Security.Claims;
 
 
7
 
8
  namespace Backend.Features.Loyalty
9
  {
 
14
  {
15
  private readonly ILoyaltyService _loyaltyService;
16
  private readonly ISubscriptionService _subscriptionService;
17
+ private readonly AppDbContext _context;
18
 
19
+ public LoyaltyController(ILoyaltyService loyaltyService, ISubscriptionService subscriptionService, AppDbContext context)
20
  {
21
  _loyaltyService = loyaltyService;
22
  _subscriptionService = subscriptionService;
23
+ _context = context;
24
  }
25
 
26
+ // ── Member endpoints ──────────────────────────────────────────────────
27
+
28
  [HttpGet("my-account")]
29
  public async Task<IActionResult> GetMyAccount()
30
  {
31
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
32
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
 
 
 
33
 
34
  var account = await _loyaltyService.GetUserAccountAsync(userIdStr);
35
+ if (account == null) return NotFound("Loyalty account not found.");
 
 
 
 
36
  return Ok(account);
37
  }
38
 
 
40
  public async Task<IActionResult> GetMyRedemptions()
41
  {
42
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
43
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
 
 
 
44
 
45
+ // Filter to this user's redemptions only
46
+ var redemptions = await _loyaltyService.GetUserRedemptionsAsync(userIdStr);
47
  return Ok(redemptions);
48
  }
49
 
50
+ [HttpGet("my-pending-redemptions")]
51
+ public async Task<IActionResult> GetMyPendingRedemptions()
52
+ {
53
+ var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
54
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
55
+
56
+ var redemptions = await _loyaltyService.GetUserRedemptionsAsync(userIdStr);
57
+ var pending = redemptions.Where(r => r.Status.Equals("Pending", StringComparison.OrdinalIgnoreCase));
58
+ return Ok(pending);
59
+ }
60
+
61
+ [HttpGet("my-points-history")]
62
+ public async Task<IActionResult> GetMyPointsHistory()
63
+ {
64
+ var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
65
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
66
+
67
+ var account = await _loyaltyService.GetUserAccountAsync(userIdStr);
68
+ if (account == null) return NotFound("Loyalty account not found.");
69
+
70
+ var history = await _loyaltyService.GetPointsHistoryAsync(account.Id);
71
+ return Ok(history);
72
+ }
73
+
74
  [HttpPost("claim")]
75
  public async Task<IActionResult> ClaimReward([FromBody] ClaimRewardRequestDto request)
76
  {
77
  var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
78
+ if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
 
 
 
79
 
80
  var (success, message) = await _loyaltyService.ClaimRewardAsync(userIdStr, request.RewardId, request.Notes ?? "Redeemed via Library Web Application");
81
+ if (success) return Ok(new { message });
 
 
 
 
 
82
  return BadRequest(new { message });
83
  }
84
 
85
+ // ── Librarian endpoints ───────────────────────────────────────────────
86
+
87
  [HttpGet("admin/redemptions/pending")]
88
  [Authorize(Roles = "Librarian")]
89
  public async Task<IActionResult> GetPendingRedemptions()
 
92
  return Ok(redemptions);
93
  }
94
 
95
+ [HttpGet("admin/all-points-history")]
96
+ [Authorize(Roles = "Librarian")]
97
+ public async Task<IActionResult> GetAllMembersPointsHistory()
98
+ {
99
+ // Get all users from DB
100
+ var users = await _context.Users
101
+ .Select(u => new { u.Id, u.FullName, u.Email })
102
+ .ToListAsync();
103
+
104
+ var results = new List<object>();
105
+
106
+ foreach (var user in users)
107
+ {
108
+ try
109
+ {
110
+ var account = await _loyaltyService.GetUserAccountAsync(user.Id.ToString());
111
+ if (account == null) continue;
112
+
113
+ var accountId = !string.IsNullOrWhiteSpace(account.Id) ? account.Id : (account.AccountId ?? string.Empty);
114
+ if (string.IsNullOrWhiteSpace(accountId)) continue;
115
+
116
+ var history = await _loyaltyService.GetPointsHistoryAsync(accountId);
117
+ var redemptions = await _loyaltyService.GetUserRedemptionsAsync(user.Id.ToString());
118
+ results.Add(new
119
+ {
120
+ userId = user.Id,
121
+ userName = user.FullName,
122
+ userEmail = user.Email,
123
+ accountId = accountId,
124
+ currentBalance = account.CurrentBalance,
125
+ tier = account.Tier,
126
+ history = history,
127
+ redemptions = redemptions
128
+ });
129
+ }
130
+ catch { /* Skip users not in loyalty system */ }
131
+ }
132
+
133
+ return Ok(results);
134
+ }
135
+
136
  [HttpPost("admin/redemptions/{id}/fulfill")]
137
  [Authorize(Roles = "Librarian")]
138
  public async Task<IActionResult> FulfillRedemption(string id)
139
  {
 
140
  var pending = await _loyaltyService.GetPendingRedemptionsAsync();
141
  var redemption = pending.FirstOrDefault(r => r.Id == id);
142
 
143
  if (redemption == null)
 
144
  return NotFound("Redemption record not found or already processed.");
 
145
 
 
146
  var success = await _loyaltyService.UpdateRedemptionStatusAsync(id, "Fulfilled");
147
  if (!success)
 
148
  return BadRequest("Failed to update status in the loyalty system.");
 
149
 
 
150
  bool membershipGranted = false;
151
  if (Guid.TryParse(redemption.ExternalUserId?.Trim(), out Guid userId))
152
  {
 
157
  if (!membershipGranted)
158
  {
159
  var errorMsg = $"Redemption fulfilled in loyalty system, but failed to grant library membership. RewardName='{redemption.RewardName}', RewardId='{redemption.RewardId}' (Redemption ID: {id}).";
 
160
  return BadRequest(new { message = errorMsg });
161
  }
162
 
163
  return Ok(new { message = $"Redemption fulfilled and {redemption.RewardName} membership granted successfully." });
164
  }
165
  }
166
+
167
  }
Backend/Features/Loyalty/LoyaltyService.cs CHANGED
@@ -20,6 +20,8 @@ namespace Backend.Features.Loyalty
20
  Task<IEnumerable<LoyaltyRedemptionDto>> GetUserRedemptionsAsync(string externalUserId);
21
  Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync();
22
  Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
 
 
23
  }
24
 
25
  public class LoyaltyService : ILoyaltyService
@@ -106,7 +108,12 @@ namespace Backend.Features.Loyalty
106
  if (response.IsSuccessStatusCode)
107
  {
108
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
109
- return await response.Content.ReadFromJsonAsync<AccountLookupResponse>(options);
 
 
 
 
 
110
  }
111
  return null;
112
  }
@@ -133,16 +140,19 @@ namespace Backend.Features.Loyalty
133
  {
134
  return (true, "Reward claimed successfully!");
135
  }
136
-
137
  var error = await response.Content.ReadAsStringAsync();
138
  _logger.LogWarning($"Failed to claim reward {rewardId} for user {externalUserId}: {error}");
139
-
140
- try {
 
141
  var errorDoc = JsonDocument.Parse(error);
142
- if (errorDoc.RootElement.TryGetProperty("message", out var msgElement)) {
 
143
  return (false, msgElement.GetString() ?? "Failed to claim reward.");
144
  }
145
- } catch { }
 
146
 
147
  return (false, string.IsNullOrEmpty(error) ? "Failed to claim reward." : error);
148
  }
@@ -181,7 +191,7 @@ namespace Backend.Features.Loyalty
181
  {
182
  var rawJson = await response.Content.ReadAsStringAsync();
183
  _logger.LogInformation($"Raw Pending Redemptions JSON: {rawJson}");
184
-
185
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
186
  return JsonSerializer.Deserialize<IEnumerable<LoyaltyRedemptionDto>>(rawJson, options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
187
  }
@@ -226,6 +236,76 @@ namespace Backend.Features.Loyalty
226
  return false;
227
  }
228
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  }
230
 
231
  public class AccountLookupResponse
@@ -233,6 +313,10 @@ namespace Backend.Features.Loyalty
233
  [JsonPropertyName("id")]
234
  public string Id { get; set; } = string.Empty;
235
 
 
 
 
 
236
  [JsonPropertyName("externalUserId")]
237
  public string ExternalUserId { get; set; } = string.Empty;
238
 
 
20
  Task<IEnumerable<LoyaltyRedemptionDto>> GetUserRedemptionsAsync(string externalUserId);
21
  Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync();
22
  Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
23
+ Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId);
24
+ Task<IEnumerable<PointHistoryEntryDto>> GetAllMembersPointsHistoryAsync();
25
  }
26
 
27
  public class LoyaltyService : ILoyaltyService
 
108
  if (response.IsSuccessStatusCode)
109
  {
110
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
111
+ var account = await response.Content.ReadFromJsonAsync<AccountLookupResponse>(options);
112
+ if (account != null && string.IsNullOrWhiteSpace(account.Id) && !string.IsNullOrWhiteSpace(account.AccountId))
113
+ {
114
+ account.Id = account.AccountId;
115
+ }
116
+ return account;
117
  }
118
  return null;
119
  }
 
140
  {
141
  return (true, "Reward claimed successfully!");
142
  }
143
+
144
  var error = await response.Content.ReadAsStringAsync();
145
  _logger.LogWarning($"Failed to claim reward {rewardId} for user {externalUserId}: {error}");
146
+
147
+ try
148
+ {
149
  var errorDoc = JsonDocument.Parse(error);
150
+ if (errorDoc.RootElement.TryGetProperty("message", out var msgElement))
151
+ {
152
  return (false, msgElement.GetString() ?? "Failed to claim reward.");
153
  }
154
+ }
155
+ catch { }
156
 
157
  return (false, string.IsNullOrEmpty(error) ? "Failed to claim reward." : error);
158
  }
 
191
  {
192
  var rawJson = await response.Content.ReadAsStringAsync();
193
  _logger.LogInformation($"Raw Pending Redemptions JSON: {rawJson}");
194
+
195
  var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
196
  return JsonSerializer.Deserialize<IEnumerable<LoyaltyRedemptionDto>>(rawJson, options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
197
  }
 
236
  return false;
237
  }
238
  }
239
+
240
+ public async Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId)
241
+ {
242
+ try
243
+ {
244
+ var response = await _httpClient.GetAsync($"/api/v1/accounts/{accountId}/history");
245
+ if (response.IsSuccessStatusCode)
246
+ {
247
+ var rawJson = await response.Content.ReadAsStringAsync();
248
+ _logger.LogInformation("Raw Points History JSON for account {AccountId}: {Json}", accountId, rawJson);
249
+
250
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
251
+ return JsonSerializer.Deserialize<IEnumerable<PointHistoryEntryDto>>(rawJson, options) ?? Enumerable.Empty<PointHistoryEntryDto>();
252
+ }
253
+
254
+ var error = await response.Content.ReadAsStringAsync();
255
+ _logger.LogWarning("Points history request failed for account {AccountId}. Status={Status}. Body={Body}", accountId, response.StatusCode, error);
256
+ return Enumerable.Empty<PointHistoryEntryDto>();
257
+ }
258
+ catch (Exception ex)
259
+ {
260
+ _logger.LogError(ex, $"Error fetching points history for account {accountId}.");
261
+ return Enumerable.Empty<PointHistoryEntryDto>();
262
+ }
263
+ }
264
+
265
+ public async Task<IEnumerable<PointHistoryEntryDto>> GetAllMembersPointsHistoryAsync()
266
+ {
267
+ return Enumerable.Empty<PointHistoryEntryDto>();
268
+ }
269
+ }
270
+
271
+ public class PointHistoryEntryDto
272
+ {
273
+ [JsonPropertyName("id")]
274
+ public int Id { get; set; }
275
+
276
+ [JsonPropertyName("accountId")]
277
+ public string AccountId { get; set; } = string.Empty;
278
+
279
+ [JsonPropertyName("externalUserId")]
280
+ public string? ExternalUserId { get; set; }
281
+
282
+ [JsonPropertyName("pointDelta")]
283
+ public double PointDelta { get; set; }
284
+
285
+ [JsonPropertyName("eventKey")]
286
+ public string EventKey { get; set; } = string.Empty;
287
+
288
+ [JsonPropertyName("description")]
289
+ public string Description { get; set; } = string.Empty;
290
+
291
+ [JsonPropertyName("referenceId")]
292
+ public string? ReferenceId { get; set; }
293
+
294
+ [JsonPropertyName("createdAt")]
295
+ public DateTime CreatedAt { get; set; }
296
+
297
+ // Optional redemption-related fields (present for REDEEM events)
298
+ [JsonPropertyName("rewardId")]
299
+ public string? RewardId { get; set; }
300
+
301
+ [JsonPropertyName("rewardName")]
302
+ public string? RewardName { get; set; }
303
+
304
+ [JsonPropertyName("redemptionStatus")]
305
+ public string? RedemptionStatus { get; set; }
306
+
307
+ [JsonPropertyName("redeemedAt")]
308
+ public DateTime? RedeemedAt { get; set; }
309
  }
310
 
311
  public class AccountLookupResponse
 
313
  [JsonPropertyName("id")]
314
  public string Id { get; set; } = string.Empty;
315
 
316
+ // Some loyalty API versions return `accountId` instead of `id`.
317
+ [JsonPropertyName("accountId")]
318
+ public string? AccountId { get; set; }
319
+
320
  [JsonPropertyName("externalUserId")]
321
  public string ExternalUserId { get; set; } = string.Empty;
322
 
Frontend/Controllers/RewardsController.cs ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using Frontend.Models;
2
+ using Frontend.Models.Dtos;
3
+ using Frontend.Services;
4
+ using Microsoft.AspNetCore.Authorization;
5
+ using Microsoft.AspNetCore.Mvc;
6
+
7
+ namespace Frontend.Controllers
8
+ {
9
+ [Authorize]
10
+ public class RewardsController : Controller
11
+ {
12
+ private readonly LibraryApiClient _apiClient;
13
+
14
+ public RewardsController(LibraryApiClient apiClient)
15
+ {
16
+ _apiClient = apiClient;
17
+ }
18
+
19
+ // Member view: available rewards + pending redemptions + points history
20
+ [HttpGet]
21
+ public async Task<IActionResult> Index()
22
+ {
23
+ var rewards = await _apiClient.GetActiveRewardsAsync();
24
+ var account = await _apiClient.GetMyLoyaltyAccountAsync();
25
+ var pending = await _apiClient.GetMyPendingRedemptionsAsync();
26
+ var redemptions = await _apiClient.GetMyRedemptionsAsync();
27
+ if (account != null && string.IsNullOrWhiteSpace(account.Id) && !string.IsNullOrWhiteSpace(account.AccountId))
28
+ account.Id = account.AccountId;
29
+
30
+ var history = account != null && !string.IsNullOrWhiteSpace(account.Id)
31
+ ? await _apiClient.GetPointsHistoryAsync(account.Id)
32
+ : Enumerable.Empty<PointHistoryEntryDto>();
33
+
34
+ var vm = new RewardsViewModel
35
+ {
36
+ Rewards = rewards,
37
+ Account = account,
38
+ PendingRedemptions = pending,
39
+ RedemptionsHistory = redemptions,
40
+ PointsHistory = history
41
+ };
42
+
43
+ return View(vm);
44
+ }
45
+
46
+ // Librarian view: all pending + all members' point history
47
+ [HttpGet]
48
+ [Authorize(Roles = "Librarian")]
49
+ public async Task<IActionResult> Manage()
50
+ {
51
+ var pending = await _apiClient.GetPendingRedemptionsAsync();
52
+ var allHistory = (await _apiClient.GetAllMembersPointsHistoryAsync())
53
+ .Select(m =>
54
+ {
55
+ m.History = m.History?.ToList() ?? new List<PointHistoryEntryDto>();
56
+ m.Redemptions = m.Redemptions?.ToList() ?? new List<LoyaltyRedemptionDto>();
57
+ return m;
58
+ })
59
+ .ToList();
60
+
61
+ var vm = new LibrarianRewardsViewModel
62
+ {
63
+ PendingRedemptions = pending,
64
+ AllMembersHistory = allHistory
65
+ };
66
+
67
+ return View(vm);
68
+ }
69
+
70
+ // POST: fulfill a redemption (librarian)
71
+ [HttpPost]
72
+ [Authorize(Roles = "Librarian")]
73
+ public async Task<IActionResult> Fulfill(string id)
74
+ {
75
+ var (success, message) = await _apiClient.FulfillRedemptionAsync(id);
76
+ if (success)
77
+ TempData["Success"] = message;
78
+ else
79
+ TempData["Error"] = message;
80
+
81
+ return RedirectToAction(nameof(Manage));
82
+ }
83
+
84
+ // POST: redeem a reward (member)
85
+ [HttpPost]
86
+ public async Task<IActionResult> Redeem(string rewardId)
87
+ {
88
+ var (success, message) = await _apiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Rewards Page");
89
+ return Json(new { success, message });
90
+ }
91
+ }
92
+ }
Frontend/Models/Dtos/LibraryDtos.cs CHANGED
@@ -145,6 +145,10 @@ namespace Frontend.Models.Dtos
145
  [JsonPropertyName("id")]
146
  public string Id { get; set; } = string.Empty;
147
 
 
 
 
 
148
  [JsonPropertyName("externalUserId")]
149
  public string ExternalUserId { get; set; } = string.Empty;
150
 
@@ -224,4 +228,57 @@ namespace Frontend.Models.Dtos
224
  {
225
  public string Role { get; set; } = string.Empty;
226
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  }
 
145
  [JsonPropertyName("id")]
146
  public string Id { get; set; } = string.Empty;
147
 
148
+ // Some responses use `accountId` instead of `id`.
149
+ [JsonPropertyName("accountId")]
150
+ public string? AccountId { get; set; }
151
+
152
  [JsonPropertyName("externalUserId")]
153
  public string ExternalUserId { get; set; } = string.Empty;
154
 
 
228
  {
229
  public string Role { get; set; } = string.Empty;
230
  }
231
+
232
+ // Points History DTOs
233
+ public class PointHistoryEntryDto
234
+ {
235
+ [JsonPropertyName("id")]
236
+ public int Id { get; set; }
237
+ [JsonPropertyName("accountId")]
238
+ public string AccountId { get; set; } = string.Empty;
239
+ [JsonPropertyName("externalUserId")]
240
+ public string? ExternalUserId { get; set; }
241
+ [JsonPropertyName("pointDelta")]
242
+ public double PointDelta { get; set; }
243
+ [JsonPropertyName("eventKey")]
244
+ public string EventKey { get; set; } = string.Empty;
245
+ [JsonPropertyName("description")]
246
+ public string Description { get; set; } = string.Empty;
247
+ [JsonPropertyName("referenceId")]
248
+ public string? ReferenceId { get; set; }
249
+ [JsonPropertyName("createdAt")]
250
+ public DateTime CreatedAt { get; set; }
251
+
252
+ [JsonPropertyName("rewardId")]
253
+ public string? RewardId { get; set; }
254
+
255
+ [JsonPropertyName("rewardName")]
256
+ public string? RewardName { get; set; }
257
+
258
+ [JsonPropertyName("redemptionStatus")]
259
+ public string? RedemptionStatus { get; set; }
260
+
261
+ [JsonPropertyName("redeemedAt")]
262
+ public DateTime? RedeemedAt { get; set; }
263
+ }
264
+
265
+ public class UserPointsHistoryDto
266
+ {
267
+ [JsonPropertyName("userId")]
268
+ public Guid UserId { get; set; }
269
+ [JsonPropertyName("userName")]
270
+ public string UserName { get; set; } = string.Empty;
271
+ [JsonPropertyName("userEmail")]
272
+ public string UserEmail { get; set; } = string.Empty;
273
+ [JsonPropertyName("accountId")]
274
+ public string AccountId { get; set; } = string.Empty;
275
+ [JsonPropertyName("currentBalance")]
276
+ public double CurrentBalance { get; set; }
277
+ [JsonPropertyName("tier")]
278
+ public string Tier { get; set; } = string.Empty;
279
+ [JsonPropertyName("history")]
280
+ public IEnumerable<PointHistoryEntryDto> History { get; set; } = Enumerable.Empty<PointHistoryEntryDto>();
281
+ [JsonPropertyName("redemptions")]
282
+ public IEnumerable<LoyaltyRedemptionDto> Redemptions { get; set; } = Enumerable.Empty<LoyaltyRedemptionDto>();
283
+ }
284
  }
Frontend/Models/RewardsViewModel.cs CHANGED
@@ -2,9 +2,20 @@ using Frontend.Models.Dtos;
2
 
3
  namespace Frontend.Models
4
  {
 
5
  public class RewardsViewModel
6
  {
7
  public IEnumerable<LoyaltyRewardDto> Rewards { get; set; } = Enumerable.Empty<LoyaltyRewardDto>();
8
  public LoyaltyAccountDto? Account { get; set; }
 
 
 
 
 
 
 
 
 
 
9
  }
10
  }
 
2
 
3
  namespace Frontend.Models
4
  {
5
+ // Member rewards page view model
6
  public class RewardsViewModel
7
  {
8
  public IEnumerable<LoyaltyRewardDto> Rewards { get; set; } = Enumerable.Empty<LoyaltyRewardDto>();
9
  public LoyaltyAccountDto? Account { get; set; }
10
+ public IEnumerable<LoyaltyRedemptionDto> PendingRedemptions { get; set; } = Enumerable.Empty<LoyaltyRedemptionDto>();
11
+ public IEnumerable<LoyaltyRedemptionDto> RedemptionsHistory { get; set; } = Enumerable.Empty<LoyaltyRedemptionDto>();
12
+ public IEnumerable<PointHistoryEntryDto> PointsHistory { get; set; } = Enumerable.Empty<PointHistoryEntryDto>();
13
+ }
14
+
15
+ // Librarian rewards management view model
16
+ public class LibrarianRewardsViewModel
17
+ {
18
+ public IEnumerable<LoyaltyRedemptionDto> PendingRedemptions { get; set; } = Enumerable.Empty<LoyaltyRedemptionDto>();
19
+ public IEnumerable<UserPointsHistoryDto> AllMembersHistory { get; set; } = Enumerable.Empty<UserPointsHistoryDto>();
20
  }
21
  }
Frontend/Services/LibraryApiClient.cs CHANGED
@@ -13,7 +13,7 @@ namespace Frontend.Services
13
  {
14
  _httpClient = httpClientFactory.CreateClient("LibraryBackend");
15
  _httpContextAccessor = httpContextAccessor;
16
-
17
  AddAuthHeaderFromCookie();
18
  }
19
 
@@ -140,16 +140,20 @@ namespace Frontend.Services
140
 
141
  public async Task<SubscriptionDto?> GetMySubscriptionAsync()
142
  {
143
- try {
 
144
  return await _httpClient.GetFromJsonAsync<SubscriptionDto>("api/subscriptions/me");
145
- } catch { return null; }
 
146
  }
147
 
148
  public async Task<IEnumerable<SubscriptionDto>> GetMyAllSubscriptionsAsync()
149
  {
150
- try {
 
151
  return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/me/all") ?? Enumerable.Empty<SubscriptionDto>();
152
- } catch { return Enumerable.Empty<SubscriptionDto>(); }
 
153
  }
154
 
155
  public async Task<SubscriptionDto?> SubscribeAsync(SubscribeRequest request)
@@ -160,9 +164,11 @@ namespace Frontend.Services
160
 
161
  public async Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId)
162
  {
163
- try {
 
164
  return await _httpClient.GetFromJsonAsync<SubscriptionDto>($"api/subscriptions/user/{userId}");
165
- } catch { return null; }
 
166
  }
167
 
168
  public async Task<SubscriptionDto?> AdminSubscribeAsync(AdminSubscribeRequest request)
@@ -179,7 +185,8 @@ namespace Frontend.Services
179
 
180
  public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
181
  {
182
- try {
 
183
  // Using exact casing for the route to be safe
184
  var response = await _httpClient.GetAsync("api/Loyalty/my-account");
185
  if (response.IsSuccessStatusCode)
@@ -195,17 +202,62 @@ namespace Frontend.Services
195
  // Throw custom exception or return null based on error
196
  }
197
  return null;
198
- } catch (Exception ex) {
 
 
199
  System.Diagnostics.Debug.WriteLine($"Loyalty API Exception: {ex.Message}");
200
- return null;
201
  }
202
  }
203
 
204
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetMyRedemptionsAsync()
205
  {
206
- try {
 
207
  return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>("api/Loyalty/my-redemptions") ?? Enumerable.Empty<LoyaltyRedemptionDto>();
208
- } catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  }
210
 
211
  public async Task<bool> RequestReturnAsync(Guid borrowingId)
@@ -220,7 +272,7 @@ namespace Frontend.Services
220
  {
221
  var request = new ClaimRewardRequestDto { RewardId = rewardId, Notes = notes };
222
  var response = await _httpClient.PostAsJsonAsync("api/Loyalty/claim", request);
223
-
224
  if (response.IsSuccessStatusCode)
225
  {
226
  var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
@@ -250,7 +302,8 @@ namespace Frontend.Services
250
 
251
  public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
252
  {
253
- try {
 
254
  // Fetch from the external loyalty API directly or via our backend proxy
255
  // The user specified the URL: http://150.95.88.91:4100/api/v1/rewards/active/THS-LMS
256
  // We'll use a new HttpClient or just the existing one if configured correctly.
@@ -260,7 +313,8 @@ namespace Frontend.Services
260
  // In ASP.NET Core MVC, the server-side HttpClient can call it directly.
261
  var client = new HttpClient();
262
  return await client.GetFromJsonAsync<IEnumerable<LoyaltyRewardDto>>("http://150.95.88.91:4100/api/v1/rewards/active/THS-LMS") ?? Enumerable.Empty<LoyaltyRewardDto>();
263
- } catch { return Enumerable.Empty<LoyaltyRewardDto>(); }
 
264
  }
265
 
266
  #region Users
@@ -300,7 +354,8 @@ namespace Frontend.Services
300
  #endregion
301
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
302
  {
303
- try {
 
304
  var response = await _httpClient.GetAsync("api/Loyalty/admin/redemptions/pending");
305
  if (response.IsSuccessStatusCode)
306
  {
@@ -308,12 +363,14 @@ namespace Frontend.Services
308
  return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
309
  }
310
  return Enumerable.Empty<LoyaltyRedemptionDto>();
311
- } catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
 
312
  }
313
 
314
  public async Task<(bool Success, string Message)> FulfillRedemptionAsync(string id)
315
  {
316
- try {
 
317
  var response = await _httpClient.PostAsync($"api/Loyalty/admin/redemptions/{id}/fulfill", null);
318
  if (response.IsSuccessStatusCode)
319
  {
@@ -335,7 +392,8 @@ namespace Frontend.Services
335
  }
336
  return (false, msg);
337
  }
338
- } catch (Exception ex) { return (false, $"Error: {ex.Message}"); }
 
339
  }
340
  }
341
  }
 
13
  {
14
  _httpClient = httpClientFactory.CreateClient("LibraryBackend");
15
  _httpContextAccessor = httpContextAccessor;
16
+
17
  AddAuthHeaderFromCookie();
18
  }
19
 
 
140
 
141
  public async Task<SubscriptionDto?> GetMySubscriptionAsync()
142
  {
143
+ try
144
+ {
145
  return await _httpClient.GetFromJsonAsync<SubscriptionDto>("api/subscriptions/me");
146
+ }
147
+ catch { return null; }
148
  }
149
 
150
  public async Task<IEnumerable<SubscriptionDto>> GetMyAllSubscriptionsAsync()
151
  {
152
+ try
153
+ {
154
  return await _httpClient.GetFromJsonAsync<IEnumerable<SubscriptionDto>>("api/subscriptions/me/all") ?? Enumerable.Empty<SubscriptionDto>();
155
+ }
156
+ catch { return Enumerable.Empty<SubscriptionDto>(); }
157
  }
158
 
159
  public async Task<SubscriptionDto?> SubscribeAsync(SubscribeRequest request)
 
164
 
165
  public async Task<SubscriptionDto?> GetUserSubscriptionAsync(Guid userId)
166
  {
167
+ try
168
+ {
169
  return await _httpClient.GetFromJsonAsync<SubscriptionDto>($"api/subscriptions/user/{userId}");
170
+ }
171
+ catch { return null; }
172
  }
173
 
174
  public async Task<SubscriptionDto?> AdminSubscribeAsync(AdminSubscribeRequest request)
 
185
 
186
  public async Task<LoyaltyAccountDto?> GetMyLoyaltyAccountAsync()
187
  {
188
+ try
189
+ {
190
  // Using exact casing for the route to be safe
191
  var response = await _httpClient.GetAsync("api/Loyalty/my-account");
192
  if (response.IsSuccessStatusCode)
 
202
  // Throw custom exception or return null based on error
203
  }
204
  return null;
205
+ }
206
+ catch (Exception ex)
207
+ {
208
  System.Diagnostics.Debug.WriteLine($"Loyalty API Exception: {ex.Message}");
209
+ return null;
210
  }
211
  }
212
 
213
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetMyRedemptionsAsync()
214
  {
215
+ try
216
+ {
217
  return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>("api/Loyalty/my-redemptions") ?? Enumerable.Empty<LoyaltyRedemptionDto>();
218
+ }
219
+ catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
220
+ }
221
+
222
+ public async Task<IEnumerable<LoyaltyRedemptionDto>> GetMyPendingRedemptionsAsync()
223
+ {
224
+ try
225
+ {
226
+ return await _httpClient.GetFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>("api/Loyalty/my-pending-redemptions") ?? Enumerable.Empty<LoyaltyRedemptionDto>();
227
+ }
228
+ catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
229
+ }
230
+
231
+ public async Task<IEnumerable<PointHistoryEntryDto>> GetPointsHistoryAsync(string accountId)
232
+ {
233
+ try
234
+ {
235
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
236
+ var response = await _httpClient.GetAsync($"api/v1/accounts/{accountId}/history");
237
+ if (response.IsSuccessStatusCode)
238
+ return await response.Content.ReadFromJsonAsync<IEnumerable<PointHistoryEntryDto>>(options) ?? Enumerable.Empty<PointHistoryEntryDto>();
239
+ return Enumerable.Empty<PointHistoryEntryDto>();
240
+ }
241
+ catch { return Enumerable.Empty<PointHistoryEntryDto>(); }
242
+ }
243
+
244
+ public async Task<IEnumerable<UserPointsHistoryDto>> GetAllMembersPointsHistoryAsync()
245
+ {
246
+ try {
247
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
248
+ var response = await _httpClient.GetAsync("api/Loyalty/admin/all-points-history");
249
+ var raw = await response.Content.ReadAsStringAsync();
250
+ if (!response.IsSuccessStatusCode)
251
+ return Enumerable.Empty<UserPointsHistoryDto>();
252
+
253
+ var data = JsonSerializer.Deserialize<List<UserPointsHistoryDto>>(raw, options) ?? new List<UserPointsHistoryDto>();
254
+ foreach (var member in data)
255
+ {
256
+ member.History = member.History?.ToList() ?? new List<PointHistoryEntryDto>();
257
+ member.Redemptions = member.Redemptions?.ToList() ?? new List<LoyaltyRedemptionDto>();
258
+ }
259
+ return data;
260
+ } catch { return Enumerable.Empty<UserPointsHistoryDto>(); }
261
  }
262
 
263
  public async Task<bool> RequestReturnAsync(Guid borrowingId)
 
272
  {
273
  var request = new ClaimRewardRequestDto { RewardId = rewardId, Notes = notes };
274
  var response = await _httpClient.PostAsJsonAsync("api/Loyalty/claim", request);
275
+
276
  if (response.IsSuccessStatusCode)
277
  {
278
  var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
 
302
 
303
  public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
304
  {
305
+ try
306
+ {
307
  // Fetch from the external loyalty API directly or via our backend proxy
308
  // The user specified the URL: http://150.95.88.91:4100/api/v1/rewards/active/THS-LMS
309
  // We'll use a new HttpClient or just the existing one if configured correctly.
 
313
  // In ASP.NET Core MVC, the server-side HttpClient can call it directly.
314
  var client = new HttpClient();
315
  return await client.GetFromJsonAsync<IEnumerable<LoyaltyRewardDto>>("http://150.95.88.91:4100/api/v1/rewards/active/THS-LMS") ?? Enumerable.Empty<LoyaltyRewardDto>();
316
+ }
317
+ catch { return Enumerable.Empty<LoyaltyRewardDto>(); }
318
  }
319
 
320
  #region Users
 
354
  #endregion
355
  public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
356
  {
357
+ try
358
+ {
359
  var response = await _httpClient.GetAsync("api/Loyalty/admin/redemptions/pending");
360
  if (response.IsSuccessStatusCode)
361
  {
 
363
  return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
364
  }
365
  return Enumerable.Empty<LoyaltyRedemptionDto>();
366
+ }
367
+ catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
368
  }
369
 
370
  public async Task<(bool Success, string Message)> FulfillRedemptionAsync(string id)
371
  {
372
+ try
373
+ {
374
  var response = await _httpClient.PostAsync($"api/Loyalty/admin/redemptions/{id}/fulfill", null);
375
  if (response.IsSuccessStatusCode)
376
  {
 
392
  }
393
  return (false, msg);
394
  }
395
+ }
396
+ catch (Exception ex) { return (false, $"Error: {ex.Message}"); }
397
  }
398
  }
399
  }
Frontend/Views/Rewards/Index.cshtml ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @model Frontend.Models.RewardsViewModel
2
+ @{
3
+ ViewData["Title"] = "My Rewards";
4
+ var hasAccount = Model.Account != null;
5
+ var redemptionById = Model.RedemptionsHistory
6
+ .Where(r => !string.IsNullOrWhiteSpace(r.Id))
7
+ .GroupBy(r => r.Id)
8
+ .ToDictionary(g => g.Key, g => g.First());
9
+
10
+ string PrettyEvent(string? eventKey, double points)
11
+ {
12
+ var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
13
+
14
+ return key switch
15
+ {
16
+ "RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
17
+ "SIGNUP" or "REGISTER" => "SIGNUP",
18
+ "SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
19
+ "REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
20
+ _ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
21
+ };
22
+ }
23
+ }
24
+
25
+ <div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6">
26
+ <div>
27
+ <h1 class="text-3xl font-extrabold tracking-tight text-slate-900">Loyalty Rewards</h1>
28
+ <p class="text-slate-500 mt-1">Earn points, redeem rewards, and track your loyalty journey.</p>
29
+ </div>
30
+ @if (hasAccount)
31
+ {
32
+ <div class="flex items-center gap-4">
33
+ <div class="text-center bg-amber-50 border border-amber-200 px-6 py-3 rounded-2xl shadow-sm">
34
+ <p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Balance</p>
35
+ <p class="text-2xl font-black text-slate-900">@Model.Account!.CurrentBalance.ToString("N0")</p>
36
+ <p class="text-xs text-amber-600 font-semibold">Points</p>
37
+ </div>
38
+ <div class="text-center bg-slate-50 border border-slate-200 px-6 py-3 rounded-2xl shadow-sm">
39
+ <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tier</p>
40
+ <p class="text-lg font-black text-slate-900">@Model.Account!.Tier</p>
41
+ </div>
42
+ </div>
43
+ }
44
+ </div>
45
+
46
+ <div class="mb-6 border-b border-slate-200">
47
+ <nav class="-mb-px flex gap-6">
48
+ <button onclick="switchTab('tab-redeem','btn-redeem')" id="btn-redeem"
49
+ class="tab-btn pb-3 text-sm font-semibold border-b-2 border-slate-900 text-slate-900 transition-all">
50
+ <span class="inline-flex items-center gap-1.5">
51
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
52
+ <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" />
53
+ </svg>
54
+ Available Rewards
55
+ </span>
56
+ </button>
57
+ <button onclick="switchTab('tab-pending','btn-pending')" id="btn-pending"
58
+ class="tab-btn pb-3 text-sm font-semibold border-b-2 border-transparent text-slate-400 hover:text-slate-700 transition-all">
59
+ <span class="inline-flex items-center gap-1.5">
60
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61
+ <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" />
62
+ </svg>
63
+ Pending
64
+ </span>
65
+ @if (Model.PendingRedemptions.Any())
66
+ {
67
+ <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">@Model.PendingRedemptions.Count()</span>
68
+ }
69
+ </button>
70
+ <button onclick="switchTab('tab-history','btn-history')" id="btn-history"
71
+ class="tab-btn pb-3 text-sm font-semibold border-b-2 border-transparent text-slate-400 hover:text-slate-700 transition-all">
72
+ <span class="inline-flex items-center gap-1.5">
73
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
74
+ <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" />
75
+ </svg>
76
+ Points History
77
+ </span>
78
+ </button>
79
+ </nav>
80
+ </div>
81
+
82
+ <!-- Available Rewards Tab -->
83
+ <div id="tab-redeem" class="tab-content">
84
+ @if (!Model.Rewards.Any())
85
+ {
86
+ <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
87
+ <p class="text-slate-400 font-medium">No active rewards available. Check back soon!</p>
88
+ </div>
89
+ }
90
+ else
91
+ {
92
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
93
+ @foreach (var reward in Model.Rewards)
94
+ {
95
+ var canAfford = hasAccount && Model.Account!.CurrentBalance >= reward.PointCost;
96
+ <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">
97
+ <div class="h-1.5 @(canAfford ? "bg-gradient-to-r from-amber-400 to-orange-400" : "bg-slate-200")"></div>
98
+ <div class="p-6">
99
+ <div class="flex justify-between items-start mb-4">
100
+ <div class="p-3 @(canAfford ? "bg-amber-50 text-amber-600" : "bg-slate-100 text-slate-400") rounded-xl">
101
+ <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>
102
+ </div>
103
+ <div class="text-right">
104
+ <span class="text-2xl font-black text-slate-900">@reward.PointCost.ToString("N0")</span>
105
+ <span class="text-[10px] block font-bold text-slate-400 uppercase">pts required</span>
106
+ </div>
107
+ </div>
108
+ <h3 class="text-base font-bold text-slate-900 mb-1 truncate">@reward.Name</h3>
109
+ <p class="text-sm text-slate-500 mb-5 line-clamp-2 h-10">@reward.Description</p>
110
+ <div class="flex items-center justify-between pt-4 border-t border-slate-100">
111
+ <span class="text-xs font-semibold text-slate-500">@reward.StockQuantity in stock</span>
112
+ @if (canAfford)
113
+ {
114
+ <button onclick="redeemReward('@reward.Id','@reward.Name',@reward.PointCost)"
115
+ 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">
116
+ Redeem
117
+ </button>
118
+ }
119
+ else
120
+ {
121
+ <span class="text-xs font-bold text-slate-400 bg-slate-100 px-3 py-2 rounded-xl">Need more pts</span>
122
+ }
123
+ </div>
124
+ </div>
125
+ @if (reward.StockQuantity < 10)
126
+ {
127
+ <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>
128
+ }
129
+ </div>
130
+ }
131
+ </div>
132
+ }
133
+ <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">
134
+ <div>
135
+ <h2 class="text-lg font-bold mb-1">How to earn more points?</h2>
136
+ <p class="text-slate-400 text-sm">Borrow books, subscribe, and return on time to earn points!</p>
137
+ </div>
138
+ <a asp-controller="Books" asp-action="Index" 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>
139
+ </div>
140
+ </div>
141
+
142
+ <!-- Pending Redemptions Tab -->
143
+ <div id="tab-pending" class="tab-content hidden">
144
+ @if (!Model.PendingRedemptions.Any())
145
+ {
146
+ <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
147
+ <p class="text-slate-400 font-medium">No pending redemptions — you're all clear!</p>
148
+ </div>
149
+ }
150
+ else
151
+ {
152
+ <div class="space-y-3">
153
+ @foreach (var r in Model.PendingRedemptions)
154
+ {
155
+ <div class="flex items-center justify-between bg-white border border-amber-100 rounded-2xl px-6 py-4 shadow-sm">
156
+ <div class="flex items-center gap-4">
157
+ <div class="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center text-amber-500 flex-shrink-0">
158
+ <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>
159
+ </div>
160
+ <div>
161
+ <p class="font-bold text-slate-900 text-sm">@r.RewardName</p>
162
+ <p class="text-xs text-slate-400">Claimed @r.RedeemedAt.ToString("MMM dd, yyyy") · @r.PointCost.ToString("N0") pts</p>
163
+ </div>
164
+ </div>
165
+ <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">
166
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
167
+ Awaiting Fulfillment
168
+ </span>
169
+ </div>
170
+ }
171
+ </div>
172
+ }
173
+ </div>
174
+
175
+ <!-- Points History Tab -->
176
+ <div id="tab-history" class="tab-content hidden">
177
+ @if (!Model.PointsHistory.Any())
178
+ {
179
+ <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
180
+ <p class="text-slate-400 font-medium">No points history yet. Start borrowing books to earn points!</p>
181
+ </div>
182
+ }
183
+ else
184
+ {
185
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
186
+ <table class="min-w-full divide-y divide-slate-100">
187
+ <thead class="bg-slate-50">
188
+ <tr>
189
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Date</th>
190
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Type</th>
191
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Details</th>
192
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Spent on</th>
193
+ <th class="px-6 py-3 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">Points</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody class="divide-y divide-slate-50">
197
+ @foreach (var h in Model.PointsHistory.OrderByDescending(x => x.CreatedAt))
198
+ {
199
+ var isPos = h.PointDelta >= 0;
200
+ var label = PrettyEvent(h.EventKey, h.PointDelta);
201
+ string? spentOn = null;
202
+ if (!isPos && !string.IsNullOrWhiteSpace(h.RewardName))
203
+ {
204
+ spentOn = h.RewardName;
205
+ }
206
+ else if (!isPos && !string.IsNullOrWhiteSpace(h.ReferenceId) && redemptionById.TryGetValue(h.ReferenceId, out var redemption))
207
+ {
208
+ spentOn = redemption.RewardName;
209
+ }
210
+ <tr class="hover:bg-slate-50 transition-colors">
211
+ <td class="px-6 py-3 text-sm text-slate-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
212
+ <td class="px-6 py-3">
213
+ <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>
214
+ </td>
215
+ <td class="px-6 py-3 text-sm text-slate-600 max-w-xs truncate">@h.Description</td>
216
+ <td class="px-6 py-3 text-sm text-slate-600 max-w-xs truncate">
217
+ @if (!string.IsNullOrWhiteSpace(spentOn))
218
+ {
219
+ <span class="font-semibold text-slate-700">@spentOn</span>
220
+ }
221
+ else
222
+ {
223
+ <span class="text-slate-400">—</span>
224
+ }
225
+ </td>
226
+ <td class="px-6 py-3 text-right font-black text-base @(isPos ? "text-emerald-600" : "text-rose-600")">
227
+ @(isPos ? "+" : "")@h.PointDelta.ToString("N0")
228
+ </td>
229
+ </tr>
230
+ }
231
+ </tbody>
232
+ </table>
233
+ </div>
234
+ }
235
+ </div>
236
+
237
+ @section Scripts {
238
+ <script>
239
+ function switchTab(tabId, btnId) {
240
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
241
+ document.getElementById(tabId).classList.remove('hidden');
242
+ document.querySelectorAll('.tab-btn').forEach(b => {
243
+ b.classList.remove('border-slate-900','text-slate-900');
244
+ b.classList.add('border-transparent','text-slate-400');
245
+ });
246
+ const btn = document.getElementById(btnId);
247
+ btn.classList.remove('border-transparent','text-slate-400');
248
+ btn.classList.add('border-slate-900','text-slate-900');
249
+ }
250
+ async function redeemReward(rewardId, rewardName, cost) {
251
+ if (!confirm(`Redeem "${rewardName}" for ${cost.toLocaleString()} points?`)) return;
252
+ try {
253
+ const r = await fetch('/Rewards/Redeem', {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
256
+ body: new URLSearchParams({ rewardId })
257
+ });
258
+ const res = await r.json();
259
+ if (res.success) { showToast(res.message, 'success'); setTimeout(() => location.reload(), 1500); }
260
+ else showToast('Error: ' + res.message, 'error');
261
+ } catch { showToast('Unexpected error.', 'error'); }
262
+ }
263
+ </script>
264
+ }
Frontend/Views/Rewards/Manage.cshtml ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @model Frontend.Models.LibrarianRewardsViewModel
2
+ @{
3
+ ViewData["Title"] = "Rewards Management";
4
+
5
+ string PrettyEvent(string? eventKey, double points)
6
+ {
7
+ var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant();
8
+
9
+ return key switch
10
+ {
11
+ "RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN",
12
+ "SIGNUP" or "REGISTER" => "SIGNUP",
13
+ "SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE",
14
+ "REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM",
15
+ _ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key)
16
+ };
17
+ }
18
+ }
19
+
20
+ <div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-4">
21
+ <div>
22
+ <h1 class="text-3xl font-extrabold tracking-tight text-slate-900">Rewards Management</h1>
23
+ <p class="text-slate-500 mt-1">Fulfill pending redemptions and review all members' loyalty activity.</p>
24
+ </div>
25
+ <div class="flex items-center gap-4">
26
+ <div class="bg-amber-50 border border-amber-200 px-5 py-2.5 rounded-xl text-center shadow-sm">
27
+ <p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Pending</p>
28
+ <p class="text-2xl font-black text-slate-900">@Model.PendingRedemptions.Count()</p>
29
+ </div>
30
+ <div class="bg-slate-50 border border-slate-200 px-5 py-2.5 rounded-xl text-center shadow-sm">
31
+ <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Members w/ History</p>
32
+ <p class="text-2xl font-black text-slate-900">@Model.AllMembersHistory.Count()</p>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Tabs -->
38
+ <div class="mb-6 border-b border-slate-200">
39
+ <nav class="-mb-px flex gap-6">
40
+ <button onclick="switchTab('tab-pending','btn-pending')" id="btn-pending"
41
+ class="tab-btn pb-3 text-sm font-semibold border-b-2 border-slate-900 text-slate-900 transition-all">
42
+ <span class="inline-flex items-center gap-1.5">
43
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
44
+ <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" />
45
+ </svg>
46
+ Pending Redemptions
47
+ </span>
48
+ @if (Model.PendingRedemptions.Any())
49
+ {
50
+ <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">@Model.PendingRedemptions.Count()</span>
51
+ }
52
+ </button>
53
+ <button onclick="switchTab('tab-history','btn-history')" id="btn-history"
54
+ class="tab-btn pb-3 text-sm font-semibold border-b-2 border-transparent text-slate-400 hover:text-slate-700 transition-all">
55
+ <span class="inline-flex items-center gap-1.5">
56
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
+ <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" />
58
+ </svg>
59
+ All Members' Points History
60
+ </span>
61
+ </button>
62
+ </nav>
63
+ </div>
64
+
65
+ <!-- Pending Redemptions Tab -->
66
+ <div id="tab-pending" class="tab-content">
67
+ @if (!Model.PendingRedemptions.Any())
68
+ {
69
+ <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
70
+ <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>
71
+ <p class="text-slate-500 font-medium">No pending redemptions. All caught up!</p>
72
+ </div>
73
+ }
74
+ else
75
+ {
76
+ <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm">
77
+ <table class="min-w-full divide-y divide-slate-100">
78
+ <thead class="bg-slate-50">
79
+ <tr>
80
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Member</th>
81
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Reward</th>
82
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Points</th>
83
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Claimed</th>
84
+ <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Status</th>
85
+ <th class="px-6 py-3 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">Action</th>
86
+ </tr>
87
+ </thead>
88
+ <tbody class="divide-y divide-slate-100">
89
+ @foreach (var r in Model.PendingRedemptions.OrderBy(x => x.RedeemedAt))
90
+ {
91
+ <tr class="hover:bg-amber-50/40 transition-colors" id="row-@r.Id">
92
+ <td class="px-6 py-4">
93
+ <div class="flex items-center gap-3">
94
+ <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">
95
+ @(r.ExternalUserId.Length >= 2 ? r.ExternalUserId.Substring(0, 2).ToUpper() : "U")
96
+ </div>
97
+ <span class="text-sm font-medium text-slate-700 font-mono text-xs">@r.ExternalUserId</span>
98
+ </div>
99
+ </td>
100
+ <td class="px-6 py-4">
101
+ <p class="text-sm font-bold text-slate-900">@r.RewardName</p>
102
+ <p class="text-xs text-slate-400">ID: @r.Id[..Math.Min(8, r.Id.Length)]...</p>
103
+ </td>
104
+ <td class="px-6 py-4 text-sm font-bold text-slate-700">@r.PointCost.ToString("N0") pts</td>
105
+ <td class="px-6 py-4 text-sm text-slate-500 whitespace-nowrap">@r.RedeemedAt.ToString("MMM dd, yyyy")</td>
106
+ <td class="px-6 py-4">
107
+ <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">
108
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
109
+ Pending
110
+ </span>
111
+ </td>
112
+ <td class="px-6 py-4 text-right">
113
+ <button onclick="fulfillRedemption('@r.Id')"
114
+ 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">
115
+ <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>
116
+ Fulfill
117
+ </button>
118
+ </td>
119
+ </tr>
120
+ }
121
+ </tbody>
122
+ </table>
123
+ </div>
124
+ }
125
+ </div>
126
+
127
+ <!-- All Members Points History Tab -->
128
+ <div id="tab-history" class="tab-content hidden">
129
+ @if (!Model.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/filter bar -->
138
+ <div class="mb-4">
139
+ <input id="member-search" 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
+ oninput="filterMembers(this.value)" />
142
+ </div>
143
+
144
+ <div class="space-y-4" id="members-list">
145
+ @foreach (var member in Model.AllMembersHistory.OrderByDescending(m => m.CurrentBalance))
146
+ {
147
+ <div class="member-card bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm"
148
+ data-name="@member.UserName.ToLower()" data-email="@member.UserEmail.ToLower()">
149
+ @{
150
+ var redemptionById = member.Redemptions
151
+ .Where(r => !string.IsNullOrWhiteSpace(r.Id))
152
+ .GroupBy(r => r.Id)
153
+ .ToDictionary(g => g.Key, g => g.First());
154
+ }
155
+ <!-- Member header -->
156
+ <div class="flex items-center justify-between px-6 py-4 bg-slate-50 border-b border-slate-100 cursor-pointer"
157
+ onclick="toggleHistory('@member.AccountId')">
158
+ <div class="flex items-center gap-4">
159
+ <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">
160
+ @(member.UserName.Length >= 2 ? member.UserName.Substring(0, 2).ToUpper() : "?")
161
+ </div>
162
+ <div>
163
+ <p class="font-bold text-slate-900 text-sm">@member.UserName</p>
164
+ <p class="text-xs text-slate-400">@member.UserEmail</p>
165
+ </div>
166
+ </div>
167
+ <div class="flex items-center gap-6">
168
+ <div class="text-right">
169
+ <p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Balance</p>
170
+ <p class="font-black text-slate-900">@member.CurrentBalance.ToString("N0") pts</p>
171
+ </div>
172
+ <div class="text-right">
173
+ <p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Tier</p>
174
+ <span class="text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 text-slate-700">@member.Tier</span>
175
+ </div>
176
+ <div class="text-right">
177
+ <p class="text-xs font-bold text-slate-400 uppercase tracking-widest">Transactions</p>
178
+ <p class="font-black text-slate-900">@member.History.Count()</p>
179
+ </div>
180
+ <svg class="w-4 h-4 text-slate-400 transition-transform" id="arrow-@member.AccountId" 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>
181
+ </div>
182
+ </div>
183
+ <!-- Collapsible history table -->
184
+ <div id="history-@member.AccountId" class="hidden overflow-x-auto">
185
+ @if (!member.History.Any())
186
+ {
187
+ <p class="px-6 py-4 text-sm text-slate-400 italic">No transactions found for this member.</p>
188
+ }
189
+ else
190
+ {
191
+ <table class="min-w-full divide-y divide-slate-50">
192
+ <thead>
193
+ <tr class="bg-slate-50/50">
194
+ <th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Date</th>
195
+ <th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Type</th>
196
+ <th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Details</th>
197
+ <th class="px-6 py-2 text-left text-[10px] font-bold text-slate-400 uppercase tracking-wider">Spent on</th>
198
+ <th class="px-6 py-2 text-right text-[10px] font-bold text-slate-400 uppercase tracking-wider">Points</th>
199
+ </tr>
200
+ </thead>
201
+ <tbody class="divide-y divide-slate-50">
202
+ @foreach (var h in member.History.OrderByDescending(x => x.CreatedAt))
203
+ {
204
+ var isPos = h.PointDelta >= 0;
205
+ var label = PrettyEvent(h.EventKey, h.PointDelta);
206
+ string? spentOn = null;
207
+ if (!isPos && !string.IsNullOrWhiteSpace(h.RewardName))
208
+ {
209
+ spentOn = h.RewardName;
210
+ }
211
+ else if (!isPos && !string.IsNullOrWhiteSpace(h.ReferenceId) && redemptionById.TryGetValue(h.ReferenceId, out var redemption))
212
+ {
213
+ spentOn = redemption.RewardName;
214
+ }
215
+ <tr class="hover:bg-slate-50 transition-colors">
216
+ <td class="px-6 py-2.5 text-xs text-slate-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
217
+ <td class="px-6 py-2.5">
218
+ <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>
219
+ </td>
220
+ <td class="px-6 py-2.5 text-xs text-slate-600 max-w-xs truncate">@h.Description</td>
221
+ <td class="px-6 py-2.5 text-xs text-slate-600 max-w-xs truncate">
222
+ @if (!string.IsNullOrWhiteSpace(spentOn))
223
+ {
224
+ <span class="font-semibold text-slate-700">@spentOn</span>
225
+ }
226
+ else
227
+ {
228
+ <span class="text-slate-400">—</span>
229
+ }
230
+ </td>
231
+ <td class="px-6 py-2.5 text-right font-black text-sm @(isPos ? "text-emerald-600" : "text-rose-600")">
232
+ @(isPos ? "+" : "")@h.PointDelta.ToString("N0")
233
+ </td>
234
+ </tr>
235
+ }
236
+ </tbody>
237
+ </table>
238
+ }
239
+ </div>
240
+ </div>
241
+ }
242
+ </div>
243
+ }
244
+ </div>
245
+
246
+ <!-- Hidden form for fulfillment -->
247
+ <form id="fulfill-form" asp-controller="Rewards" asp-action="Fulfill" method="post" class="hidden">
248
+ <input type="hidden" name="id" id="fulfill-id" />
249
+ </form>
250
+
251
+ @section Scripts {
252
+ <script>
253
+ function switchTab(tabId, btnId) {
254
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
255
+ document.getElementById(tabId).classList.remove('hidden');
256
+ document.querySelectorAll('.tab-btn').forEach(b => {
257
+ b.classList.remove('border-slate-900','text-slate-900');
258
+ b.classList.add('border-transparent','text-slate-400');
259
+ });
260
+ const btn = document.getElementById(btnId);
261
+ btn.classList.remove('border-transparent','text-slate-400');
262
+ btn.classList.add('border-slate-900','text-slate-900');
263
+ }
264
+
265
+ function toggleHistory(accountId) {
266
+ const panel = document.getElementById('history-' + accountId);
267
+ const arrow = document.getElementById('arrow-' + accountId);
268
+ panel.classList.toggle('hidden');
269
+ arrow.classList.toggle('rotate-180');
270
+ }
271
+
272
+ function filterMembers(query) {
273
+ const q = query.toLowerCase();
274
+ document.querySelectorAll('.member-card').forEach(card => {
275
+ const name = card.dataset.name || '';
276
+ const email = card.dataset.email || '';
277
+ card.style.display = (name.includes(q) || email.includes(q)) ? '' : 'none';
278
+ });
279
+ }
280
+
281
+ function fulfillRedemption(id) {
282
+ if (!confirm('Fulfill this redemption and grant membership to the member?')) return;
283
+ document.getElementById('fulfill-id').value = id;
284
+ document.getElementById('fulfill-form').submit();
285
+ }
286
+ </script>
287
+ }
Frontend/Views/Shared/_Layout.cshtml CHANGED
@@ -88,6 +88,11 @@
88
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
89
  My Membership
90
  </a>
 
 
 
 
 
91
  }
92
  @if (User.Identity?.IsAuthenticated == true && User.IsInRole("Librarian"))
93
  {
@@ -101,7 +106,7 @@
101
  Manage Borrowings
102
  </a>
103
 
104
- <a asp-controller="Subscriptions" asp-action="Rewards" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Subscriptions" && ViewContext.RouteData.Values["Action"]?.ToString() == "Rewards" ? "nav-link-active" : "nav-link-inactive")">
105
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
106
  Rewards
107
  </a>
@@ -224,3 +229,4 @@
224
  @await RenderSectionAsync("Scripts", required: false)
225
  </body>
226
  </html>
 
 
88
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
89
  My Membership
90
  </a>
91
+
92
+ <a asp-controller="Rewards" asp-action="Index" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Rewards" ? "nav-link-active" : "nav-link-inactive")">
93
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
94
+ Rewards
95
+ </a>
96
  }
97
  @if (User.Identity?.IsAuthenticated == true && User.IsInRole("Librarian"))
98
  {
 
106
  Manage Borrowings
107
  </a>
108
 
109
+ <a asp-controller="Rewards" asp-action="Manage" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Rewards" ? "nav-link-active" : "nav-link-inactive")">
110
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
111
  Rewards
112
  </a>
 
229
  @await RenderSectionAsync("Scripts", required: false)
230
  </body>
231
  </html>
232
+ <!-- test -->