Spaces:
Sleeping
Sleeping
Yuyuqt commited on
Commit ·
1fe511c
1
Parent(s): 6aad87c
add: wishlist and favourite features
Browse files
BlazorFrontend/Components/Layout/MainLayout.razor
CHANGED
|
@@ -49,6 +49,10 @@
|
|
| 49 |
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
| 50 |
Rewards
|
| 51 |
</NavLink>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
</Authorized>
|
| 54 |
</AuthorizeView>
|
|
|
|
| 49 |
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
| 50 |
Rewards
|
| 51 |
</NavLink>
|
| 52 |
+
<NavLink href="Wishlist" class="nav-link" ActiveClass="active">
|
| 53 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 mr-3"><path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
| 54 |
+
Wishlist
|
| 55 |
+
</NavLink>
|
| 56 |
}
|
| 57 |
</Authorized>
|
| 58 |
</AuthorizeView>
|
BlazorFrontend/Components/Pages/Books.razor
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
@using Microsoft.AspNetCore.Authorization
|
| 5 |
@inject LibraryApiClient ApiClient
|
|
|
|
| 6 |
@inject IJSRuntime JS
|
| 7 |
@inject NavigationManager Navigation
|
| 8 |
|
|
@@ -128,6 +129,27 @@
|
|
| 128 |
</div>
|
| 129 |
<div class="mt-auto pt-4 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 130 |
<button @onclick="() => ShowDetails(book.Id)" class="flex-1 btn-secondary py-1 text-[11px]">Details</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
<AuthorizeView Roles="Librarian">
|
| 132 |
<button @onclick="() => ShowEdit(book.Id)" class="p-1.5 rounded-md border border-mystic-200 hover:bg-mystic-50 text-mystic-600">
|
| 133 |
<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" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
|
@@ -191,14 +213,32 @@ else if (_viewMode == ViewMode.Details && _selectedBook != null)
|
|
| 191 |
|
| 192 |
<div class="flex items-center gap-4 pt-4">
|
| 193 |
<AuthorizeView Roles="Member">
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
</AuthorizeView>
|
| 203 |
|
| 204 |
<AuthorizeView Roles="Librarian">
|
|
@@ -394,6 +434,57 @@ else if (_viewMode == ViewMode.Create || _viewMode == ViewMode.Edit)
|
|
| 394 |
_formModel.CategoryIds.Remove(id);
|
| 395 |
}
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
private async Task HandleSubmit()
|
| 398 |
{
|
| 399 |
_isSubmitting = true;
|
|
|
|
| 3 |
@using BlazorFrontend.Models.Dtos
|
| 4 |
@using Microsoft.AspNetCore.Authorization
|
| 5 |
@inject LibraryApiClient ApiClient
|
| 6 |
+
@inject WishlistService WishlistService
|
| 7 |
@inject IJSRuntime JS
|
| 8 |
@inject NavigationManager Navigation
|
| 9 |
|
|
|
|
| 129 |
</div>
|
| 130 |
<div class="mt-auto pt-4 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 131 |
<button @onclick="() => ShowDetails(book.Id)" class="flex-1 btn-secondary py-1 text-[11px]">Details</button>
|
| 132 |
+
<AuthorizeView Roles="Member">
|
| 133 |
+
<div class="flex gap-1">
|
| 134 |
+
<button @onclick="() => ToggleFavourite(book)"
|
| 135 |
+
class='p-1.5 rounded-md border transition-all @(IsFavourite(book.Id) ? "border-rose-200 bg-rose-50 text-rose-500" : "border-mystic-200 text-mystic-400 hover:bg-rose-50 hover:text-rose-500")'
|
| 136 |
+
title='@(IsFavourite(book.Id) ? "Remove from Favourites" : "Add to Favourites")'>
|
| 137 |
+
<svg class="w-3.5 h-3.5" fill='@(IsFavourite(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 138 |
+
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
|
| 139 |
+
</svg>
|
| 140 |
+
</button>
|
| 141 |
+
@if (book.AvailableCopies == 0)
|
| 142 |
+
{
|
| 143 |
+
<button @onclick="() => ToggleWishlist(book)"
|
| 144 |
+
class='p-1.5 rounded-md border transition-all @(IsInWishlist(book.Id) ? "border-amber-200 bg-amber-50 text-amber-500" : "border-mystic-200 text-mystic-400 hover:bg-amber-50 hover:text-amber-500")'
|
| 145 |
+
title='@(IsInWishlist(book.Id) ? "Remove from Wishlist" : "Add to Wishlist")'>
|
| 146 |
+
<svg class="w-3.5 h-3.5" fill='@(IsInWishlist(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 147 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
| 148 |
+
</svg>
|
| 149 |
+
</button>
|
| 150 |
+
}
|
| 151 |
+
</div>
|
| 152 |
+
</AuthorizeView>
|
| 153 |
<AuthorizeView Roles="Librarian">
|
| 154 |
<button @onclick="() => ShowEdit(book.Id)" class="p-1.5 rounded-md border border-mystic-200 hover:bg-mystic-50 text-mystic-600">
|
| 155 |
<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" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
|
|
|
| 213 |
|
| 214 |
<div class="flex items-center gap-4 pt-4">
|
| 215 |
<AuthorizeView Roles="Member">
|
| 216 |
+
<div class="flex items-center gap-3">
|
| 217 |
+
@if (_selectedBook.AvailableCopies > 0)
|
| 218 |
+
{
|
| 219 |
+
<button @onclick="() => BorrowAsync(_selectedBook.Id)" class="btn-primary py-3 px-8 text-base shadow-lg shadow-mystic-200">Borrow This Book</button>
|
| 220 |
+
}
|
| 221 |
+
else
|
| 222 |
+
{
|
| 223 |
+
<div class="flex flex-col gap-2">
|
| 224 |
+
<button disabled class="btn-primary py-3 px-8 text-base opacity-50 cursor-not-allowed">Out of Stock</button>
|
| 225 |
+
<button @onclick="() => ToggleWishlist(_selectedBook)"
|
| 226 |
+
class='text-xs font-bold flex items-center gap-1.5 transition-colors @(IsInWishlist(_selectedBook.Id) ? "text-amber-600 underline" : "text-mystic-400 hover:text-amber-600 hover:underline")'>
|
| 227 |
+
<svg class="w-4 h-4" fill='@(IsInWishlist(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 228 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
| 229 |
+
</svg>
|
| 230 |
+
@(IsInWishlist(_selectedBook.Id) ? "In Wishlist (Notifying)" : "Add to Wishlist for Notification")
|
| 231 |
+
</button>
|
| 232 |
+
</div>
|
| 233 |
+
}
|
| 234 |
+
<button @onclick="() => ToggleFavourite(_selectedBook)"
|
| 235 |
+
class='p-3 rounded-xl border transition-all shadow-sm active:scale-95 @(IsFavourite(_selectedBook.Id) ? "border-rose-200 bg-rose-50 text-rose-500" : "border-mystic-200 text-mystic-400 hover:bg-rose-50 hover:text-rose-500")'
|
| 236 |
+
title='@(IsFavourite(_selectedBook.Id) ? "Remove from Favourites" : "Add to Favourites")'>
|
| 237 |
+
<svg class="w-6 h-6" fill='@(IsFavourite(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
| 238 |
+
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
|
| 239 |
+
</svg>
|
| 240 |
+
</button>
|
| 241 |
+
</div>
|
| 242 |
</AuthorizeView>
|
| 243 |
|
| 244 |
<AuthorizeView Roles="Librarian">
|
|
|
|
| 434 |
_formModel.CategoryIds.Remove(id);
|
| 435 |
}
|
| 436 |
|
| 437 |
+
private List<BookDto> _wishlist = new();
|
| 438 |
+
private List<BookDto> _favourites = new();
|
| 439 |
+
|
| 440 |
+
protected override async Task OnAfterRenderAsync(bool firstRender)
|
| 441 |
+
{
|
| 442 |
+
if (firstRender)
|
| 443 |
+
{
|
| 444 |
+
await LoadCollectionsAsync();
|
| 445 |
+
StateHasChanged();
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
private async Task LoadCollectionsAsync()
|
| 450 |
+
{
|
| 451 |
+
_wishlist = await WishlistService.GetWishlistAsync();
|
| 452 |
+
_favourites = await WishlistService.GetFavouritesAsync();
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
private bool IsFavourite(int id) => _favourites.Any(b => b.Id == id);
|
| 456 |
+
private bool IsInWishlist(int id) => _wishlist.Any(b => b.Id == id);
|
| 457 |
+
|
| 458 |
+
private async Task ToggleFavourite(BookDto book)
|
| 459 |
+
{
|
| 460 |
+
if (IsFavourite(book.Id))
|
| 461 |
+
{
|
| 462 |
+
await WishlistService.RemoveFromFavouriteAsync(book.Id);
|
| 463 |
+
await JS.InvokeVoidAsync("showToast", $"Removed \"{book.Title}\" from favourites", "success");
|
| 464 |
+
}
|
| 465 |
+
else
|
| 466 |
+
{
|
| 467 |
+
await WishlistService.AddToFavouritesAsync(book);
|
| 468 |
+
await JS.InvokeVoidAsync("showToast", $"Added \"{book.Title}\" to favourites!", "success");
|
| 469 |
+
}
|
| 470 |
+
await LoadCollectionsAsync();
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
private async Task ToggleWishlist(BookDto book)
|
| 474 |
+
{
|
| 475 |
+
if (IsInWishlist(book.Id))
|
| 476 |
+
{
|
| 477 |
+
await WishlistService.RemoveFromWishlistAsync(book.Id);
|
| 478 |
+
await JS.InvokeVoidAsync("showToast", $"Removed \"{book.Title}\" from wishlist", "success");
|
| 479 |
+
}
|
| 480 |
+
else
|
| 481 |
+
{
|
| 482 |
+
await WishlistService.AddToWishlistAsync(book);
|
| 483 |
+
await JS.InvokeVoidAsync("showToast", $"Added \"{book.Title}\" to wishlist!", "success");
|
| 484 |
+
}
|
| 485 |
+
await LoadCollectionsAsync();
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
private async Task HandleSubmit()
|
| 489 |
{
|
| 490 |
_isSubmitting = true;
|
BlazorFrontend/Components/Pages/Wishlist.razor
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@page "/Wishlist"
|
| 2 |
+
@using BlazorFrontend.Services
|
| 3 |
+
@using BlazorFrontend.Models.Dtos
|
| 4 |
+
@using Microsoft.AspNetCore.Authorization
|
| 5 |
+
@attribute [Authorize(Roles = "Member")]
|
| 6 |
+
@inject WishlistService WishlistService
|
| 7 |
+
@inject IJSRuntime JS
|
| 8 |
+
@inject NavigationManager Navigation
|
| 9 |
+
|
| 10 |
+
<PageTitle>My Wishlist & Favourites</PageTitle>
|
| 11 |
+
|
| 12 |
+
<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">
|
| 13 |
+
<div>
|
| 14 |
+
<h1 class="text-3xl font-extrabold tracking-tight text-mystic-900">My Collections</h1>
|
| 15 |
+
<p class="text-mystic-600 mt-1">Track books you want to borrow and your all-time favourites.</p>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<div class="mb-6 border-b border-mystic-200">
|
| 20 |
+
<nav class="-mb-px flex gap-6">
|
| 21 |
+
<button @onclick='() => _activeTab = "wishlist"'
|
| 22 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "wishlist" ? "border-amber-500 text-amber-600" : "border-transparent text-mystic-400 hover:text-amber-500")'>
|
| 23 |
+
<span class="inline-flex items-center gap-1.5">
|
| 24 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 25 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
| 26 |
+
</svg>
|
| 27 |
+
Wishlist
|
| 28 |
+
@if (_wishlist.Any())
|
| 29 |
+
{
|
| 30 |
+
<span class="ml-1 px-1.5 py-0.5 rounded-full bg-amber-500 text-white text-[10px]">@_wishlist.Count</span>
|
| 31 |
+
}
|
| 32 |
+
</span>
|
| 33 |
+
</button>
|
| 34 |
+
<button @onclick='() => _activeTab = "favourites"'
|
| 35 |
+
class='pb-3 text-sm font-semibold border-b-2 transition-all @(_activeTab == "favourites" ? "border-rose-500 text-rose-600" : "border-transparent text-mystic-400 hover:text-rose-500")'>
|
| 36 |
+
<span class="inline-flex items-center gap-1.5">
|
| 37 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 38 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
| 39 |
+
</svg>
|
| 40 |
+
Favourites
|
| 41 |
+
@if (_favourites.Any())
|
| 42 |
+
{
|
| 43 |
+
<span class="ml-1 px-1.5 py-0.5 rounded-full bg-rose-500 text-white text-[10px]">@_favourites.Count</span>
|
| 44 |
+
}
|
| 45 |
+
</span>
|
| 46 |
+
</button>
|
| 47 |
+
</nav>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
@if (_isLoading)
|
| 51 |
+
{
|
| 52 |
+
<div class="py-20 flex justify-center">
|
| 53 |
+
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-mystic-900"></div>
|
| 54 |
+
</div>
|
| 55 |
+
}
|
| 56 |
+
else
|
| 57 |
+
{
|
| 58 |
+
<div class="animate-in fade-in duration-300">
|
| 59 |
+
@if (_activeTab == "wishlist")
|
| 60 |
+
{
|
| 61 |
+
@if (!_wishlist.Any())
|
| 62 |
+
{
|
| 63 |
+
<div class="py-20 text-center bg-mystic-50 rounded-3xl border-2 border-dashed border-mystic-200">
|
| 64 |
+
<p class="text-mystic-400 font-medium">Your wishlist is empty. Add books that are currently out of stock!</p>
|
| 65 |
+
<a href="Books" class="inline-block mt-4 text-sm font-bold text-mystic-900 hover:underline underline-offset-4">Browse Catalog →</a>
|
| 66 |
+
</div>
|
| 67 |
+
}
|
| 68 |
+
else
|
| 69 |
+
{
|
| 70 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 71 |
+
@foreach (var book in _wishlist)
|
| 72 |
+
{
|
| 73 |
+
<div class="card p-4 flex gap-4 group hover:shadow-md transition-shadow relative">
|
| 74 |
+
<div class="w-20 h-28 bg-mystic-100 rounded-lg flex-shrink-0 flex items-center justify-center text-mystic-300 overflow-hidden border border-mystic-100">
|
| 75 |
+
@if (!string.IsNullOrWhiteSpace(book.CoverUrl))
|
| 76 |
+
{
|
| 77 |
+
<img src="@book.CoverUrl" alt="@book.Title" class="w-full h-full object-cover" />
|
| 78 |
+
}
|
| 79 |
+
else
|
| 80 |
+
{
|
| 81 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
| 82 |
+
}
|
| 83 |
+
</div>
|
| 84 |
+
<div class="flex-grow min-w-0">
|
| 85 |
+
<h3 class="text-sm font-bold text-mystic-900 truncate">@book.Title</h3>
|
| 86 |
+
<p class="text-xs text-mystic-500 mt-0.5 truncate">@book.Author</p>
|
| 87 |
+
<div class="mt-2">
|
| 88 |
+
<span class="inline-flex items-center rounded-full @(book.AvailableCopies > 0 ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700") px-2 py-0.5 text-[10px] font-bold">
|
| 89 |
+
@(book.AvailableCopies > 0 ? "Now Available" : "Out of Stock")
|
| 90 |
+
</span>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="mt-4 flex items-center gap-2">
|
| 93 |
+
<button @onclick='() => Navigation.NavigateTo($"/Books")' class="text-[10px] font-bold text-mystic-600 hover:text-mystic-900 uppercase tracking-tight">View Info</button>
|
| 94 |
+
<span class="text-mystic-200">|</span>
|
| 95 |
+
<button @onclick="() => RemoveFromWishlist(book.Id)" class="text-[10px] font-bold text-rose-600 hover:text-rose-700 uppercase tracking-tight">Remove</button>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
@if (book.AvailableCopies > 0)
|
| 99 |
+
{
|
| 100 |
+
<div class="absolute -top-2 -right-2 bg-emerald-500 text-white p-1 rounded-full shadow-sm animate-bounce">
|
| 101 |
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
|
| 102 |
+
</div>
|
| 103 |
+
}
|
| 104 |
+
</div>
|
| 105 |
+
}
|
| 106 |
+
</div>
|
| 107 |
+
<div class="mt-8 p-4 bg-mystic-900 rounded-2xl text-white flex items-center gap-4">
|
| 108 |
+
<div class="p-2 bg-mystic-800 rounded-lg">
|
| 109 |
+
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
| 110 |
+
</div>
|
| 111 |
+
<p class="text-xs font-medium">We'll notify you via Firebase when out-of-stock items become available!</p>
|
| 112 |
+
</div>
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
else
|
| 116 |
+
{
|
| 117 |
+
@if (!_favourites.Any())
|
| 118 |
+
{
|
| 119 |
+
<div class="py-20 text-center bg-mystic-50 rounded-3xl border-2 border-dashed border-mystic-200">
|
| 120 |
+
<p class="text-mystic-400 font-medium">You haven't added any favourites yet.</p>
|
| 121 |
+
<a href="Books" class="inline-block mt-4 text-sm font-bold text-mystic-900 hover:underline underline-offset-4">Browse Catalog →</a>
|
| 122 |
+
</div>
|
| 123 |
+
}
|
| 124 |
+
else
|
| 125 |
+
{
|
| 126 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 127 |
+
@foreach (var book in _favourites)
|
| 128 |
+
{
|
| 129 |
+
<div class="card p-4 flex gap-4 group hover:shadow-md transition-shadow">
|
| 130 |
+
<div class="w-20 h-28 bg-mystic-100 rounded-lg flex-shrink-0 flex items-center justify-center text-mystic-300 overflow-hidden border border-mystic-100">
|
| 131 |
+
@if (!string.IsNullOrWhiteSpace(book.CoverUrl))
|
| 132 |
+
{
|
| 133 |
+
<img src="@book.CoverUrl" alt="@book.Title" class="w-full h-full object-cover" />
|
| 134 |
+
}
|
| 135 |
+
else
|
| 136 |
+
{
|
| 137 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
| 138 |
+
}
|
| 139 |
+
</div>
|
| 140 |
+
<div class="flex-grow min-w-0">
|
| 141 |
+
<h3 class="text-sm font-bold text-mystic-900 truncate">@book.Title</h3>
|
| 142 |
+
<p class="text-xs text-mystic-500 mt-0.5 truncate">@book.Author</p>
|
| 143 |
+
<div class="mt-4 flex items-center gap-2">
|
| 144 |
+
<button @onclick='() => Navigation.NavigateTo($"/Books")' class="text-[10px] font-bold text-mystic-600 hover:text-mystic-900 uppercase tracking-tight">View Info</button>
|
| 145 |
+
<span class="text-mystic-200">|</span>
|
| 146 |
+
<button @onclick="() => RemoveFromFavourite(book.Id)" class="text-[10px] font-bold text-rose-600 hover:text-rose-700 uppercase tracking-tight">Remove</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
}
|
| 151 |
+
</div>
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
</div>
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
@code {
|
| 158 |
+
private string _activeTab = "wishlist";
|
| 159 |
+
private bool _isLoading = true;
|
| 160 |
+
private List<BookDto> _wishlist = new();
|
| 161 |
+
private List<BookDto> _favourites = new();
|
| 162 |
+
|
| 163 |
+
protected override async Task OnAfterRenderAsync(bool firstRender)
|
| 164 |
+
{
|
| 165 |
+
if (firstRender)
|
| 166 |
+
{
|
| 167 |
+
await LoadDataAsync();
|
| 168 |
+
_isLoading = false;
|
| 169 |
+
StateHasChanged();
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
private async Task LoadDataAsync()
|
| 174 |
+
{
|
| 175 |
+
_wishlist = await WishlistService.GetWishlistAsync();
|
| 176 |
+
_favourites = await WishlistService.GetFavouritesAsync();
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
private async Task RemoveFromWishlist(int id)
|
| 180 |
+
{
|
| 181 |
+
await WishlistService.RemoveFromWishlistAsync(id);
|
| 182 |
+
await LoadDataAsync();
|
| 183 |
+
await JS.InvokeVoidAsync("showToast", "Removed from wishlist", "success");
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
private async Task RemoveFromFavourite(int id)
|
| 187 |
+
{
|
| 188 |
+
await WishlistService.RemoveFromFavouriteAsync(id);
|
| 189 |
+
await LoadDataAsync();
|
| 190 |
+
await JS.InvokeVoidAsync("showToast", "Removed from favourites", "success");
|
| 191 |
+
}
|
| 192 |
+
}
|
BlazorFrontend/Program.cs
CHANGED
|
@@ -28,6 +28,7 @@ builder.Services.AddScoped(sp =>
|
|
| 28 |
|
| 29 |
|
| 30 |
builder.Services.AddScoped<LibraryApiClient>();
|
|
|
|
| 31 |
|
| 32 |
var app = builder.Build();
|
| 33 |
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
builder.Services.AddScoped<LibraryApiClient>();
|
| 31 |
+
builder.Services.AddScoped<WishlistService>();
|
| 32 |
|
| 33 |
var app = builder.Build();
|
| 34 |
|
BlazorFrontend/Services/WishlistService.cs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using BlazorFrontend.Models.Dtos;
|
| 2 |
+
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
|
| 3 |
+
using System.Text.Json;
|
| 4 |
+
|
| 5 |
+
namespace BlazorFrontend.Services;
|
| 6 |
+
|
| 7 |
+
public class WishlistService
|
| 8 |
+
{
|
| 9 |
+
private readonly ProtectedLocalStorage _localStorage;
|
| 10 |
+
private const string WishlistKey = "user_wishlist";
|
| 11 |
+
private const string FavouriteKey = "user_favourites";
|
| 12 |
+
|
| 13 |
+
public WishlistService(ProtectedLocalStorage localStorage)
|
| 14 |
+
{
|
| 15 |
+
_localStorage = localStorage;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
public async Task<List<BookDto>> GetWishlistAsync()
|
| 19 |
+
{
|
| 20 |
+
try
|
| 21 |
+
{
|
| 22 |
+
var result = await _localStorage.GetAsync<string>(WishlistKey);
|
| 23 |
+
if (result.Success && !string.IsNullOrWhiteSpace(result.Value))
|
| 24 |
+
{
|
| 25 |
+
return JsonSerializer.Deserialize<List<BookDto>>(result.Value) ?? new List<BookDto>();
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
catch { }
|
| 29 |
+
return new List<BookDto>();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
public async Task AddToWishlistAsync(BookDto book)
|
| 33 |
+
{
|
| 34 |
+
var list = await GetWishlistAsync();
|
| 35 |
+
if (!list.Any(b => b.Id == book.Id))
|
| 36 |
+
{
|
| 37 |
+
list.Add(book);
|
| 38 |
+
await _localStorage.SetAsync(WishlistKey, JsonSerializer.Serialize(list));
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
public async Task RemoveFromWishlistAsync(int bookId)
|
| 43 |
+
{
|
| 44 |
+
var list = await GetWishlistAsync();
|
| 45 |
+
var item = list.FirstOrDefault(b => b.Id == bookId);
|
| 46 |
+
if (item != null)
|
| 47 |
+
{
|
| 48 |
+
list.Remove(item);
|
| 49 |
+
await _localStorage.SetAsync(WishlistKey, JsonSerializer.Serialize(list));
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
public async Task<List<BookDto>> GetFavouritesAsync()
|
| 54 |
+
{
|
| 55 |
+
try
|
| 56 |
+
{
|
| 57 |
+
var result = await _localStorage.GetAsync<string>(FavouriteKey);
|
| 58 |
+
if (result.Success && !string.IsNullOrWhiteSpace(result.Value))
|
| 59 |
+
{
|
| 60 |
+
return JsonSerializer.Deserialize<List<BookDto>>(result.Value) ?? new List<BookDto>();
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
catch { }
|
| 64 |
+
return new List<BookDto>();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
public async Task AddToFavouritesAsync(BookDto book)
|
| 68 |
+
{
|
| 69 |
+
var list = await GetFavouritesAsync();
|
| 70 |
+
if (!list.Any(b => b.Id == book.Id))
|
| 71 |
+
{
|
| 72 |
+
list.Add(book);
|
| 73 |
+
await _localStorage.SetAsync(FavouriteKey, JsonSerializer.Serialize(list));
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
public async Task RemoveFromFavouriteAsync(int bookId)
|
| 78 |
+
{
|
| 79 |
+
var list = await GetFavouritesAsync();
|
| 80 |
+
var item = list.FirstOrDefault(b => b.Id == bookId);
|
| 81 |
+
if (item != null)
|
| 82 |
+
{
|
| 83 |
+
list.Remove(item);
|
| 84 |
+
await _localStorage.SetAsync(FavouriteKey, JsonSerializer.Serialize(list));
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|