Spaces:
Sleeping
Sleeping
| @page "/Rewards/Manage" | |
| @using BlazorFrontend.Services | |
| @using LibraryManagement.Shared.Models | |
| @using Microsoft.AspNetCore.Authorization | |
| @inject LibraryApiClient ApiClient | |
| @inject IJSRuntime JS | |
| @attribute [Authorize(Roles = "Librarian")] | |
| <PageTitle>Rewards Management</PageTitle> | |
| <div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-4 animate-in fade-in slide-in-from-top-4 duration-500"> | |
| <div> | |
| <h1 class="text-3xl font-extrabold tracking-tight text-mystic-900">Rewards Management</h1> | |
| <p class="text-mystic-900 mt-1">Fulfill pending redemptions and review all members' loyalty activity.</p> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <div class="bg-amber-50 border border-amber-200 px-5 py-2.5 rounded-xl text-center shadow-sm"> | |
| <p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Pending</p> | |
| <p class="text-2xl font-black text-mystic-900">@_pendingRedemptions.Count()</p> | |
| </div> | |
| <div class="bg-mystic-50 border border-mystic-200 px-5 py-2.5 rounded-xl text-center shadow-sm"> | |
| <p class="text-[10px] font-bold text-mystic-500 uppercase tracking-widest">Members w/ History</p> | |
| <p class="text-2xl font-black text-mystic-900">@_allMembersHistory.Count()</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-6 border-b border-mystic-200"> | |
| <nav class="-mb-px flex gap-6"> | |
| <button @onclick='() => _activeTab = "pending"' | |
| class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "pending" ? "border-mystic-900 text-mystic-900" : "border-transparent text-mystic-400 hover:text-mystic-700")'> | |
| <span class="inline-flex items-center gap-1.5"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| Pending Redemptions | |
| </span> | |
| @if (_pendingRedemptions.Any()) | |
| { | |
| <span class="ml-1 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[10px] font-black rounded-full bg-rose-500 text-white">@_pendingRedemptions.Count()</span> | |
| } | |
| </button> | |
| <button @onclick='() => _activeTab = "history"' | |
| class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "history" ? "border-mystic-900 text-mystic-900" : "border-transparent text-mystic-400 hover:text-mystic-700")'> | |
| <span class="inline-flex items-center gap-1.5"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" /> | |
| </svg> | |
| All Members' Points History | |
| </span> | |
| </button> | |
| </nav> | |
| </div> | |
| @if (_isLoading) | |
| { | |
| <div class="py-20 flex justify-center"> | |
| <div class="animate-spin rounded-full h-10 w-10 border-b-2 border-mystic-900"></div> | |
| </div> | |
| } | |
| else | |
| { | |
| @if (_activeTab == "pending") | |
| { | |
| <div class="animate-in fade-in duration-300"> | |
| @if (!_pendingRedemptions.Any()) | |
| { | |
| <div class="py-20 text-center bg-mystic-50 rounded-3xl border-2 border-dashed border-mystic-200"> | |
| <svg class="w-16 h-16 mx-auto opacity-20 text-mystic-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> | |
| <p class="text-mystic-500 font-medium">No pending redemptions. All caught up!</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="bg-white border border-mystic-200 rounded-2xl overflow-hidden shadow-sm"> | |
| <table class="min-w-full divide-y divide-mystic-100"> | |
| <thead class="bg-mystic-50"> | |
| <tr> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Member</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Reward</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Points</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Claimed</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Status</th> | |
| <th class="px-6 py-3 text-right text-xs font-bold text-mystic-500 uppercase tracking-wider">Action</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-mystic-100"> | |
| @foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt)) | |
| { | |
| <tr class="hover:bg-amber-50/40 transition-colors"> | |
| <td class="px-6 py-4"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 rounded-full bg-mystic-900 flex items-center justify-center text-white text-xs font-bold flex-shrink-0"> | |
| @(r.ExternalUserId.Length >= 2 ? r.ExternalUserId.Substring(0, 2).ToUpper() : "U") | |
| </div> | |
| <span class="text-sm font-medium text-mystic-700 font-mono text-xs">@r.ExternalUserId</span> | |
| </div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <p class="text-sm font-bold text-mystic-900">@r.RewardName</p> | |
| <p class="text-xs text-mystic-400">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)...</p> | |
| </td> | |
| <td class="px-6 py-4 text-sm font-bold text-mystic-700">@r.PointCost.ToString("N0") pts</td> | |
| <td class="px-6 py-4 text-sm text-mystic-500 whitespace-nowrap">@r.RedeemedAt.ToString("MMM dd, yyyy")</td> | |
| <td class="px-6 py-4"> | |
| <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"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span> | |
| Pending | |
| </span> | |
| </td> | |
| <td class="px-6 py-4 text-right"> | |
| <button @onclick="() => FulfillAsync(r)" | |
| 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"> | |
| <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> | |
| Fulfill | |
| </button> | |
| </td> | |
| </tr> | |
| } | |
| </tbody> | |
| </table> | |
| </div> | |
| } | |
| </div> | |
| } | |
| else if (_activeTab == "history") | |
| { | |
| <div class="animate-in fade-in duration-300"> | |
| @if (!_allMembersHistory.Any()) | |
| { | |
| <div class="py-20 text-center bg-mystic-50 rounded-3xl border-2 border-dashed border-mystic-200"> | |
| <p class="text-mystic-400 font-medium">No loyalty history found for any members yet.</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <!-- Search bar --> | |
| <div class="mb-4"> | |
| <input type="text" placeholder="Filter by member name or email..." | |
| class="w-full max-w-sm px-4 py-2.5 border border-mystic-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-mystic-900 bg-white" | |
| @bind="_searchQuery" @bind:event="oninput" /> | |
| </div> | |
| <div class="space-y-4"> | |
| @foreach (var member in FilteredMembers) | |
| { | |
| <div class="bg-white border border-mystic-200 rounded-2xl overflow-hidden shadow-sm"> | |
| <!-- Member header --> | |
| <div class="flex items-center justify-between px-6 py-4 bg-mystic-50 border-b border-mystic-100 cursor-pointer" | |
| @onclick="() => ToggleMember(member.AccountId)"> | |
| <div class="flex items-center gap-4"> | |
| <div class="w-10 h-10 rounded-full bg-mystic-900 flex items-center justify-center text-white font-bold text-sm flex-shrink-0"> | |
| @(member.UserName.Length >= 2 ? member.UserName.Substring(0, 2).ToUpper() : "?") | |
| </div> | |
| <div> | |
| <p class="font-bold text-mystic-900 text-sm">@member.UserName</p> | |
| <p class="text-xs text-mystic-400">@member.UserEmail</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-6"> | |
| <div class="text-right"> | |
| <p class="text-xs font-bold text-mystic-400 uppercase tracking-widest">Balance</p> | |
| <p class="font-black text-mystic-900">@member.CurrentBalance.ToString("N0") pts</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-xs font-bold text-mystic-400 uppercase tracking-widest">Tier</p> | |
| <span class="text-xs font-bold px-2 py-0.5 rounded-full bg-mystic-200 text-mystic-700">@member.Tier</span> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-xs font-bold text-mystic-400 uppercase tracking-widest">Transactions</p> | |
| <p class="font-black text-mystic-900">@member.History.Count()</p> | |
| </div> | |
| <svg class="w-4 h-4 text-mystic-400 transition-transform @(_expandedMembers.Contains(member.AccountId) ? "rotate-180" : "")" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg> | |
| </div> | |
| </div> | |
| <!-- Collapsible history table --> | |
| @if (_expandedMembers.Contains(member.AccountId)) | |
| { | |
| <div class="overflow-x-auto animate-in slide-in-from-top-2 duration-300"> | |
| @if (!member.History.Any()) | |
| { | |
| <p class="px-6 py-4 text-sm text-mystic-400 italic">No transactions found for this member.</p> | |
| } | |
| else | |
| { | |
| <table class="min-w-full divide-y divide-mystic-50"> | |
| <thead> | |
| <tr class="bg-mystic-50/50"> | |
| <th class="px-6 py-2 text-left text-[10px] font-bold text-mystic-400 uppercase tracking-wider">Date</th> | |
| <th class="px-6 py-2 text-left text-[10px] font-bold text-mystic-400 uppercase tracking-wider">Type</th> | |
| <th class="px-6 py-2 text-left text-[10px] font-bold text-mystic-400 uppercase tracking-wider">Details</th> | |
| <th class="px-6 py-2 text-left text-[10px] font-bold text-mystic-400 uppercase tracking-wider">Spent on</th> | |
| <th class="px-6 py-2 text-right text-[10px] font-bold text-mystic-400 uppercase tracking-wider">Points</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-mystic-50"> | |
| @foreach (var h in member.History.OrderByDescending(x => x.CreatedAt)) | |
| { | |
| var isPos = h.PointDelta >= 0; | |
| var label = PrettyEvent(h.EventKey, h.PointDelta); | |
| string? spentOn = h.RewardName; | |
| <tr class="hover:bg-mystic-50 transition-colors"> | |
| <td class="px-6 py-2.5 text-xs text-mystic-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td> | |
| <td class="px-6 py-2.5"> | |
| <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> | |
| </td> | |
| <td class="px-6 py-2.5 text-xs text-mystic-600 max-w-xs truncate">@h.Description</td> | |
| <td class="px-6 py-2.5 text-xs text-mystic-600 max-w-xs truncate"> | |
| @if (!string.IsNullOrWhiteSpace(spentOn)) | |
| { | |
| <span class="font-semibold text-mystic-700">@spentOn</span> | |
| } | |
| else | |
| { | |
| <span class="text-mystic-400">—</span> | |
| } | |
| </td> | |
| <td class="px-6 py-2.5 text-right font-black text-sm @(isPos ? "text-emerald-600" : "text-rose-600")"> | |
| @(isPos ? "+" : "")@h.PointDelta.ToString("N0") | |
| </td> | |
| </tr> | |
| } | |
| </tbody> | |
| </table> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| } | |
| @code { | |
| private string _activeTab = "pending"; | |
| private bool _isLoading = true; | |
| private string _searchQuery = ""; | |
| private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>(); | |
| private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>(); | |
| private HashSet<string> _expandedMembers = new(); | |
| protected override async Task OnInitializedAsync() | |
| { | |
| await LoadDataAsync(); | |
| } | |
| private async Task LoadDataAsync() | |
| { | |
| _isLoading = true; | |
| try | |
| { | |
| _pendingRedemptions = await ApiClient.GetPendingRedemptionsAsync(); | |
| _allMembersHistory = await ApiClient.GetAllMembersPointsHistoryAsync(); | |
| } | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Error loading management data: {ex.Message}"); | |
| } | |
| finally | |
| { | |
| _isLoading = false; | |
| } | |
| } | |
| private IEnumerable<UserPointsHistoryDto> FilteredMembers => | |
| string.IsNullOrWhiteSpace(_searchQuery) | |
| ? _allMembersHistory | |
| : _allMembersHistory.Where(m => m.UserName.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) || | |
| m.UserEmail.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase)); | |
| private void ToggleMember(string accountId) | |
| { | |
| if (_expandedMembers.Contains(accountId)) | |
| _expandedMembers.Remove(accountId); | |
| else | |
| _expandedMembers.Add(accountId); | |
| } | |
| private async Task FulfillAsync(LoyaltyRedemptionDto redemption) | |
| { | |
| bool confirmed = await JS.InvokeAsync<bool>("confirm", "Fulfill this redemption and grant membership to the member?"); | |
| if (!confirmed) return; | |
| var result = await ApiClient.FulfillRedemptionAsync(redemption.Id); | |
| if (result.Success) | |
| { | |
| await JS.InvokeVoidAsync("showToast", result.Message, "success"); | |
| await LoadDataAsync(); | |
| } | |
| else | |
| { | |
| await JS.InvokeVoidAsync("showToast", result.Message, "error"); | |
| } | |
| } | |
| private string PrettyEvent(string? eventKey, double points) | |
| { | |
| var key = (eventKey ?? string.Empty).Trim().ToUpperInvariant(); | |
| return key switch | |
| { | |
| "RETURN" or "BOOK_RETURN" or "BORROW_RETURN" => "RETURN", | |
| "SIGNUP" or "REGISTER" => "SIGNUP", | |
| "SUBSCRIBE" or "SUBSCRIPTION" or "MEMBERSHIP" => "SUBSCRIBE", | |
| "REDEEM" or "REDEMPTION" or "CLAIM_REWARD" => "REDEEM", | |
| _ => points < 0 ? "SPENT" : (string.IsNullOrEmpty(key) ? "EARNED" : key) | |
| }; | |
| } | |
| } | |