Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
25f925e
1
Parent(s): 7fe61c9
add: reward system
Browse files- Backend/Features/Loyalty/LoyaltyController.cs +6 -7
- Backend/Features/Loyalty/LoyaltyModels.cs +16 -0
- Backend/Features/Loyalty/LoyaltyService.cs +28 -3
- Backend/Features/Subscriptions/SubscriptionModels.cs +1 -1
- Backend/Features/Subscriptions/SubscriptionService.cs +21 -18
- Database/Models/Membership.cs +1 -1
- Database/remove_total_membership.sql +0 -72
- Frontend/Controllers/BorrowingsController.cs +12 -0
- Frontend/Controllers/MembershipController.cs +19 -4
- Frontend/Controllers/SubscriptionsController.cs +20 -1
- Frontend/Models/Dtos/LibraryDtos.cs +7 -1
- Frontend/Models/Dtos/LoyaltyRedemptionDto.cs +17 -3
- Frontend/Models/MembershipViewModel.cs +1 -0
- Frontend/Services/LibraryApiClient.cs +73 -0
- Frontend/Views/Borrowings/Rewards.cshtml +32 -1
- Frontend/Views/Membership/Index.cshtml +53 -3
- Frontend/Views/Shared/_Layout.cshtml +10 -7
Backend/Features/Loyalty/LoyaltyController.cs
CHANGED
|
@@ -36,11 +36,7 @@ namespace Backend.Features.Loyalty
|
|
| 36 |
return NotFound("Loyalty account not found.");
|
| 37 |
}
|
| 38 |
|
| 39 |
-
return Ok(
|
| 40 |
-
{
|
| 41 |
-
currentBalance = account.CurrentBalance,
|
| 42 |
-
tier = account.Tier
|
| 43 |
-
});
|
| 44 |
}
|
| 45 |
|
| 46 |
[HttpGet("my-redemptions")]
|
|
@@ -52,7 +48,7 @@ namespace Backend.Features.Loyalty
|
|
| 52 |
return Unauthorized();
|
| 53 |
}
|
| 54 |
|
| 55 |
-
var redemptions = await _loyaltyService.
|
| 56 |
return Ok(redemptions);
|
| 57 |
}
|
| 58 |
|
|
@@ -107,12 +103,15 @@ namespace Backend.Features.Loyalty
|
|
| 107 |
bool membershipGranted = false;
|
| 108 |
if (int.TryParse(redemption.ExternalUserId?.Trim(), out int userId))
|
| 109 |
{
|
|
|
|
| 110 |
membershipGranted = await _subscriptionService.HandleLoyaltyRedemptionAsync(userId, redemption.RewardId, redemption.Id);
|
| 111 |
}
|
| 112 |
|
| 113 |
if (!membershipGranted)
|
| 114 |
{
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
return Ok(new { message = $"Redemption fulfilled and {redemption.RewardName} membership granted successfully." });
|
|
|
|
| 36 |
return NotFound("Loyalty account not found.");
|
| 37 |
}
|
| 38 |
|
| 39 |
+
return Ok(account);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
[HttpGet("my-redemptions")]
|
|
|
|
| 48 |
return Unauthorized();
|
| 49 |
}
|
| 50 |
|
| 51 |
+
var redemptions = await _loyaltyService.GetRedemptionsHistoryAsync();
|
| 52 |
return Ok(redemptions);
|
| 53 |
}
|
| 54 |
|
|
|
|
| 103 |
bool membershipGranted = false;
|
| 104 |
if (int.TryParse(redemption.ExternalUserId?.Trim(), out int userId))
|
| 105 |
{
|
| 106 |
+
System.Diagnostics.Debug.WriteLine($"Fulfilling redemption {id} for user {userId}. RewardId: '{redemption.RewardId}'");
|
| 107 |
membershipGranted = await _subscriptionService.HandleLoyaltyRedemptionAsync(userId, redemption.RewardId, redemption.Id);
|
| 108 |
}
|
| 109 |
|
| 110 |
if (!membershipGranted)
|
| 111 |
{
|
| 112 |
+
var errorMsg = $"Redemption fulfilled in loyalty system, but failed to grant library membership. The Reward ID '{redemption.RewardId}' (Redemption ID: {id}) may not be correctly mapped in the library system, or the user already has researchers this membership.";
|
| 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." });
|
Backend/Features/Loyalty/LoyaltyModels.cs
CHANGED
|
@@ -11,20 +11,36 @@ namespace Backend.Features.Loyalty
|
|
| 11 |
[JsonPropertyName("rewardId")]
|
| 12 |
public string RewardId { get; set; } = string.Empty;
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
[JsonPropertyName("rewardName")]
|
| 15 |
public string RewardName { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
[JsonPropertyName("pointsSpent")]
|
| 18 |
public double PointsSpent { get; set; }
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
[JsonPropertyName("status")]
|
| 21 |
public string Status { get; set; } = string.Empty;
|
| 22 |
|
| 23 |
[JsonPropertyName("externalUserId")]
|
| 24 |
public string ExternalUserId { get; set; } = string.Empty;
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
[JsonPropertyName("createdAt")]
|
| 27 |
public DateTime CreatedAt { get; set; }
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
public class ClaimRewardRequestDto
|
|
|
|
| 11 |
[JsonPropertyName("rewardId")]
|
| 12 |
public string RewardId { get; set; } = string.Empty;
|
| 13 |
|
| 14 |
+
// Fallback for snake_case from external API
|
| 15 |
+
[JsonPropertyName("reward_id")]
|
| 16 |
+
public string? RewardIdSnakeCase { get => RewardId; set => RewardId = string.IsNullOrEmpty(value) ? RewardId : value; }
|
| 17 |
+
|
| 18 |
[JsonPropertyName("rewardName")]
|
| 19 |
public string RewardName { get; set; } = string.Empty;
|
| 20 |
+
|
| 21 |
+
[JsonPropertyName("reward_name")]
|
| 22 |
+
public string? RewardNameSnakeCase { get => RewardName; set => RewardName = string.IsNullOrEmpty(value) ? RewardName : value; }
|
| 23 |
|
| 24 |
[JsonPropertyName("pointsSpent")]
|
| 25 |
public double PointsSpent { get; set; }
|
| 26 |
|
| 27 |
+
[JsonPropertyName("points_spent")]
|
| 28 |
+
public double? PointsSpentSnakeCase { set => PointsSpent = value ?? PointsSpent; }
|
| 29 |
+
|
| 30 |
[JsonPropertyName("status")]
|
| 31 |
public string Status { get; set; } = string.Empty;
|
| 32 |
|
| 33 |
[JsonPropertyName("externalUserId")]
|
| 34 |
public string ExternalUserId { get; set; } = string.Empty;
|
| 35 |
|
| 36 |
+
[JsonPropertyName("external_user_id")]
|
| 37 |
+
public string? ExternalUserIdSnakeCase { get => ExternalUserId; set => ExternalUserId = string.IsNullOrEmpty(value) ? ExternalUserId : value; }
|
| 38 |
+
|
| 39 |
[JsonPropertyName("createdAt")]
|
| 40 |
public DateTime CreatedAt { get; set; }
|
| 41 |
+
|
| 42 |
+
[JsonPropertyName("created_at")]
|
| 43 |
+
public DateTime? CreatedAtSnakeCase { set => CreatedAt = value ?? CreatedAt; }
|
| 44 |
}
|
| 45 |
|
| 46 |
public class ClaimRewardRequestDto
|
Backend/Features/Loyalty/LoyaltyService.cs
CHANGED
|
@@ -16,8 +16,8 @@ namespace Backend.Features.Loyalty
|
|
| 16 |
Task<bool> ProcessEventAsync(string externalUserId, string eventKey, double eventValue, string referenceId, string description, string email, string mobile);
|
| 17 |
Task<AccountLookupResponse?> GetUserAccountAsync(string externalUserId);
|
| 18 |
Task<(bool Success, string Message)> ClaimRewardAsync(string externalUserId, string rewardId, string notes);
|
| 19 |
-
Task<IEnumerable<LoyaltyRedemptionDto>> GetUserRedemptionsAsync(string externalUserId);
|
| 20 |
Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync();
|
|
|
|
| 21 |
Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
|
| 22 |
}
|
| 23 |
|
|
@@ -31,7 +31,10 @@ namespace Backend.Features.Loyalty
|
|
| 31 |
{
|
| 32 |
_httpClient = httpClient;
|
| 33 |
_logger = logger;
|
| 34 |
-
_httpClient.DefaultRequestHeaders.
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
|
|
@@ -175,8 +178,11 @@ namespace Backend.Features.Loyalty
|
|
| 175 |
var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/pending");
|
| 176 |
if (response.IsSuccessStatusCode)
|
| 177 |
{
|
|
|
|
|
|
|
|
|
|
| 178 |
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
| 179 |
-
return
|
| 180 |
}
|
| 181 |
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 182 |
}
|
|
@@ -187,6 +193,25 @@ namespace Backend.Features.Loyalty
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
public async Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status)
|
| 191 |
{
|
| 192 |
try
|
|
|
|
| 16 |
Task<bool> ProcessEventAsync(string externalUserId, string eventKey, double eventValue, string referenceId, string description, string email, string mobile);
|
| 17 |
Task<AccountLookupResponse?> GetUserAccountAsync(string externalUserId);
|
| 18 |
Task<(bool Success, string Message)> ClaimRewardAsync(string externalUserId, string rewardId, string notes);
|
|
|
|
| 19 |
Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync();
|
| 20 |
+
Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync();
|
| 21 |
Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status);
|
| 22 |
}
|
| 23 |
|
|
|
|
| 31 |
{
|
| 32 |
_httpClient = httpClient;
|
| 33 |
_logger = logger;
|
| 34 |
+
if (!_httpClient.DefaultRequestHeaders.Contains("x-system-id"))
|
| 35 |
+
{
|
| 36 |
+
_httpClient.DefaultRequestHeaders.Add("x-system-id", SystemId);
|
| 37 |
+
}
|
| 38 |
}
|
| 39 |
|
| 40 |
public async Task<bool> RegisterUserAsync(string externalUserId, string email, string mobile)
|
|
|
|
| 178 |
var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/pending");
|
| 179 |
if (response.IsSuccessStatusCode)
|
| 180 |
{
|
| 181 |
+
var rawJson = await response.Content.ReadAsStringAsync();
|
| 182 |
+
_logger.LogInformation($"Raw Pending Redemptions JSON: {rawJson}");
|
| 183 |
+
|
| 184 |
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
| 185 |
+
return JsonSerializer.Deserialize<IEnumerable<LoyaltyRedemptionDto>>(rawJson, options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 186 |
}
|
| 187 |
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 188 |
}
|
|
|
|
| 193 |
}
|
| 194 |
}
|
| 195 |
|
| 196 |
+
public async Task<IEnumerable<LoyaltyRedemptionDto>> GetRedemptionsHistoryAsync()
|
| 197 |
+
{
|
| 198 |
+
try
|
| 199 |
+
{
|
| 200 |
+
var response = await _httpClient.GetAsync("/api/v1/admin/redemptions/history");
|
| 201 |
+
if (response.IsSuccessStatusCode)
|
| 202 |
+
{
|
| 203 |
+
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
| 204 |
+
return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 205 |
+
}
|
| 206 |
+
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 207 |
+
}
|
| 208 |
+
catch (Exception ex)
|
| 209 |
+
{
|
| 210 |
+
_logger.LogError(ex, "Error fetching redemptions history.");
|
| 211 |
+
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
public async Task<bool> UpdateRedemptionStatusAsync(string redemptionId, string status)
|
| 216 |
{
|
| 217 |
try
|
Backend/Features/Subscriptions/SubscriptionModels.cs
CHANGED
|
@@ -11,7 +11,7 @@ namespace Backend.Features.Subscriptions
|
|
| 11 |
public decimal Price { get; set; }
|
| 12 |
public string Currency { get; set; } = "MMK";
|
| 13 |
public int DurationMonths { get; set; }
|
| 14 |
-
public string?
|
| 15 |
}
|
| 16 |
|
| 17 |
public class SubscriptionDto
|
|
|
|
| 11 |
public decimal Price { get; set; }
|
| 12 |
public string Currency { get; set; } = "MMK";
|
| 13 |
public int DurationMonths { get; set; }
|
| 14 |
+
public string? RewardId { get; set; }
|
| 15 |
}
|
| 16 |
|
| 17 |
public class SubscriptionDto
|
Backend/Features/Subscriptions/SubscriptionService.cs
CHANGED
|
@@ -35,7 +35,7 @@ namespace Backend.Features.Subscriptions
|
|
| 35 |
BorrowingDays = m.BorrowingDays,
|
| 36 |
Price = m.Price,
|
| 37 |
DurationMonths = m.DurationMonths,
|
| 38 |
-
|
| 39 |
}).ToListAsync();
|
| 40 |
}
|
| 41 |
|
|
@@ -54,27 +54,25 @@ namespace Backend.Features.Subscriptions
|
|
| 54 |
|
| 55 |
public async Task<bool> HandleLoyaltyRedemptionAsync(int userId, string rewardId, string id)
|
| 56 |
{
|
| 57 |
-
// externalUserId matches how other loyalty calls identify users
|
| 58 |
-
string externalUserId = userId.ToString();
|
| 59 |
-
|
| 60 |
try
|
| 61 |
{
|
| 62 |
-
//
|
| 63 |
-
var
|
|
|
|
| 64 |
|
| 65 |
-
if (
|
| 66 |
{
|
|
|
|
| 67 |
return false;
|
| 68 |
}
|
| 69 |
|
| 70 |
-
//
|
| 71 |
-
await
|
| 72 |
|
| 73 |
return true;
|
| 74 |
}
|
| 75 |
catch
|
| 76 |
{
|
| 77 |
-
// Swallow exceptions to maintain interface contract; caller can handle false result
|
| 78 |
return false;
|
| 79 |
}
|
| 80 |
}
|
|
@@ -84,29 +82,34 @@ namespace Backend.Features.Subscriptions
|
|
| 84 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 85 |
if (membership == null) throw new Exception("Membership plan not found.");
|
| 86 |
|
| 87 |
-
//
|
| 88 |
-
|
|
|
|
| 89 |
.Where(s => s.UserId == userId && s.IsActive)
|
| 90 |
-
.
|
|
|
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 93 |
{
|
| 94 |
-
|
| 95 |
}
|
| 96 |
|
| 97 |
var subscription = new UserSubscription
|
| 98 |
{
|
| 99 |
UserId = userId,
|
| 100 |
MembershipId = membershipId,
|
| 101 |
-
StartDate =
|
| 102 |
-
ExpiryDate =
|
| 103 |
IsActive = true
|
| 104 |
};
|
| 105 |
|
| 106 |
_context.UserSubscriptions.Add(subscription);
|
| 107 |
await _context.SaveChangesAsync();
|
| 108 |
|
| 109 |
-
// Explicitly load
|
| 110 |
await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
|
| 111 |
await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
|
| 112 |
|
|
|
|
| 35 |
BorrowingDays = m.BorrowingDays,
|
| 36 |
Price = m.Price,
|
| 37 |
DurationMonths = m.DurationMonths,
|
| 38 |
+
RewardId = m.RewardId
|
| 39 |
}).ToListAsync();
|
| 40 |
}
|
| 41 |
|
|
|
|
| 54 |
|
| 55 |
public async Task<bool> HandleLoyaltyRedemptionAsync(int userId, string rewardId, string id)
|
| 56 |
{
|
|
|
|
|
|
|
|
|
|
| 57 |
try
|
| 58 |
{
|
| 59 |
+
// 1. Find the local membership that corresponds to this loyalty reward
|
| 60 |
+
var membership = await _context.Memberships
|
| 61 |
+
.FirstOrDefaultAsync(m => m.RewardId == rewardId);
|
| 62 |
|
| 63 |
+
if (membership == null)
|
| 64 |
{
|
| 65 |
+
// If no membership matches this rewardId, we can't fulfill it automatically
|
| 66 |
return false;
|
| 67 |
}
|
| 68 |
|
| 69 |
+
// 2. Grant the membership to the user (will be queued if they already have one)
|
| 70 |
+
await SubscribeUserAsync(userId, membership.Id);
|
| 71 |
|
| 72 |
return true;
|
| 73 |
}
|
| 74 |
catch
|
| 75 |
{
|
|
|
|
| 76 |
return false;
|
| 77 |
}
|
| 78 |
}
|
|
|
|
| 82 |
var membership = await _context.Memberships.FindAsync(membershipId);
|
| 83 |
if (membership == null) throw new Exception("Membership plan not found.");
|
| 84 |
|
| 85 |
+
// Find the latest expiry date for this user to enable queuing
|
| 86 |
+
// We look at all active or future subscriptions
|
| 87 |
+
var latestSubscription = await _context.UserSubscriptions
|
| 88 |
.Where(s => s.UserId == userId && s.IsActive)
|
| 89 |
+
.OrderByDescending(s => s.ExpiryDate)
|
| 90 |
+
.FirstOrDefaultAsync();
|
| 91 |
|
| 92 |
+
DateTime startDate = DateTime.UtcNow;
|
| 93 |
+
|
| 94 |
+
// If there's an existing subscription that expires in the future, start after it
|
| 95 |
+
if (latestSubscription != null && latestSubscription.ExpiryDate > startDate)
|
| 96 |
{
|
| 97 |
+
startDate = latestSubscription.ExpiryDate;
|
| 98 |
}
|
| 99 |
|
| 100 |
var subscription = new UserSubscription
|
| 101 |
{
|
| 102 |
UserId = userId,
|
| 103 |
MembershipId = membershipId,
|
| 104 |
+
StartDate = startDate,
|
| 105 |
+
ExpiryDate = startDate.AddMonths(membership.DurationMonths),
|
| 106 |
IsActive = true
|
| 107 |
};
|
| 108 |
|
| 109 |
_context.UserSubscriptions.Add(subscription);
|
| 110 |
await _context.SaveChangesAsync();
|
| 111 |
|
| 112 |
+
// Explicitly load context for DTO
|
| 113 |
await _context.Entry(subscription).Reference(s => s.Membership).LoadAsync();
|
| 114 |
await _context.Entry(subscription).Reference(s => s.User).LoadAsync();
|
| 115 |
|
Database/Models/Membership.cs
CHANGED
|
@@ -16,7 +16,7 @@ public partial class Membership
|
|
| 16 |
public decimal Price { get; set; }
|
| 17 |
|
| 18 |
public int DurationMonths { get; set; }
|
| 19 |
-
public string?
|
| 20 |
|
| 21 |
public virtual ICollection<UserSubscription> UserSubscriptions { get; set; } = new List<UserSubscription>();
|
| 22 |
}
|
|
|
|
| 16 |
public decimal Price { get; set; }
|
| 17 |
|
| 18 |
public int DurationMonths { get; set; }
|
| 19 |
+
public string? RewardId { get; set; }
|
| 20 |
|
| 21 |
public virtual ICollection<UserSubscription> UserSubscriptions { get; set; } = new List<UserSubscription>();
|
| 22 |
}
|
Database/remove_total_membership.sql
DELETED
|
@@ -1,72 +0,0 @@
|
|
| 1 |
-
-- EXTREMELY AGGRESSIVE script to remove TotalMembership and ALL its dependencies
|
| 2 |
-
USE LibraryManagement;
|
| 3 |
-
GO
|
| 4 |
-
|
| 5 |
-
PRINT 'Starting aggressive cleanup of TotalMembership column...';
|
| 6 |
-
|
| 7 |
-
-- 1. Specifically drop the constraint the user mentioned if it exists
|
| 8 |
-
IF EXISTS (SELECT * FROM sys.objects WHERE name = 'DF__UserSubsc__Total__5DCAEF64' AND type = 'D')
|
| 9 |
-
BEGIN
|
| 10 |
-
PRINT 'Explicitly dropping known constraint DF__UserSubsc__Total__5DCAEF64';
|
| 11 |
-
ALTER TABLE UserSubscriptions DROP CONSTRAINT DF__UserSubsc__Total__5DCAEF64;
|
| 12 |
-
END
|
| 13 |
-
|
| 14 |
-
-- 2. Dynamically find and drop ALL other default constraints on this column
|
| 15 |
-
DECLARE @SQL nvarchar(MAX) = '';
|
| 16 |
-
SELECT @SQL += 'ALTER TABLE UserSubscriptions DROP CONSTRAINT ' + d.name + ';' + CHAR(13)
|
| 17 |
-
FROM sys.default_constraints d
|
| 18 |
-
INNER JOIN sys.columns c ON d.parent_column_id = c.column_id AND d.parent_object_id = c.object_id
|
| 19 |
-
WHERE c.object_id = OBJECT_ID('UserSubscriptions')
|
| 20 |
-
AND c.name = 'TotalMembership';
|
| 21 |
-
|
| 22 |
-
IF @SQL <> ''
|
| 23 |
-
BEGIN
|
| 24 |
-
PRINT 'Dropping additional dynamic constraints...';
|
| 25 |
-
EXEC sp_executesql @SQL;
|
| 26 |
-
END
|
| 27 |
-
|
| 28 |
-
-- 3. Find and drop any Indexes that use this column
|
| 29 |
-
SET @SQL = '';
|
| 30 |
-
SELECT @SQL += 'DROP INDEX ' + i.name + ' ON UserSubscriptions;' + CHAR(13)
|
| 31 |
-
FROM sys.indexes i
|
| 32 |
-
INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
|
| 33 |
-
INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
|
| 34 |
-
WHERE i.object_id = OBJECT_ID('UserSubscriptions')
|
| 35 |
-
AND c.name = 'TotalMembership';
|
| 36 |
-
|
| 37 |
-
IF @SQL <> ''
|
| 38 |
-
BEGIN
|
| 39 |
-
PRINT 'Dropping dependent indexes...';
|
| 40 |
-
EXEC sp_executesql @SQL;
|
| 41 |
-
END
|
| 42 |
-
|
| 43 |
-
-- 4. Check for and drop foreign keys if they somehow depend on this (rare for this name but possible)
|
| 44 |
-
SET @SQL = '';
|
| 45 |
-
SELECT @SQL += 'ALTER TABLE UserSubscriptions DROP CONSTRAINT ' + fk.name + ';' + CHAR(13)
|
| 46 |
-
FROM sys.foreign_keys fk
|
| 47 |
-
INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
|
| 48 |
-
INNER JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id
|
| 49 |
-
WHERE fk.parent_object_id = OBJECT_ID('UserSubscriptions')
|
| 50 |
-
AND c.name = 'TotalMembership';
|
| 51 |
-
|
| 52 |
-
IF @SQL <> ''
|
| 53 |
-
BEGIN
|
| 54 |
-
PRINT 'Dropping dependent foreign keys...';
|
| 55 |
-
EXEC sp_executesql @SQL;
|
| 56 |
-
END
|
| 57 |
-
|
| 58 |
-
-- 5. Finally, drop the column
|
| 59 |
-
IF EXISTS (
|
| 60 |
-
SELECT * FROM sys.columns
|
| 61 |
-
WHERE object_id = OBJECT_ID('UserSubscriptions')
|
| 62 |
-
AND name = 'TotalMembership'
|
| 63 |
-
)
|
| 64 |
-
BEGIN
|
| 65 |
-
ALTER TABLE UserSubscriptions DROP COLUMN TotalMembership;
|
| 66 |
-
PRINT 'SUCCESS: Dropped column TotalMembership from UserSubscriptions.';
|
| 67 |
-
END
|
| 68 |
-
ELSE
|
| 69 |
-
BEGIN
|
| 70 |
-
PRINT 'Done: Column TotalMembership does not exist in UserSubscriptions.';
|
| 71 |
-
END
|
| 72 |
-
GO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Frontend/Controllers/BorrowingsController.cs
CHANGED
|
@@ -93,5 +93,17 @@ namespace Frontend.Controllers
|
|
| 93 |
|
| 94 |
return RedirectToAction(nameof(Index));
|
| 95 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
}
|
| 97 |
}
|
|
|
|
| 93 |
|
| 94 |
return RedirectToAction(nameof(Index));
|
| 95 |
}
|
| 96 |
+
[HttpPost]
|
| 97 |
+
public async Task<IActionResult> Redeem(string rewardId)
|
| 98 |
+
{
|
| 99 |
+
var (success, message) = await _apiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Rewards Page");
|
| 100 |
+
|
| 101 |
+
if (success)
|
| 102 |
+
{
|
| 103 |
+
return Json(new { success = true, message });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
return Json(new { success = false, message });
|
| 107 |
+
}
|
| 108 |
}
|
| 109 |
}
|
Frontend/Controllers/MembershipController.cs
CHANGED
|
@@ -25,16 +25,17 @@ namespace Frontend.Controllers
|
|
| 25 |
var memberships = await _apiClient.GetMembershipsAsync();
|
| 26 |
var redemptions = await _apiClient.GetMyRedemptionsAsync();
|
| 27 |
var loyaltyAccount = await _apiClient.GetMyLoyaltyAccountAsync();
|
|
|
|
| 28 |
|
| 29 |
// Filter redemptions to only show memberships that are Pending (Queued)
|
| 30 |
// We use LoyaltyRewardId to identify which redemptions are memberships
|
| 31 |
var membershipRewardIds = memberships
|
| 32 |
-
.Where(m => !string.IsNullOrEmpty(m.
|
| 33 |
-
.Select(m => m.
|
| 34 |
.ToList();
|
| 35 |
|
| 36 |
var queuedMemberships = redemptions
|
| 37 |
-
.Where(r => (r.Status == "Pending" || r.Status == "PENDING") && membershipRewardIds.Contains(r.
|
| 38 |
.ToList();
|
| 39 |
|
| 40 |
var viewModel = new MembershipViewModel
|
|
@@ -42,10 +43,24 @@ namespace Frontend.Controllers
|
|
| 42 |
CurrentSubscription = subscription,
|
| 43 |
AvailableMemberships = memberships.ToList(),
|
| 44 |
QueuedMemberships = queuedMemberships,
|
| 45 |
-
LoyaltyAccount = loyaltyAccount
|
|
|
|
| 46 |
};
|
| 47 |
|
| 48 |
return View(viewModel);
|
| 49 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
}
|
|
|
|
| 25 |
var memberships = await _apiClient.GetMembershipsAsync();
|
| 26 |
var redemptions = await _apiClient.GetMyRedemptionsAsync();
|
| 27 |
var loyaltyAccount = await _apiClient.GetMyLoyaltyAccountAsync();
|
| 28 |
+
var activeRewards = await _apiClient.GetActiveRewardsAsync();
|
| 29 |
|
| 30 |
// Filter redemptions to only show memberships that are Pending (Queued)
|
| 31 |
// We use LoyaltyRewardId to identify which redemptions are memberships
|
| 32 |
var membershipRewardIds = memberships
|
| 33 |
+
.Where(m => !string.IsNullOrEmpty(m.RewardId))
|
| 34 |
+
.Select(m => m.RewardId)
|
| 35 |
.ToList();
|
| 36 |
|
| 37 |
var queuedMemberships = redemptions
|
| 38 |
+
.Where(r => (r.Status == "Pending" || r.Status == "PENDING" || r.Status == "Fulfilled" || r.Status == "FULFILLED") && membershipRewardIds.Contains(r.RewardId))
|
| 39 |
.ToList();
|
| 40 |
|
| 41 |
var viewModel = new MembershipViewModel
|
|
|
|
| 43 |
CurrentSubscription = subscription,
|
| 44 |
AvailableMemberships = memberships.ToList(),
|
| 45 |
QueuedMemberships = queuedMemberships,
|
| 46 |
+
LoyaltyAccount = loyaltyAccount,
|
| 47 |
+
RewardPointCosts = activeRewards.ToDictionary(r => r.Id, r => r.PointCost)
|
| 48 |
};
|
| 49 |
|
| 50 |
return View(viewModel);
|
| 51 |
}
|
| 52 |
+
|
| 53 |
+
[HttpPost]
|
| 54 |
+
public async Task<IActionResult> Redeem(string rewardId)
|
| 55 |
+
{
|
| 56 |
+
var (success, message) = await _apiClient.ClaimRewardAsync(rewardId, "Redeemed via Library Membership Page");
|
| 57 |
+
|
| 58 |
+
if (success)
|
| 59 |
+
{
|
| 60 |
+
return Json(new { success = true, message });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return Json(new { success = false, message });
|
| 64 |
+
}
|
| 65 |
}
|
| 66 |
}
|
Frontend/Controllers/SubscriptionsController.cs
CHANGED
|
@@ -8,7 +8,7 @@ using System.Linq;
|
|
| 8 |
|
| 9 |
namespace Frontend.Controllers
|
| 10 |
{
|
| 11 |
-
[Authorize(Roles = "Admin")]
|
| 12 |
public class SubscriptionsController : Controller
|
| 13 |
{
|
| 14 |
private readonly LibraryApiClient _apiClient;
|
|
@@ -31,6 +31,25 @@ namespace Frontend.Controllers
|
|
| 31 |
|
| 32 |
return View(viewModel);
|
| 33 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
}
|
|
|
|
| 8 |
|
| 9 |
namespace Frontend.Controllers
|
| 10 |
{
|
| 11 |
+
[Authorize(Roles = "Admin,Librarian")]
|
| 12 |
public class SubscriptionsController : Controller
|
| 13 |
{
|
| 14 |
private readonly LibraryApiClient _apiClient;
|
|
|
|
| 31 |
|
| 32 |
return View(viewModel);
|
| 33 |
}
|
| 34 |
+
public async Task<IActionResult> Rewards()
|
| 35 |
+
{
|
| 36 |
+
var pendingClaims = await _apiClient.GetPendingRedemptionsAsync();
|
| 37 |
+
return View("LoyaltyClaims", pendingClaims.ToList());
|
| 38 |
+
}
|
| 39 |
|
| 40 |
+
[HttpPost]
|
| 41 |
+
public async Task<IActionResult> FulfillRedemption(string id)
|
| 42 |
+
{
|
| 43 |
+
var (success, message) = await _apiClient.FulfillRedemptionAsync(id);
|
| 44 |
+
if (success)
|
| 45 |
+
{
|
| 46 |
+
TempData["Success"] = message;
|
| 47 |
+
}
|
| 48 |
+
else
|
| 49 |
+
{
|
| 50 |
+
TempData["Error"] = message;
|
| 51 |
+
}
|
| 52 |
+
return RedirectToAction("Rewards");
|
| 53 |
+
}
|
| 54 |
}
|
| 55 |
}
|
Frontend/Models/Dtos/LibraryDtos.cs
CHANGED
|
@@ -102,7 +102,7 @@ namespace Frontend.Models.Dtos
|
|
| 102 |
public int BorrowingDays { get; set; }
|
| 103 |
public decimal Price { get; set; }
|
| 104 |
public int DurationMonths { get; set; }
|
| 105 |
-
public string?
|
| 106 |
}
|
| 107 |
|
| 108 |
public class SubscriptionDto
|
|
@@ -170,6 +170,12 @@ namespace Frontend.Models.Dtos
|
|
| 170 |
public bool IsActive { get; set; }
|
| 171 |
}
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
// User DTOs
|
| 174 |
public class UserDto
|
| 175 |
{
|
|
|
|
| 102 |
public int BorrowingDays { get; set; }
|
| 103 |
public decimal Price { get; set; }
|
| 104 |
public int DurationMonths { get; set; }
|
| 105 |
+
public string? RewardId { get; set; }
|
| 106 |
}
|
| 107 |
|
| 108 |
public class SubscriptionDto
|
|
|
|
| 170 |
public bool IsActive { get; set; }
|
| 171 |
}
|
| 172 |
|
| 173 |
+
public class ClaimRewardRequestDto
|
| 174 |
+
{
|
| 175 |
+
public string RewardId { get; set; } = string.Empty;
|
| 176 |
+
public string? Notes { get; set; }
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
// User DTOs
|
| 180 |
public class UserDto
|
| 181 |
{
|
Frontend/Models/Dtos/LoyaltyRedemptionDto.cs
CHANGED
|
@@ -1,15 +1,29 @@
|
|
| 1 |
using System;
|
|
|
|
| 2 |
|
| 3 |
namespace Frontend.Models.Dtos
|
| 4 |
{
|
| 5 |
public class LoyaltyRedemptionDto
|
| 6 |
{
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
public string ExternalUserId { get; set; } = string.Empty;
|
|
|
|
|
|
|
| 9 |
public string RewardName { get; set; } = string.Empty;
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
public string Status { get; set; } = string.Empty;
|
|
|
|
|
|
|
| 13 |
public DateTime CreatedAt { get; set; }
|
| 14 |
}
|
| 15 |
}
|
|
|
|
| 1 |
using System;
|
| 2 |
+
using System.Text.Json.Serialization;
|
| 3 |
|
| 4 |
namespace Frontend.Models.Dtos
|
| 5 |
{
|
| 6 |
public class LoyaltyRedemptionDto
|
| 7 |
{
|
| 8 |
+
[JsonPropertyName("id")]
|
| 9 |
+
public string Id { get; set; } = string.Empty;
|
| 10 |
+
|
| 11 |
+
[JsonPropertyName("externalUserId")]
|
| 12 |
public string ExternalUserId { get; set; } = string.Empty;
|
| 13 |
+
|
| 14 |
+
[JsonPropertyName("rewardName")]
|
| 15 |
public string RewardName { get; set; } = string.Empty;
|
| 16 |
+
|
| 17 |
+
[JsonPropertyName("rewardId")]
|
| 18 |
+
public string? RewardId { get; set; }
|
| 19 |
+
|
| 20 |
+
[JsonPropertyName("pointsSpent")]
|
| 21 |
+
public double PointsSpent { get; set; }
|
| 22 |
+
|
| 23 |
+
[JsonPropertyName("status")]
|
| 24 |
public string Status { get; set; } = string.Empty;
|
| 25 |
+
|
| 26 |
+
[JsonPropertyName("createdAt")]
|
| 27 |
public DateTime CreatedAt { get; set; }
|
| 28 |
}
|
| 29 |
}
|
Frontend/Models/MembershipViewModel.cs
CHANGED
|
@@ -9,5 +9,6 @@ namespace Frontend.Models
|
|
| 9 |
public List<MembershipDto> AvailableMemberships { get; set; } = new List<MembershipDto>();
|
| 10 |
public List<LoyaltyRedemptionDto> QueuedMemberships { get; set; } = new List<LoyaltyRedemptionDto>();
|
| 11 |
public LoyaltyAccountDto? LoyaltyAccount { get; set; }
|
|
|
|
| 12 |
}
|
| 13 |
}
|
|
|
|
| 9 |
public List<MembershipDto> AvailableMemberships { get; set; } = new List<MembershipDto>();
|
| 10 |
public List<LoyaltyRedemptionDto> QueuedMemberships { get; set; } = new List<LoyaltyRedemptionDto>();
|
| 11 |
public LoyaltyAccountDto? LoyaltyAccount { get; set; }
|
| 12 |
+
public Dictionary<string, double> RewardPointCosts { get; set; } = new Dictionary<string, double>();
|
| 13 |
}
|
| 14 |
}
|
Frontend/Services/LibraryApiClient.cs
CHANGED
|
@@ -201,6 +201,40 @@ namespace Frontend.Services
|
|
| 201 |
return response.IsSuccessStatusCode;
|
| 202 |
}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
|
| 205 |
{
|
| 206 |
try {
|
|
@@ -251,5 +285,44 @@ namespace Frontend.Services
|
|
| 251 |
return response.IsSuccessStatusCode;
|
| 252 |
}
|
| 253 |
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
}
|
|
|
|
| 201 |
return response.IsSuccessStatusCode;
|
| 202 |
}
|
| 203 |
|
| 204 |
+
public async Task<(bool Success, string Message)> ClaimRewardAsync(string rewardId, string? notes = null)
|
| 205 |
+
{
|
| 206 |
+
try
|
| 207 |
+
{
|
| 208 |
+
var request = new ClaimRewardRequestDto { RewardId = rewardId, Notes = notes };
|
| 209 |
+
var response = await _httpClient.PostAsJsonAsync("api/Loyalty/claim", request);
|
| 210 |
+
|
| 211 |
+
if (response.IsSuccessStatusCode)
|
| 212 |
+
{
|
| 213 |
+
var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 214 |
+
string msg = "Reward claimed successfully!";
|
| 215 |
+
if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
|
| 216 |
+
{
|
| 217 |
+
msg = msgElement.GetString() ?? msg;
|
| 218 |
+
}
|
| 219 |
+
return (true, msg);
|
| 220 |
+
}
|
| 221 |
+
else
|
| 222 |
+
{
|
| 223 |
+
var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 224 |
+
string msg = "Failed to claim reward.";
|
| 225 |
+
if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
|
| 226 |
+
{
|
| 227 |
+
msg = msgElement.GetString() ?? msg;
|
| 228 |
+
}
|
| 229 |
+
return (false, msg);
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
catch (Exception ex)
|
| 233 |
+
{
|
| 234 |
+
return (false, $"An error occurred: {ex.Message}");
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
public async Task<IEnumerable<LoyaltyRewardDto>> GetActiveRewardsAsync()
|
| 239 |
{
|
| 240 |
try {
|
|
|
|
| 285 |
return response.IsSuccessStatusCode;
|
| 286 |
}
|
| 287 |
#endregion
|
| 288 |
+
public async Task<IEnumerable<LoyaltyRedemptionDto>> GetPendingRedemptionsAsync()
|
| 289 |
+
{
|
| 290 |
+
try {
|
| 291 |
+
var response = await _httpClient.GetAsync("api/Loyalty/admin/redemptions/pending");
|
| 292 |
+
if (response.IsSuccessStatusCode)
|
| 293 |
+
{
|
| 294 |
+
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
| 295 |
+
return await response.Content.ReadFromJsonAsync<IEnumerable<LoyaltyRedemptionDto>>(options) ?? Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 296 |
+
}
|
| 297 |
+
return Enumerable.Empty<LoyaltyRedemptionDto>();
|
| 298 |
+
} catch { return Enumerable.Empty<LoyaltyRedemptionDto>(); }
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
public async Task<(bool Success, string Message)> FulfillRedemptionAsync(string id)
|
| 302 |
+
{
|
| 303 |
+
try {
|
| 304 |
+
var response = await _httpClient.PostAsync($"api/Loyalty/admin/redemptions/{id}/fulfill", null);
|
| 305 |
+
if (response.IsSuccessStatusCode)
|
| 306 |
+
{
|
| 307 |
+
var result = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 308 |
+
string msg = "Redemption fulfilled successfully!";
|
| 309 |
+
if (result != null && result.RootElement.TryGetProperty("message", out var msgElement))
|
| 310 |
+
{
|
| 311 |
+
msg = msgElement.GetString() ?? msg;
|
| 312 |
+
}
|
| 313 |
+
return (true, msg);
|
| 314 |
+
}
|
| 315 |
+
else
|
| 316 |
+
{
|
| 317 |
+
var error = await response.Content.ReadFromJsonAsync<JsonDocument>();
|
| 318 |
+
string msg = "Failed to fulfill redemption.";
|
| 319 |
+
if (error != null && error.RootElement.TryGetProperty("message", out var msgElement))
|
| 320 |
+
{
|
| 321 |
+
msg = msgElement.GetString() ?? msg;
|
| 322 |
+
}
|
| 323 |
+
return (false, msg);
|
| 324 |
+
}
|
| 325 |
+
} catch (Exception ex) { return (false, $"Error: {ex.Message}"); }
|
| 326 |
+
}
|
| 327 |
}
|
| 328 |
}
|
Frontend/Views/Borrowings/Rewards.cshtml
CHANGED
|
@@ -53,7 +53,8 @@
|
|
| 53 |
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Stock</span>
|
| 54 |
<span class="text-sm font-bold text-slate-700">@reward.StockQuantity available</span>
|
| 55 |
</div>
|
| 56 |
-
<button
|
|
|
|
| 57 |
Redeem Reward
|
| 58 |
</button>
|
| 59 |
</div>
|
|
@@ -82,3 +83,33 @@
|
|
| 82 |
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-amber-400/10 rounded-full blur-3xl"></div>
|
| 83 |
<div class="absolute -left-10 -top-10 w-48 h-48 bg-blue-400/5 rounded-full blur-3xl"></div>
|
| 84 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Stock</span>
|
| 54 |
<span class="text-sm font-bold text-slate-700">@reward.StockQuantity available</span>
|
| 55 |
</div>
|
| 56 |
+
<button onclick="redeemReward('@reward.Id', '@reward.Name', @reward.PointCost)"
|
| 57 |
+
class="bg-slate-900 text-white px-5 py-2.5 rounded-xl text-xs font-bold hover:bg-slate-800 hover:shadow-lg transition-all active:scale-95">
|
| 58 |
Redeem Reward
|
| 59 |
</button>
|
| 60 |
</div>
|
|
|
|
| 83 |
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-amber-400/10 rounded-full blur-3xl"></div>
|
| 84 |
<div class="absolute -left-10 -top-10 w-48 h-48 bg-blue-400/5 rounded-full blur-3xl"></div>
|
| 85 |
</div>
|
| 86 |
+
|
| 87 |
+
<script>
|
| 88 |
+
async function redeemReward(rewardId, rewardName, cost) {
|
| 89 |
+
if (!confirm(`Are you sure you want to redeem ${rewardName} for ${cost.toLocaleString()} points?`)) {
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
try {
|
| 94 |
+
const response = await fetch('/Borrowings/Redeem', {
|
| 95 |
+
method: 'POST',
|
| 96 |
+
headers: {
|
| 97 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 98 |
+
},
|
| 99 |
+
body: new URLSearchParams({ rewardId: rewardId })
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
const result = await response.json();
|
| 103 |
+
|
| 104 |
+
if (result.success) {
|
| 105 |
+
alert(result.message);
|
| 106 |
+
window.location.reload();
|
| 107 |
+
} else {
|
| 108 |
+
alert('Error: ' + result.message);
|
| 109 |
+
}
|
| 110 |
+
} catch (error) {
|
| 111 |
+
console.error('Redemption error:', error);
|
| 112 |
+
alert('An unexpected error occurred. Please try again.');
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
</script>
|
Frontend/Views/Membership/Index.cshtml
CHANGED
|
@@ -189,14 +189,23 @@
|
|
| 189 |
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 190 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 191 |
</div>
|
| 192 |
-
<span class="text-slate-600 font-
|
| 193 |
</li>
|
| 194 |
<li class="flex items-center gap-3 text-sm">
|
| 195 |
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 196 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 197 |
</div>
|
| 198 |
-
<span class="text-slate-600 font-
|
| 199 |
</li>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</ul>
|
| 201 |
|
| 202 |
@if (isCurrent)
|
|
@@ -205,7 +214,17 @@
|
|
| 205 |
}
|
| 206 |
else
|
| 207 |
{
|
| 208 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
</div>
|
| 211 |
}
|
|
@@ -213,6 +232,37 @@
|
|
| 213 |
</section>
|
| 214 |
</div>
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
<style>
|
| 217 |
body {
|
| 218 |
background-color: #f8fafc;
|
|
|
|
| 189 |
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 190 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 191 |
</div>
|
| 192 |
+
<span class="text-slate-600 font-bold">@plan.MaxBooks Books limit</span>
|
| 193 |
</li>
|
| 194 |
<li class="flex items-center gap-3 text-sm">
|
| 195 |
<div class="h-5 w-5 rounded-full bg-slate-100 flex items-center justify-center text-slate-900">
|
| 196 |
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 197 |
</div>
|
| 198 |
+
<span class="text-slate-600 font-bold">@plan.BorrowingDays Days keep</span>
|
| 199 |
</li>
|
| 200 |
+
@if (!string.IsNullOrEmpty(plan.RewardId) && Model.RewardPointCosts.TryGetValue(plan.RewardId, out var pointCost))
|
| 201 |
+
{
|
| 202 |
+
<li class="flex items-center gap-3 text-sm text-amber-600 font-black">
|
| 203 |
+
<div class="h-5 w-5 rounded-full bg-amber-100 flex items-center justify-center text-amber-600">
|
| 204 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/></svg>
|
| 205 |
+
</div>
|
| 206 |
+
<span>@pointCost.ToString("N0") Points</span>
|
| 207 |
+
</li>
|
| 208 |
+
}
|
| 209 |
</ul>
|
| 210 |
|
| 211 |
@if (isCurrent)
|
|
|
|
| 214 |
}
|
| 215 |
else
|
| 216 |
{
|
| 217 |
+
<div class="space-y-3">
|
| 218 |
+
<a href="#" class="block w-full py-4 rounded-2xl bg-white border-2 border-slate-900 text-slate-900 text-center font-bold text-sm transition-all duration-200 hover:bg-slate-900 hover:text-white">Pay Cash</a>
|
| 219 |
+
|
| 220 |
+
@if (!string.IsNullOrEmpty(plan.RewardId) && Model.RewardPointCosts.ContainsKey(plan.RewardId))
|
| 221 |
+
{
|
| 222 |
+
<button onclick="redeemMembership('@plan.RewardId', '@plan.Type', @(Model.RewardPointCosts[plan.RewardId]))"
|
| 223 |
+
class="w-full py-4 rounded-2xl bg-amber-400 text-slate-900 font-black text-sm hover:bg-amber-500 transition-all shadow-lg shadow-amber-400/20">
|
| 224 |
+
Redeem with Points
|
| 225 |
+
</button>
|
| 226 |
+
}
|
| 227 |
+
</div>
|
| 228 |
}
|
| 229 |
</div>
|
| 230 |
}
|
|
|
|
| 232 |
</section>
|
| 233 |
</div>
|
| 234 |
|
| 235 |
+
<!-- Modal / Toast Scripts -->
|
| 236 |
+
<script>
|
| 237 |
+
async function redeemMembership(rewardId, planName, cost) {
|
| 238 |
+
if (!confirm(`Are you sure you want to redeem ${planName} for ${cost.toLocaleString()} points?`)) {
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
try {
|
| 243 |
+
const response = await fetch('/Membership/Redeem', {
|
| 244 |
+
method: 'POST',
|
| 245 |
+
headers: {
|
| 246 |
+
'Content-Type': 'application/x-www-form-urlencoded',
|
| 247 |
+
},
|
| 248 |
+
body: new URLSearchParams({ rewardId: rewardId })
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
const result = await response.json();
|
| 252 |
+
|
| 253 |
+
if (result.success) {
|
| 254 |
+
alert(result.message);
|
| 255 |
+
window.location.reload();
|
| 256 |
+
} else {
|
| 257 |
+
alert('Error: ' + result.message);
|
| 258 |
+
}
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error('Redemption error:', error);
|
| 261 |
+
alert('An unexpected error occurred. Please try again.');
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
</script>
|
| 265 |
+
|
| 266 |
<style>
|
| 267 |
body {
|
| 268 |
background-color: #f8fafc;
|
Frontend/Views/Shared/_Layout.cshtml
CHANGED
|
@@ -75,10 +75,13 @@
|
|
| 75 |
<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="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
| 76 |
Books Catalog
|
| 77 |
</a>
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
@if (User.Identity?.IsAuthenticated == true && !User.IsInRole("Librarian"))
|
| 83 |
{
|
| 84 |
<a asp-controller="Membership" asp-action="Index" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Membership" ? "nav-link-active" : "nav-link-inactive")">
|
|
@@ -98,9 +101,9 @@
|
|
| 98 |
Manage Borrowings
|
| 99 |
</a>
|
| 100 |
|
| 101 |
-
<a
|
| 102 |
-
<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"><
|
| 103 |
-
|
| 104 |
</a>
|
| 105 |
}
|
| 106 |
|
|
|
|
| 75 |
<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="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
| 76 |
Books Catalog
|
| 77 |
</a>
|
| 78 |
+
@if (User.Identity?.IsAuthenticated != true || !User.IsInRole("Librarian"))
|
| 79 |
+
{
|
| 80 |
+
<a href="/Borrowings" class="nav-link nav-link-inactive">
|
| 81 |
+
<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="m21 21-6-6m6 6v-4.8m0 4.8h-4.8"/><path d="M3 16.2V21m0 0h4.8M3 21l6-6"/><path d="M21 7.8V3m0 0h-4.8M21 3l-6 6"/><path d="M3 7.8V3m0 0h4.8M3 3l6 6"/></svg>
|
| 82 |
+
Active Loans
|
| 83 |
+
</a>
|
| 84 |
+
}
|
| 85 |
@if (User.Identity?.IsAuthenticated == true && !User.IsInRole("Librarian"))
|
| 86 |
{
|
| 87 |
<a asp-controller="Membership" asp-action="Index" class="nav-link @(ViewContext.RouteData.Values["Controller"]?.ToString() == "Membership" ? "nav-link-active" : "nav-link-inactive")">
|
|
|
|
| 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>
|
| 108 |
}
|
| 109 |
|