diff --git a/e2e/BrowseItemTest.spec.ts b/e2e/BrowseItemTest.spec.ts index d702dbd..c14f67b 100644 --- a/e2e/BrowseItemTest.spec.ts +++ b/e2e/BrowseItemTest.spec.ts @@ -10,4 +10,18 @@ test('Browse Items', async ({ page }) => { //Expect await expect(page.getByRole('heading', { name: 'Adventurer GPS Watch' })).toBeVisible(); +}); + +test('Favorites redirect to sign-in when signed out', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); + + const productName = 'Adventurer GPS Watch'; + const card = page.locator('.catalog-item-with-favorites').filter({ + has: page.getByRole('link', { name: productName }), + }); + + await card.locator('button[aria-label="Log in to save favorites"]').first().click(); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); }); \ No newline at end of file diff --git a/e2e/FavoritesTest.spec.ts b/e2e/FavoritesTest.spec.ts new file mode 100644 index 0000000..2e5364d --- /dev/null +++ b/e2e/FavoritesTest.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +test('Shopper Favorites: save from catalog, view in header, toggle off/on', async ({ page }) => { + await page.goto('/'); + + const productName = 'Adventurer GPS Watch'; + + await expect(page.getByRole('heading', { name: 'Ready for a new adventure?' })).toBeVisible(); + + // Favorite from catalog card. + const card = page.locator('.catalog-item-with-favorites').filter({ + has: page.getByRole('link', { name: productName }), + }); + + const urlBefore = page.url(); + await card.locator('button[aria-label="Save to favorites"]').first().click(); + await expect(page).toHaveURL(urlBefore); + + // Header should reflect the new favorites count. + const headerFavoritesButton = page.getByRole('button', { name: 'Favorites' }); + await expect(page.locator('.favorites-badge')).toHaveText('1'); + + // Open favorites quick list. + await headerFavoritesButton.click(); + const dropdown = page.locator('.favorites-dropdown-content'); + await expect(dropdown.getByRole('link', { name: productName })).toBeVisible(); + + // Navigate back to the favorited item. + await dropdown.getByRole('link', { name: productName }).click(); + await expect(page.getByRole('heading', { name: productName })).toBeVisible(); + + // Toggle off from item page. + const removeButton = page.getByRole('button', { name: 'Remove from favorites' }); + await removeButton.click(); + await headerFavoritesButton.click(); + await expect(page.locator('.favorites-badge')).toHaveCount(0); + + // Toggle back on; duplicates must not accumulate. + await page.locator('button[aria-label="Save to favorites"]').first().click(); + await headerFavoritesButton.click(); + + const occurrencesInDropdown = page.locator('.favorites-dropdown-content').getByRole('link', { name: productName }); + await expect(occurrencesInDropdown).toHaveCount(1); +}); + diff --git a/playwright.config.ts b/playwright.config.ts index 9728afb..3b7a9c7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ }, { name: 'e2e tests logged in', - testMatch: ['**/AddItemTest.spec.ts', '**/RemoveItemTest.spec.ts'], + testMatch: ['**/AddItemTest.spec.ts', '**/RemoveItemTest.spec.ts', '**/FavoritesTest.spec.ts'], dependencies: ['setup'], use: { storageState: STORAGE_STATE, @@ -85,11 +85,11 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj', + command: 'ESHOP_USE_HTTP_ENDPOINTS=1 dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj -p:NuGetAudit=false -p:LibraryRestore=False', url: 'http://localhost:5045', reuseExistingServer: !process.env.CI, stderr: 'pipe', stdout: 'pipe', - timeout: process.env.CI ? (5 * 60_000) : 60_000, + timeout: process.env.CI ? (5 * 60_000) : 180_000, }, }); diff --git a/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor new file mode 100644 index 0000000..14b4974 --- /dev/null +++ b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor @@ -0,0 +1,77 @@ +@using eShop.WebAppComponents.Item +@using eShop.WebAppComponents.Catalog +@inject IProductImageUrlProvider ProductImages +@inject eShop.WebApp.Services.IFavoritesState FavoritesState +@inject NavigationManager Nav + +@implements IDisposable + +
+ + + @Item.Name + + + @Item.Name + $@Item.Price.ToString("0.00") + + + + +
+ +@code { + [Parameter, EditorRequired] + public required CatalogItem Item { get; set; } + + [Parameter] + public bool IsLoggedIn { get; set; } + + private IDisposable? favoritesSubscription; + private bool isFavorited; + + private string HeartFill => IsLoggedIn && isFavorited ? "currentColor" : "none"; + + protected override void OnInitialized() + { + isFavorited = IsLoggedIn && FavoritesState.Contains(Item.Id); + favoritesSubscription = FavoritesState.NotifyOnChange( + EventCallback.Factory.Create(this, UpdateIsFavoritedAsync)); + } + + private Task ToggleFavoriteAsync() + { + if (!IsLoggedIn) + { + Nav.NavigateTo(Pages.User.LogIn.Url(Nav)); + return Task.CompletedTask; + } + + return FavoritesState.ToggleAsync(Item); + } + + private Task UpdateIsFavoritedAsync() + { + isFavorited = IsLoggedIn && FavoritesState.Contains(Item.Id); + return InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + favoritesSubscription?.Dispose(); + } +} + diff --git a/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css new file mode 100644 index 0000000..70b396f --- /dev/null +++ b/src/WebApp/Components/Catalog/CatalogListItemWithFavorites.razor.css @@ -0,0 +1,82 @@ +.catalog-item { + flex-basis: calc(33.33% - 2.5rem); + flex-shrink: 0; + box-sizing: border-box; + padding: 2px; + position: relative; +} + +.catalog-item:hover { + cursor: pointer; + padding: 0; + border: 2px solid #000; +} + +.catalog-product { + background-color: transparent; + padding: 0; + margin: 0; + border: 0; + display: block; +} + +.catalog-product-image img { + max-width: 100%; +} + +.catalog-product .catalog-product-content { + display: flex; + padding: 0 0.75rem; + align-items: center; + gap: 0.5rem; + align-self: stretch; +} + +.catalog-product-content .name { + color: #000; + font-size: 1rem; + font-style: normal; + font-weight: 600; + line-height: 150%; + text-align: left; +} + +.catalog-product-content .price { + color: #444; + text-align: right; + font-size: 1rem; + font-style: normal; + font-weight: 600; + line-height: 150%; + margin-left: auto; +} + +.favorites-heart-button { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 2; + background: transparent; + border: 0; + padding: 0.25rem; + cursor: pointer; + color: #000; +} + +.favorites-heart-button:hover { + background: #f0efeb; + border-radius: 0.25rem; +} + +@media only screen and (max-width: 480px) { + .catalog-item { + flex-basis: calc(100% - 2rem); + } +} + +@media only screen and (min-width: 481px) and (max-width: 1024px) { + .catalog-item { + flex-basis: calc(50% - 3rem); + } +} + diff --git a/src/WebApp/Components/Layout/FavoritesMenu.razor b/src/WebApp/Components/Layout/FavoritesMenu.razor new file mode 100644 index 0000000..b4ff552 --- /dev/null +++ b/src/WebApp/Components/Layout/FavoritesMenu.razor @@ -0,0 +1,91 @@ +@using eShop.WebAppComponents.Catalog +@using eShop.WebAppComponents.Item +@inject eShop.WebApp.Services.IFavoritesState FavoritesState +@inject NavigationManager Nav + +@attribute [Microsoft.AspNetCore.Components.StreamRendering] +@implements IDisposable + +
+ + + @if (_isOpen && IsLoggedInAndHasFavorites) + { + + } +
+ +@code { + [Microsoft.AspNetCore.Components.CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private IDisposable? favoritesSubscription; + private IReadOnlyList? favorites; + private bool _isOpen; + private bool IsLoggedInAndHasFavorites => HttpContext?.User.Identity?.IsAuthenticated == true && favoritesCount > 0; + private int favoritesCount => favorites?.Count ?? 0; + private string HeartFill => IsLoggedInAndHasFavorites ? "currentColor" : "none"; + + protected override async Task OnInitializedAsync() + { + favoritesSubscription = FavoritesState.NotifyOnChange( + EventCallback.Factory.Create(this, UpdateFavoritesAsync)); + + await UpdateFavoritesAsync(); + } + + private async Task UpdateFavoritesAsync() + { + favorites = await FavoritesState.GetFavoritesAsync(); + await InvokeAsync(StateHasChanged); + } + + private void OnFavoritesClicked() + { + if (HttpContext?.User.Identity?.IsAuthenticated != true) + { + Nav.NavigateTo(Pages.User.LogIn.Url(Nav)); + return; + } + + _isOpen = !_isOpen; + } + + private Task OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + _isOpen = false; + } + + return Task.CompletedTask; + } + + public void Dispose() + { + favoritesSubscription?.Dispose(); + } +} + diff --git a/src/WebApp/Components/Layout/FavoritesMenu.razor.css b/src/WebApp/Components/Layout/FavoritesMenu.razor.css new file mode 100644 index 0000000..7f32f0f --- /dev/null +++ b/src/WebApp/Components/Layout/FavoritesMenu.razor.css @@ -0,0 +1,82 @@ +.favorites-menu { + position: relative; + display: inline-block; +} + +.favorites-button { + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: #000; + position: relative; +} + +.favorites-heart { + color: #000; +} + +.favorites-badge { + position: absolute; + right: -0.5rem; + top: -0.5rem; + border-radius: 20px; + border: 1px solid #000; + background: #000; + color: #fff; + font-size: 0.75rem; + font-style: normal; + font-weight: 400; + line-height: 0.25rem; + padding: 0.25rem; + min-width: 1.5rem; + text-align: center; +} + +.favorites-dropdown-content { + display: block; + position: absolute; + right: 0; + top: 2.8rem; + background-color: #fff; + min-width: 14rem; + box-shadow: 0 0.25rem 0.5rem 0 rgba(0, 0, 0, 0.2); + z-index: 10; +} + +.favorites-dropdown-heading { + padding: 0.75rem 1rem 0.25rem 1rem; + font-size: 1rem; + font-weight: 600; + color: #000; +} + +.favorites-dropdown-item { + padding: 0.75rem 1rem; + text-decoration: none; + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 0.75rem; + color: #000; +} + +.favorites-dropdown-item:hover { + background-color: #ddd; +} + +.favorites-dropdown-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.favorites-dropdown-item-price { + color: #444; + font-weight: 600; + flex-shrink: 0; +} + diff --git a/src/WebApp/Components/Layout/HeaderBar.razor b/src/WebApp/Components/Layout/HeaderBar.razor index 9f8d1ac..f337dae 100644 --- a/src/WebApp/Components/Layout/HeaderBar.razor +++ b/src/WebApp/Components/Layout/HeaderBar.razor @@ -14,6 +14,7 @@ +
diff --git a/src/WebApp/Components/Pages/Catalog/Catalog.razor b/src/WebApp/Components/Pages/Catalog/Catalog.razor index 04df7dd..4e4f1f0 100644 --- a/src/WebApp/Components/Pages/Catalog/Catalog.razor +++ b/src/WebApp/Components/Pages/Catalog/Catalog.razor @@ -20,7 +20,7 @@
@foreach (var item in catalogResult.Data) { - + }
@@ -37,6 +37,9 @@ @code { const int PageSize = 9; + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + [SupplyParameterFromQuery] public int? Page { get; set; } @@ -47,6 +50,7 @@ public int? ItemTypeId { get; set; } CatalogResult? catalogResult; + private bool isLoggedIn => HttpContext?.User.Identity?.IsAuthenticated == true; static IEnumerable GetVisiblePageIndexes(CatalogResult result) => Enumerable.Range(1, (int)Math.Ceiling(1.0 * result.Count / PageSize)); diff --git a/src/WebApp/Components/Pages/Item/ItemPage.razor b/src/WebApp/Components/Pages/Item/ItemPage.razor index f61a11b..794b9a4 100644 --- a/src/WebApp/Components/Pages/Item/ItemPage.razor +++ b/src/WebApp/Components/Pages/Item/ItemPage.razor @@ -1,7 +1,9 @@ @page "/item/{itemId:int}" @using System.Net +@implements IDisposable @inject CatalogService CatalogService @inject BasketState BasketState +@inject eShop.WebApp.Services.IFavoritesState FavoritesState @inject NavigationManager Nav @inject IProductImageUrlProvider ProductImages @@ -18,32 +20,51 @@

Brand: @item.CatalogBrand?.Brand

-
- - $@item.Price.ToString("0.00") - - @if (isLoggedIn) - { - - } - else - { - + } + else + { + + } + + +
+ + - } - + +
@if (numInCart > 0) { @@ -64,8 +85,12 @@ else if (notFound) private CatalogItem? item; private int numInCart; private bool isLoggedIn; + private bool isFavorited; private bool notFound; + private IDisposable? favoritesSubscription; + private string HeartFill => isLoggedIn && isFavorited ? "currentColor" : "none"; + [Parameter] public int ItemId { get; set; } @@ -78,6 +103,11 @@ else if (notFound) { isLoggedIn = HttpContext?.User.Identity?.IsAuthenticated == true; item = await CatalogService.GetCatalogItem(ItemId); + + favoritesSubscription = FavoritesState.NotifyOnChange( + EventCallback.Factory.Create(this, UpdateIsFavoritedAsync)); + + isFavorited = isLoggedIn && FavoritesState.Contains(ItemId); await UpdateNumInCartAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) @@ -102,9 +132,35 @@ else if (notFound) } } + private async Task ToggleFavoriteAsync() + { + if (!isLoggedIn) + { + Nav.NavigateTo(Pages.User.LogIn.Url(Nav)); + return; + } + + if (item is not null) + { + await FavoritesState.ToggleAsync(item); + isFavorited = FavoritesState.Contains(ItemId); + } + } + + private Task UpdateIsFavoritedAsync() + { + isFavorited = isLoggedIn && FavoritesState.Contains(ItemId); + return InvokeAsync(StateHasChanged); + } + private async Task UpdateNumInCartAsync() { var items = await BasketState.GetBasketItemsAsync(); numInCart = items.FirstOrDefault(row => row.ProductId == ItemId)?.Quantity ?? 0; } + + public void Dispose() + { + favoritesSubscription?.Dispose(); + } } diff --git a/src/WebApp/Components/Pages/Item/ItemPage.razor.css b/src/WebApp/Components/Pages/Item/ItemPage.razor.css index c21fa93..289a5ac 100644 --- a/src/WebApp/Components/Pages/Item/ItemPage.razor.css +++ b/src/WebApp/Components/Pages/Item/ItemPage.razor.css @@ -25,6 +25,34 @@ img { gap: 1.2rem; } +.purchase-actions { + display: flex; + align-items: center; + gap: 1.2rem; + flex-wrap: wrap; +} + +.favorites-toggle { + display: flex; + align-items: center; +} + +.favorites-toggle-button { + background: transparent; + border: 1px solid #000; + border-radius: .25rem; + padding: 0.5rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: #000; +} + +.favorites-toggle-button:hover { + background: #f2f1ed; +} + .price { font-size: 1.6rem; font-weight: 600; diff --git a/src/WebApp/Components/_Imports.razor b/src/WebApp/Components/_Imports.razor index c6b8017..3694570 100644 --- a/src/WebApp/Components/_Imports.razor +++ b/src/WebApp/Components/_Imports.razor @@ -10,6 +10,7 @@ @using Microsoft.JSInterop @using eShop.WebApp @using eShop.WebApp.Components +@using eShop.WebApp.Components.Catalog @using eShop.WebApp.Services @using eShop.WebAppComponents.Catalog @using eShop.WebAppComponents.Services \ No newline at end of file diff --git a/src/WebApp/Extensions/Extensions.cs b/src/WebApp/Extensions/Extensions.cs index e44c571..4491c75 100644 --- a/src/WebApp/Extensions/Extensions.cs +++ b/src/WebApp/Extensions/Extensions.cs @@ -21,6 +21,7 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) // Application services builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/WebApp/Services/FavoritesState.cs b/src/WebApp/Services/FavoritesState.cs new file mode 100644 index 0000000..e0763f2 --- /dev/null +++ b/src/WebApp/Services/FavoritesState.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using eShop.WebAppComponents.Catalog; +using eShop.WebAppComponents.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace eShop.WebApp.Services; + +public class FavoritesState( + ICatalogService catalogService, + AuthenticationStateProvider authenticationStateProvider) : IFavoritesState +{ + private readonly HashSet _favoriteProductIds = []; + private readonly List _orderedFavoriteProductIds = []; + + private IReadOnlyList? _cachedFavorites; + private readonly HashSet _changeSubscriptions = []; + + public bool Contains(int productId) => _favoriteProductIds.Contains(productId); + + public async Task> GetFavoritesAsync() + { + if (!await IsAuthenticatedAsync()) + { + return []; + } + + return _cachedFavorites ??= await FetchFavoritesCoreAsync(); + } + + public async Task ToggleAsync(CatalogItem item) + { + if (!await IsAuthenticatedAsync()) + { + return; + } + + var productId = item.Id; + + if (_favoriteProductIds.Contains(productId)) + { + _favoriteProductIds.Remove(productId); + _orderedFavoriteProductIds.Remove(productId); + } + else + { + _favoriteProductIds.Add(productId); + _orderedFavoriteProductIds.Add(productId); + } + + _cachedFavorites = null; + await NotifyChangeSubscribersAsync(); + } + + public IDisposable NotifyOnChange(EventCallback callback) + { + var subscription = new FavoritesStateChangedSubscription(this, callback); + _changeSubscriptions.Add(subscription); + return subscription; + } + + private async Task IsAuthenticatedAsync() + { + var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); + return authState.User.Identity?.IsAuthenticated == true; + } + + private async Task> FetchFavoritesCoreAsync() + { + if (_orderedFavoriteProductIds.Count == 0) + { + return []; + } + + var ids = _orderedFavoriteProductIds; + var items = await catalogService.GetCatalogItems(ids); + var itemsById = items.ToDictionary(i => i.Id, i => i); + + // Preserve favorites insertion order for nicer UX. + return ids.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToList(); + } + + private Task NotifyChangeSubscribersAsync() + => Task.WhenAll(_changeSubscriptions.Select(s => s.NotifyAsync())); + + private sealed class FavoritesStateChangedSubscription(FavoritesState owner, EventCallback callback) : IDisposable + { + public Task NotifyAsync() => callback.InvokeAsync(); + + public void Dispose() => owner._changeSubscriptions.Remove(this); + } +} + diff --git a/src/WebApp/Services/IFavoritesState.cs b/src/WebApp/Services/IFavoritesState.cs new file mode 100644 index 0000000..04a7666 --- /dev/null +++ b/src/WebApp/Services/IFavoritesState.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using eShop.WebAppComponents.Catalog; +using Microsoft.AspNetCore.Components; + +namespace eShop.WebApp.Services; + +public interface IFavoritesState +{ + Task> GetFavoritesAsync(); + Task ToggleAsync(CatalogItem item); + bool Contains(int productId); + IDisposable NotifyOnChange(EventCallback callback); +} +