Spaces:
Sleeping
Sleeping
| @page "/rewards/manage" | |
| @using LibraryManagement.Shared.Models | |
| @using BlazorWebAssembly.Services | |
| @using Microsoft.AspNetCore.Authorization | |
| @attribute [Authorize(Roles = "Librarian")] | |
| @inject LibraryApiClient ApiClient | |
| @inject IJSRuntime JS | |
| <div class="max-w-7xl mx-auto py-12 px-6"> | |
| <!-- Header --> | |
| <div class="mb-12"> | |
| <h1 class="text-5xl font-black tracking-tight text-main mb-4">Rewards Management</h1> | |
| <p class="text-lg text-muted max-w-2xl"> | |
| Review and fulfill loyalty reward claims from library members. | |
| </p> | |
| </div> | |
| <!-- Tabs --> | |
| <div class="flex items-center gap-8 border-b border-border mb-12"> | |
| <button @onclick='() => _activeTab = "pending"' | |
| class='pb-4 text-sm font-bold tracking-widest uppercase transition-all relative @(_activeTab == "pending" ? "text-primary" : "text-muted hover:text-main")'> | |
| Pending Redemptions | |
| @if (_pendingRedemptions.Any()) | |
| { | |
| <span class="ml-2 bg-primary text-white text-[10px] px-2 py-0.5 rounded-full">@_pendingRedemptions.Count()</span> | |
| } | |
| @if (_activeTab == "pending") | |
| { | |
| <div class="absolute bottom-0 left-0 w-full h-0.5 bg-primary animate-scale-x"></div> | |
| } | |
| </button> | |
| <button @onclick='() => _activeTab = "history"' | |
| class='pb-4 text-sm font-bold tracking-widest uppercase transition-all relative @(_activeTab == "history" ? "text-primary" : "text-muted hover:text-main")'> | |
| Members Points History | |
| @if (_activeTab == "history") | |
| { | |
| <div class="absolute bottom-0 left-0 w-full h-0.5 bg-primary animate-scale-x"></div> | |
| } | |
| </button> | |
| </div> | |
| @if (_isLoading) | |
| { | |
| <div class="flex justify-center py-20"> | |
| <div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div> | |
| </div> | |
| } | |
| else | |
| { | |
| @if (_activeTab == "pending") | |
| { | |
| <div class="animate-fade"> | |
| @if (!_pendingRedemptions.Any()) | |
| { | |
| <div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm"> | |
| <p class="text-muted font-bold text-lg">No pending redemptions at the moment.</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="max-w-6xl"> | |
| <div class="bg-card rounded-2xl border border-border overflow-hidden shadow-sm"> | |
| <table class="w-full text-left border-collapse whitespace-nowrap"> | |
| <thead class="bg-body border-b border-border"> | |
| <tr> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Member</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Reward</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Points</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Claimed Date</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Status</th> | |
| <th class="px-6 py-4 text-[10px] font-bold text-muted uppercase tracking-widest">Action</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-border"> | |
| @foreach (var r in _pendingRedemptions.OrderBy(x => x.RedeemedAt)) | |
| { | |
| <tr class="hover:bg-body transition-colors"> | |
| <td class="px-6 py-4"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm"> | |
| @(string.IsNullOrEmpty(r.UserName) || r.UserName == "Unknown Member" ? "U" : r.UserName.Substring(0, 1).ToUpper()) | |
| </div> | |
| <span class="text-sm font-bold text-main">@(r.UserName ?? "Unknown Member")</span> | |
| </div> | |
| </td> | |
| <td class="px-6 py-4"> | |
| <p class="text-sm font-bold text-main">@r.RewardName</p> | |
| <p class="text-[10px] text-muted font-bold tracking-widest uppercase">ID: @(r.Id.Length > 8 ? r.Id[..8] : r.Id)</p> | |
| </td> | |
| <td class="px-6 py-4 text-sm font-black text-primary">@r.PointCost.ToString("N0") pts</td> | |
| <td class="px-6 py-4 text-sm font-medium text-muted">@r.RedeemedAt.ToString("MMM dd, yyyy")</td> | |
| <td class="px-4 py-4"> | |
| <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-amber-500/10 text-amber-500"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span> | |
| Pending | |
| </span> | |
| </td> | |
| <td class="px-4 py-4"> | |
| <button @onclick="() => FulfillAsync(r)" | |
| class="inline-flex items-center gap-1.5 bg-green-500/10 text-green-500 border border-green-500/20 px-4 py-2 rounded-lg text-xs font-bold hover:bg-green-500 hover:text-white transition-all shadow-sm active:scale-95"> | |
| <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> | |
| } | |
| </div> | |
| } | |
| else if (_activeTab == "history") | |
| { | |
| <div class="animate-fade"> | |
| @if (!_allMembersHistory.Any()) | |
| { | |
| <div class="py-24 text-center bg-card rounded-3xl border border-dashed border-border shadow-sm"> | |
| <p class="text-muted font-bold text-lg">No loyalty history found.</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="mb-6"> | |
| <input type="text" placeholder="Filter by member name or email..." | |
| class="w-full max-w-md bg-card border border-border rounded-xl px-4 py-3 focus:ring-2 focus:ring-primary focus:border-transparent outline-none text-main shadow-sm transition-all" | |
| @bind="_searchQuery" @bind:event="oninput" /> | |
| </div> | |
| <div class="space-y-4"> | |
| @foreach (var member in FilteredMembers) | |
| { | |
| <div class="bg-card border border-border rounded-2xl overflow-hidden shadow-sm hover:border-primary/50 transition-colors"> | |
| <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between px-6 py-5 cursor-pointer hover:bg-body transition-colors" | |
| @onclick="() => ToggleMember(member.AccountId)"> | |
| <div class="flex items-center gap-4 mb-4 sm:mb-0"> | |
| <div class="w-12 h-12 rounded-full bg-primary/10 text-primary border border-primary/20 flex items-center justify-center font-bold shadow-inner flex-shrink-0"> | |
| @(member.UserName.Length >= 2 ? member.UserName.Substring(0, 2).ToUpper() : "?") | |
| </div> | |
| <div> | |
| <p class="font-bold text-main text-lg">@member.UserName</p> | |
| <p class="text-xs font-medium text-muted">@member.UserEmail</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-12"> | |
| <div class="text-center sm:text-right"> | |
| <p class="text-[10px] text-muted font-bold tracking-widest uppercase mb-1">Current Balance</p> | |
| <p class="text-2xl font-black text-primary">@member.CurrentBalance.ToString("N0") <span class="text-xs">PTS</span></p> | |
| </div> | |
| <div class="text-center sm:text-right"> | |
| <p class="text-[10px] text-muted font-bold tracking-widest uppercase mb-1">Tier</p> | |
| <span class="inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-primary text-white shadow-sm">@member.Tier</span> | |
| </div> | |
| <div class="w-8 h-8 rounded-full bg-body flex items-center justify-center transition-transform @(_expandedMemberId == member.AccountId ? "rotate-180" : "")"> | |
| <svg class="w-5 h-5 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"></path></svg> | |
| </div> | |
| </div> | |
| </div> | |
| @if (_expandedMemberId == member.AccountId) | |
| { | |
| <div class="border-t border-border bg-body/30 p-6 animate-slide-down"> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> | |
| <!-- Points History --> | |
| <div> | |
| <h4 class="text-xs font-bold text-muted uppercase tracking-[0.2em] mb-4 flex items-center gap-2"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| Points History | |
| </h4> | |
| <div class="space-y-2 max-h-64 overflow-y-auto pr-2 custom-scrollbar"> | |
| @foreach (var entry in member.History.OrderByDescending(x => x.CreatedAt)) | |
| { | |
| <div class="flex items-center justify-between p-3 bg-card rounded-xl border border-border shadow-sm"> | |
| <div> | |
| <p class="text-sm font-bold text-main">@entry.Description</p> | |
| <p class="text-[10px] text-muted">@entry.CreatedAt.ToString("MMM dd, yyyy HH:mm")</p> | |
| </div> | |
| <div class='text-sm font-black @(entry.PointDelta >= 0 ? "text-green-500" : "text-red-500")'> | |
| @(entry.PointDelta >= 0 ? "+" : "")@entry.PointDelta | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| <!-- Redemptions --> | |
| <div> | |
| <h4 class="text-xs font-bold text-muted uppercase tracking-[0.2em] mb-4 flex items-center gap-2"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg> | |
| Redemption History | |
| </h4> | |
| <div class="space-y-2 max-h-64 overflow-y-auto pr-2 custom-scrollbar"> | |
| @foreach (var red in member.Redemptions.OrderByDescending(x => x.RedeemedAt)) | |
| { | |
| <div class="flex items-center justify-between p-3 bg-card rounded-xl border border-border shadow-sm"> | |
| <div> | |
| <p class="text-sm font-bold text-main">@red.RewardName</p> | |
| <p class="text-[10px] text-muted">@red.RedeemedAt.ToString("MMM dd, yyyy")</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-xs font-black text-primary">@red.PointCost pts</p> | |
| <span class='text-[9px] font-bold uppercase tracking-widest @(red.Status == "Fulfilled" ? "text-green-500" : "text-amber-500")'>@red.Status</span> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| } | |
| } | |
| </div> | |
| @code { | |
| private string _activeTab = "pending"; | |
| private bool _isLoading = true; | |
| private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>(); | |
| private IEnumerable<UserPointsHistoryDto> _allMembersHistory = Enumerable.Empty<UserPointsHistoryDto>(); | |
| private string _searchQuery = ""; | |
| private string? _expandedMemberId; | |
| 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 rewards manage 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 (_expandedMemberId == accountId) | |
| _expandedMemberId = null; | |
| else | |
| _expandedMemberId = accountId; | |
| } | |
| private async Task FulfillAsync(LoyaltyRedemptionDto redemption) | |
| { | |
| bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Fulfill {redemption.RewardName} for {redemption.UserName}?"); | |
| if (!confirmed) return; | |
| var result = await ApiClient.FulfillRedemptionAsync(redemption.Id); | |
| if (result.Success) | |
| { | |
| await LoadDataAsync(); | |
| await JS.InvokeVoidAsync("alert", result.Message); | |
| } | |
| else | |
| { | |
| await JS.InvokeVoidAsync("alert", result.Message); | |
| } | |
| } | |
| } | |