Spaces:
Sleeping
Sleeping
| Frontend.Models.RewardsViewModel | |
| @{ | |
| ViewData["Title"] = "My Rewards"; | |
| var hasAccount = Model.Account != null; | |
| var redemptionById = Model.RedemptionsHistory | |
| .Where(r => !string.IsNullOrWhiteSpace(r.Id)) | |
| .GroupBy(r => r.Id) | |
| .ToDictionary(g => g.Key, g => g.First()); | |
| 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) | |
| }; | |
| } | |
| } | |
| <div class="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6"> | |
| <div> | |
| <h1 class="text-3xl font-extrabold tracking-tight text-slate-900">Loyalty Rewards</h1> | |
| <p class="text-slate-500 mt-1">Earn points, redeem rewards, and track your loyalty journey.</p> | |
| </div> | |
| (hasAccount) | |
| { | |
| <div class="flex items-center gap-4"> | |
| <div class="text-center bg-amber-50 border border-amber-200 px-6 py-3 rounded-2xl shadow-sm"> | |
| <p class="text-[10px] font-bold text-amber-600 uppercase tracking-widest">Balance</p> | |
| <p class="text-2xl font-black text-slate-900">@Model.Account!.CurrentBalance.ToString("N0")</p> | |
| <p class="text-xs text-amber-600 font-semibold">Points</p> | |
| </div> | |
| <div class="text-center bg-slate-50 border border-slate-200 px-6 py-3 rounded-2xl shadow-sm"> | |
| <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tier</p> | |
| <p class="text-lg font-black text-slate-900">@Model.Account!.Tier</p> | |
| </div> | |
| </div> | |
| } | |
| </div> | |
| <div class="mb-6 border-b border-slate-200"> | |
| <nav class="-mb-px flex gap-6"> | |
| <button onclick="switchTab('tab-redeem','btn-redeem')" id="btn-redeem" | |
| class="tab-btn pb-3 text-sm font-semibold border-b-2 border-slate-900 text-slate-900 transition-all"> | |
| <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="switchTab('tab-pending','btn-pending')" id="btn-pending" | |
| class="tab-btn pb-3 text-sm font-semibold border-b-2 border-transparent text-slate-400 hover:text-slate-700 transition-all"> | |
| <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 (Model.PendingRedemptions.Any()) | |
| { | |
| <span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-[10px] font-black rounded-full bg-amber-400 text-slate-900">@Model.PendingRedemptions.Count()</span> | |
| } | |
| </button> | |
| <button onclick="switchTab('tab-history','btn-history')" id="btn-history" | |
| class="tab-btn pb-3 text-sm font-semibold border-b-2 border-transparent text-slate-400 hover:text-slate-700 transition-all"> | |
| <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> | |
| <!-- Available Rewards Tab --> | |
| <div id="tab-redeem" class="tab-content"> | |
| @if (!Model.Rewards.Any()) | |
| { | |
| <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200"> | |
| <p class="text-slate-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 Model.Rewards) | |
| { | |
| var canAfford = hasAccount && Model.Account!.CurrentBalance >= reward.PointCost; | |
| <div class="group relative bg-white border border-slate-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-amber-400 to-orange-400" : "bg-slate-200")"></div> | |
| <div class="p-6"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <div class="p-3 @(canAfford ? "bg-amber-50 text-amber-600" : "bg-slate-100 text-slate-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-slate-900">@reward.PointCost.ToString("N0")</span> | |
| <span class="text-[10px] block font-bold text-slate-400 uppercase">pts required</span> | |
| </div> | |
| </div> | |
| <h3 class="text-base font-bold text-slate-900 mb-1 truncate">@reward.Name</h3> | |
| <p class="text-sm text-slate-500 mb-5 line-clamp-2 h-10">@reward.Description</p> | |
| <div class="flex items-center justify-between pt-4 border-t border-slate-100"> | |
| <span class="text-xs font-semibold text-slate-500">@reward.StockQuantity in stock</span> | |
| @if (canAfford) | |
| { | |
| <button onclick="redeemReward('@reward.Id','@reward.Name',@reward.PointCost)" | |
| class="bg-slate-900 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-amber-500 transition-all active:scale-95"> | |
| Redeem | |
| </button> | |
| } | |
| else | |
| { | |
| <span class="text-xs font-bold text-slate-400 bg-slate-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-slate-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-slate-400 text-sm">Borrow books, subscribe, and return on time to earn points!</p> | |
| </div> | |
| <a asp-controller="Books" asp-action="Index" class="bg-amber-400 text-slate-900 px-6 py-3 rounded-2xl font-black hover:bg-amber-300 transition-all text-sm whitespace-nowrap">Browse Books →</a> | |
| </div> | |
| </div> | |
| <!-- Pending Redemptions Tab --> | |
| <div id="tab-pending" class="tab-content hidden"> | |
| @if (!Model.PendingRedemptions.Any()) | |
| { | |
| <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200"> | |
| <p class="text-slate-400 font-medium">No pending redemptions — you're all clear!</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="space-y-3"> | |
| @foreach (var r in Model.PendingRedemptions) | |
| { | |
| <div class="flex items-center justify-between bg-white border border-amber-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-amber-50 flex items-center justify-center text-amber-500 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-slate-900 text-sm">@r.RewardName</p> | |
| <p class="text-xs text-slate-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-amber-100 text-amber-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span> | |
| Awaiting Fulfillment | |
| </span> | |
| </div> | |
| } | |
| </div> | |
| } | |
| </div> | |
| <!-- Points History Tab --> | |
| <div id="tab-history" class="tab-content hidden"> | |
| @if (!Model.PointsHistory.Any()) | |
| { | |
| <div class="py-20 text-center bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200"> | |
| <p class="text-slate-400 font-medium">No points history yet. Start borrowing books to earn points!</p> | |
| </div> | |
| } | |
| else | |
| { | |
| <div class="bg-white border border-slate-200 rounded-2xl overflow-hidden shadow-sm"> | |
| <table class="min-w-full divide-y divide-slate-100"> | |
| <thead class="bg-slate-50"> | |
| <tr> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Date</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Type</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Details</th> | |
| <th class="px-6 py-3 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">Spent on</th> | |
| <th class="px-6 py-3 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">Points</th> | |
| </tr> | |
| </thead> | |
| <tbody class="divide-y divide-slate-50"> | |
| @foreach (var h in Model.PointsHistory.OrderByDescending(x => x.CreatedAt)) | |
| { | |
| var isPos = h.PointDelta >= 0; | |
| var label = PrettyEvent(h.EventKey, h.PointDelta); | |
| string? spentOn = null; | |
| if (!isPos && !string.IsNullOrWhiteSpace(h.RewardName)) | |
| { | |
| spentOn = h.RewardName; | |
| } | |
| else if (!isPos && !string.IsNullOrWhiteSpace(h.ReferenceId) && redemptionById.TryGetValue(h.ReferenceId, out var redemption)) | |
| { | |
| spentOn = redemption.RewardName; | |
| } | |
| <tr class="hover:bg-slate-50 transition-colors"> | |
| <td class="px-6 py-3 text-sm text-slate-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-slate-600 max-w-xs truncate">@h.Description</td> | |
| <td class="px-6 py-3 text-sm text-slate-600 max-w-xs truncate"> | |
| @if (!string.IsNullOrWhiteSpace(spentOn)) | |
| { | |
| <span class="font-semibold text-slate-700">@spentOn</span> | |
| } | |
| else | |
| { | |
| <span class="text-slate-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> | |
| Scripts { | |
| <script> | |
| function switchTab(tabId, btnId) { | |
| document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden')); | |
| document.getElementById(tabId).classList.remove('hidden'); | |
| document.querySelectorAll('.tab-btn').forEach(b => { | |
| b.classList.remove('border-slate-900','text-slate-900'); | |
| b.classList.add('border-transparent','text-slate-400'); | |
| }); | |
| const btn = document.getElementById(btnId); | |
| btn.classList.remove('border-transparent','text-slate-400'); | |
| btn.classList.add('border-slate-900','text-slate-900'); | |
| } | |
| async function redeemReward(rewardId, rewardName, cost) { | |
| if (!confirm(`Redeem "${rewardName}" for ${cost.toLocaleString()} points?`)) return; | |
| try { | |
| const r = await fetch('/Rewards/Redeem', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body: new URLSearchParams({ rewardId }) | |
| }); | |
| const res = await r.json(); | |
| if (res.success) { showToast(res.message, 'success'); setTimeout(() => location.reload(), 1500); } | |
| else showToast('Error: ' + res.message, 'error'); | |
| } catch { showToast('Unexpected error.', 'error'); } | |
| } | |
| </script> | |
| } | |