LibraryManagement / BlazorWebAssembly /Pages /RewardsManage.razor
Yuyuqt
add: small fixes, UI designing
ec76476
Raw
History Blame Contribute Delete
17.9 kB
@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);
}
}
}