Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
ad67295
1
Parent(s): da13799
add: points history
Browse files- Backend/Features/Loyalty/LoyaltyAccountsController.cs +39 -0
- Backend/Features/Loyalty/LoyaltyController.cs +82 -35
- Backend/Features/Loyalty/LoyaltyService.cs +91 -7
- Frontend/Controllers/RewardsController.cs +92 -0
- Frontend/Models/Dtos/LibraryDtos.cs +57 -0
- Frontend/Models/RewardsViewModel.cs +11 -0
- Frontend/Services/LibraryApiClient.cs +77 -19
- Frontend/Views/Rewards/Index.cshtml +264 -0
- Frontend/Views/Rewards/Manage.cshtml +287 -0
- Frontend/Views/Shared/_Layout.cshtml +7 -1
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
|
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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 |
-
}
|
|
|
|
| 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="
|
| 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 -->
|