Yuyuqt
add: blazor web assembly.
4433399
Raw
History Blame Contribute Delete
16.4 kB
@page "/Rewards"
@using BlazorFrontend.Services
@using LibraryManagement.Shared.Models
@inject LibraryApiClient ApiClient
@inject IJSRuntime JS
<PageTitle>My Rewards</PageTitle>
<div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6 animate-in fade-in slide-in-from-top-4 duration-500">
<div>
<h1 class="text-3xl font-extrabold tracking-tight text-mystic-900">Loyalty Rewards</h1>
<p class="text-mystic-900 mt-1">Earn points, redeem rewards, and track your loyalty journey.</p>
</div>
@if (_account != null)
{
<div class="flex items-center gap-4">
<div class="text-center bg-mystic-900 text-white px-6 py-3 rounded-2xl shadow-lg border border-white/5">
<p class="text-[10px] font-bold text-mystic-200 uppercase tracking-widest">Balance</p>
<p class="text-2xl font-black">@_account.CurrentBalance.ToString("N0")</p>
<p class="text-xs text-mystic-300 font-semibold">Points</p>
</div>
<div class="text-center bg-mystic-50 border border-mystic-200 px-6 py-3 rounded-2xl shadow-sm">
<p class="text-[10px] font-bold text-mystic-500 uppercase tracking-widest">Tier</p>
<p class="text-lg font-black text-mystic-900">@_account.Tier</p>
</div>
</div>
}
</div>
<div class="mb-6 border-b border-mystic-200">
<nav class="-mb-px flex gap-6">
<button @onclick='() => _activeTab = "redeem"'
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "redeem" ? "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="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" />
</svg>
Available Rewards
</span>
</button>
<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
</span>
@if (_pendingRedemptions.Any())
{
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-black rounded-full bg-mystic-900 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>
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 == "redeem")
{
<div class="animate-in fade-in duration-300">
@if (!_availableRewards.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 active rewards available. Check back soon!</p>
</div>
}
else
{
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach (var reward in _availableRewards)
{
var canAfford = _account != null && _account.CurrentBalance >= reward.PointCost;
<div class="group relative bg-white border border-mystic-200 rounded-2xl shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 overflow-hidden">
<div class="h-1.5 @(canAfford ? "bg-gradient-to-r from-mystic-600 to-mystic-900" : "bg-mystic-200")"></div>
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="p-3 @(canAfford ? "bg-mystic-50 text-mystic-600" : "bg-mystic-100 text-mystic-400") rounded-xl">
<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>
</div>
<div class="text-right">
<span class="text-2xl font-black text-mystic-900">@reward.PointCost.ToString("N0")</span>
<span class="text-[10px] block font-bold text-mystic-400 uppercase">pts required</span>
</div>
</div>
<h3 class="text-base font-bold text-mystic-900 mb-1 truncate">@reward.Name</h3>
<p class="text-sm text-mystic-500 mb-5 line-clamp-2 h-10">@reward.Description</p>
<div class="flex items-center justify-between pt-4 border-t border-mystic-100">
<span class="text-xs font-semibold text-mystic-500">@reward.StockQuantity in stock</span>
@if (canAfford)
{
<button @onclick="() => RedeemAsync(reward)"
class="bg-mystic-900 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-mystic-700 transition-all active:scale-95 shadow-md">
Redeem
</button>
}
else
{
<span class="text-xs font-bold text-mystic-400 bg-mystic-100 px-3 py-2 rounded-xl">Need more pts</span>
}
</div>
</div>
@if (reward.StockQuantity < 10)
{
<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>
}
</div>
}
</div>
}
<div class="mt-8 p-7 bg-mystic-900 rounded-3xl text-white flex flex-col md:flex-row items-center justify-between gap-5">
<div>
<h2 class="text-lg font-bold mb-1">How to earn more points?</h2>
<p class="text-mystic-400 text-sm">Borrow books, subscribe, and return on time to earn points!</p>
</div>
<a href="Books" class="bg-white text-mystic-900 px-6 py-3 rounded-2xl font-black hover:bg-mystic-50 transition-all text-sm whitespace-nowrap shadow-sm">Browse Books →</a>
</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">
<p class="text-mystic-400 font-medium">No pending redemptions — you're all clear!</p>
</div>
}
else
{
<div class="space-y-3">
@foreach (var r in _pendingRedemptions)
{
<div class="flex items-center justify-between bg-white border border-mystic-100 rounded-2xl px-6 py-4 shadow-sm">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-mystic-50 flex items-center justify-center text-mystic-600 flex-shrink-0">
<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>
</div>
<div>
<p class="font-bold text-mystic-900 text-sm">@r.RewardName</p>
<p class="text-xs text-mystic-400">Claimed @r.RedeemedAt.ToString("MMM dd, yyyy") · @r.PointCost.ToString("N0") pts</p>
</div>
</div>
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-mystic-50 text-mystic-700 border border-mystic-200">
<span class="w-1.5 h-1.5 rounded-full bg-mystic-900 animate-pulse"></span>
Awaiting Fulfillment
</span>
</div>
}
</div>
}
</div>
}
else if (_activeTab == "history")
{
<div class="animate-in fade-in duration-300">
@if (!_pointsHistory.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 points history yet. Start borrowing books to earn points!</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">Date</th>
<th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Details</th>
<th class="px-6 py-3 text-left text-xs font-bold text-mystic-500 uppercase tracking-wider">Spent on</th>
<th class="px-6 py-3 text-right text-xs font-bold text-mystic-500 uppercase tracking-wider">Points</th>
</tr>
</thead>
<tbody class="divide-y divide-mystic-50">
@foreach (var h in _pointsHistory.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-3 text-sm text-mystic-500 whitespace-nowrap">@h.CreatedAt.ToString("MMM dd, yyyy")</td>
<td class="px-6 py-3">
<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>
</td>
<td class="px-6 py-3 text-sm text-mystic-600 max-w-xs truncate">@h.Description</td>
<td class="px-6 py-3 text-sm 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-3 text-right font-black text-base @(isPos ? "text-emerald-600" : "text-rose-600")">
@(isPos ? "+" : "")@h.PointDelta.ToString("N0")
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
@code {
private string _activeTab = "redeem";
private bool _isLoading = true;
private LoyaltyAccountDto? _account;
private IEnumerable<LoyaltyRewardDto> _availableRewards = Enumerable.Empty<LoyaltyRewardDto>();
private IEnumerable<LoyaltyRedemptionDto> _pendingRedemptions = Enumerable.Empty<LoyaltyRedemptionDto>();
private IEnumerable<PointHistoryEntryDto> _pointsHistory = Enumerable.Empty<PointHistoryEntryDto>();
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_isLoading = true;
try
{
_account = await ApiClient.GetMyLoyaltyAccountAsync();
_availableRewards = await ApiClient.GetActiveRewardsAsync();
_pendingRedemptions = await ApiClient.GetMyPendingRedemptionsAsync();
if (_account != null)
{
_pointsHistory = await ApiClient.GetPointsHistoryAsync(_account.AccountId);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error loading rewards: {ex.Message}");
}
finally
{
_isLoading = false;
}
}
private async Task RedeemAsync(LoyaltyRewardDto reward)
{
bool confirmed = await JS.InvokeAsync<bool>("confirm", $"Redeem \"{reward.Name}\" for {reward.PointCost:N0} points?");
if (!confirmed) return;
var result = await ApiClient.ClaimRewardAsync(reward.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)
};
}
}