diff --git a/samples/aspire-shop/AspireShop.CatalogDb/CatalogDbContext.cs b/samples/aspire-shop/AspireShop.CatalogDb/CatalogDbContext.cs index 418708dfd..6d5871547 100644 --- a/samples/aspire-shop/AspireShop.CatalogDb/CatalogDbContext.cs +++ b/samples/aspire-shop/AspireShop.CatalogDb/CatalogDbContext.cs @@ -87,6 +87,10 @@ private static void DefineCatalogItem(EntityTypeBuilder builder) builder.Property(ci => ci.PictureFileName) .IsRequired(false); + builder.Property(ci => ci.Badge) + .IsRequired(false) + .HasMaxLength(30); + builder.Ignore(ci => ci.PictureUri); builder.HasOne(ci => ci.CatalogBrand) diff --git a/samples/aspire-shop/AspireShop.CatalogDb/CatalogItem.cs b/samples/aspire-shop/AspireShop.CatalogDb/CatalogItem.cs index 2cf1f5bad..7cd749d68 100644 --- a/samples/aspire-shop/AspireShop.CatalogDb/CatalogItem.cs +++ b/samples/aspire-shop/AspireShop.CatalogDb/CatalogItem.cs @@ -9,6 +9,11 @@ public class CatalogItem public required string PictureFileName { get; set; } public string? PictureUri { get; set; } + /// + /// Optional merchandising label shown on the product (e.g. "New arrival", "Special"). + /// + public string? Badge { get; set; } + public int CatalogTypeId { get; set; } public required CatalogType CatalogType { get; set; } diff --git a/samples/aspire-shop/AspireShop.CatalogDbManager/CatalogDbInitializer.cs b/samples/aspire-shop/AspireShop.CatalogDbManager/CatalogDbInitializer.cs index 020b6e23c..7d06640a0 100644 --- a/samples/aspire-shop/AspireShop.CatalogDbManager/CatalogDbInitializer.cs +++ b/samples/aspire-shop/AspireShop.CatalogDbManager/CatalogDbInitializer.cs @@ -39,11 +39,7 @@ private async Task SeedAsync(CatalogDbContext dbContext, CancellationToken cance static List GetPreconfiguredCatalogBrands() { return [ - new() { Brand = "Azure" }, - new() { Brand = ".NET" }, - new() { Brand = "Visual Studio" }, - new() { Brand = "SQL Server" }, - new() { Brand = "Other" } + new() { Brand = "Aspire" } ]; } @@ -52,33 +48,42 @@ static List GetPreconfiguredCatalogTypes() return [ new() { Type = "Mug" }, new() { Type = "T-Shirt" }, - new() { Type = "Sheet" }, - new() { Type = "USB Memory Stick" } + new() { Type = "Hoodie" }, + new() { Type = "Jacket" }, + new() { Type = "Skateboard" }, + new() { Type = "Pin Badge" }, + new() { Type = "Sticker" } ]; } static List GetPreconfiguredItems(DbSet catalogBrands, DbSet catalogTypes) { - var dotNet = catalogBrands.First(b => b.Brand == ".NET"); - var other = catalogBrands.First(b => b.Brand == "Other"); + var aspire = catalogBrands.First(b => b.Brand == "Aspire"); var mug = catalogTypes.First(c => c.Type == "Mug"); var tshirt = catalogTypes.First(c => c.Type == "T-Shirt"); - var sheet = catalogTypes.First(c => c.Type == "Sheet"); + var hoodie = catalogTypes.First(c => c.Type == "Hoodie"); + var jacket = catalogTypes.First(c => c.Type == "Jacket"); + var skateboard = catalogTypes.First(c => c.Type == "Skateboard"); + var pinBadge = catalogTypes.First(c => c.Type == "Pin Badge"); + var sticker = catalogTypes.First(c => c.Type == "Sticker"); return [ - new() { CatalogType = tshirt, CatalogBrand = dotNet, AvailableStock = 100, Description = ".NET Bot Black Hoodie", Name = ".NET Bot Black Hoodie", Price = 19.5M, PictureFileName = "1.png" }, - new() { CatalogType = mug, CatalogBrand = dotNet, AvailableStock = 100, Description = ".NET Black & White Mug", Name = ".NET Black & White Mug", Price= 8.50M, PictureFileName = "2.png" }, - new() { CatalogType = tshirt, CatalogBrand = other, AvailableStock = 100, Description = "Prism White T-Shirt", Name = "Prism White T-Shirt", Price = 12, PictureFileName = "3.png" }, - new() { CatalogType = tshirt, CatalogBrand = dotNet, AvailableStock = 100, Description = ".NET Foundation T-shirt", Name = ".NET Foundation T-shirt", Price = 12, PictureFileName = "4.png" }, - new() { CatalogType = sheet, CatalogBrand = other, AvailableStock = 100, Description = "Roslyn Red Sheet", Name = "Roslyn Red Sheet", Price = 8.5M, PictureFileName = "5.png" }, - new() { CatalogType = tshirt, CatalogBrand = dotNet, AvailableStock = 100, Description = ".NET Blue Hoodie", Name = ".NET Blue Hoodie", Price = 12, PictureFileName = "6.png" }, - new() { CatalogType = tshirt, CatalogBrand = other, AvailableStock = 100, Description = "Roslyn Red T-Shirt", Name = "Roslyn Red T-Shirt", Price = 12, PictureFileName = "7.png" }, - new() { CatalogType = tshirt, CatalogBrand = other, AvailableStock = 100, Description = "Kudu Purple Hoodie", Name = "Kudu Purple Hoodie", Price = 8.5M, PictureFileName = "8.png" }, - new() { CatalogType = mug, CatalogBrand = other, AvailableStock = 100, Description = "Cup White Mug", Name = "Cup White Mug", Price = 12, PictureFileName = "9.png" }, - new() { CatalogType = sheet, CatalogBrand = dotNet, AvailableStock = 100, Description = ".NET Foundation Sheet", Name = ".NET Foundation Sheet", Price = 12, PictureFileName = "10.png" }, - new() { CatalogType = sheet, CatalogBrand = dotNet, AvailableStock = 100, Description = "Cup Sheet", Name = "Cup Sheet", Price = 8.5M, PictureFileName = "11.png" }, - new() { CatalogType = tshirt, CatalogBrand = other, AvailableStock = 100, Description = "Prism White TShirt", Name = "Prism White TShirt", Price = 12, PictureFileName = "12.png" } + new() { CatalogType = hoodie, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Charcoal Hoodie", Description = "A cozy charcoal pullover hoodie with the layered Aspire logo centered on the chest.", Price = 44.00M, PictureFileName = "1.png" }, + new() { CatalogType = mug, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Logo Mug", Description = "A glossy white ceramic mug featuring the layered Aspire logo.", Price = 12.00M, PictureFileName = "2.png" }, + new() { CatalogType = tshirt, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire.dev Logo T-Shirt", Description = "A white cotton tee with a left-chest Aspire logo and the aspire.dev wordmark.", Price = 24.00M, PictureFileName = "3.png" }, + new() { CatalogType = tshirt, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire OG T-Shirt", Description = "A lavender tee with an \"OG\" graphic and a mosaic band of Aspire logo tiles \u2014 a nod to the aspire.dev og:image social card.", Price = 26.00M, PictureFileName = "4.png", Badge = "og:image" }, + new() { CatalogType = sticker, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Logo Sticker", Description = "A glossy die-cut vinyl sticker of the layered Aspire logo.", Price = 3.50M, PictureFileName = "5.png" }, + new() { CatalogType = hoodie, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire All-Over Print Hoodie", Description = "A sky-blue pullover hoodie with an all-over print of repeating Aspire logos.", Price = 52.00M, PictureFileName = "6.png", Badge = "New arrival" }, + new() { CatalogType = tshirt, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Red Logo T-Shirt", Description = "A bold red cotton tee with a large, centered Aspire logo.", Price = 22.00M, PictureFileName = "7.png" }, + new() { CatalogType = hoodie, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire.dev Zip Hoodie", Description = "A periwinkle full-zip hoodie with a chest Aspire logo and aspire.dev down the sleeve.", Price = 49.50M, PictureFileName = "8.png" }, + new() { CatalogType = mug, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire \"Say Bye to YAML\" Mug", Description = "A white ceramic mug with the Aspire logo and a playful \"Say bye to YAML\" message.", Price = 14.00M, PictureFileName = "9.png" }, + new() { CatalogType = sticker, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Logo Sticker Pack", Description = "A pack of glossy die-cut stickers featuring the layered Aspire logo.", Price = 9.00M, PictureFileName = "10.png" }, + new() { CatalogType = pinBadge, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire \"No YAML\" Pin Badge", Description = "A round pin-back button badge with the Aspire logo and a \"No YAML\" callout.", Price = 5.00M, PictureFileName = "11.png" }, + new() { CatalogType = tshirt, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire \"Goodbye YAML\" T-Shirt", Description = "A white tee with the Aspire logo and a \"Goodbye, YAML \u2014 Hello, Aspire\" slogan.", Price = 24.00M, PictureFileName = "12.png" }, + new() { CatalogType = tshirt, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire.dev Ringer T-Shirt", Description = "A white ringer tee with lavender trim, the Aspire logo, and a \"Goodbye, YAML \u2014 Hello, aspire.dev\" slogan.", Price = 26.00M, PictureFileName = "13.png" }, + new() { CatalogType = jacket, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire.dev Softshell Jacket", Description = "A tan softshell jacket with a chest Aspire logo and aspire.dev down the sleeve.", Price = 79.00M, PictureFileName = "14.png", Badge = "New arrival" }, + new() { CatalogType = skateboard, CatalogBrand = aspire, AvailableStock = 100, Name = "Aspire Skateboard Deck", Description = "A white maple skateboard deck with the Aspire logo, wordmark, and aspire.dev branding.", Price = 65.00M, PictureFileName = "15.png", Badge = "Special" } ]; } diff --git a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.Designer.cs b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.Designer.cs similarity index 94% rename from samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.Designer.cs rename to samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.Designer.cs index 2d006f707..0177c1e19 100644 --- a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.Designer.cs +++ b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.Designer.cs @@ -11,7 +11,7 @@ namespace AspireShop.CatalogDbManager.Migrations { [DbContext(typeof(CatalogDbContext))] - [Migration("20241028194903_Initial")] + [Migration("20260709111930_Initial")] partial class Initial { /// @@ -19,7 +19,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -62,6 +62,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("AvailableStock") .HasColumnType("integer"); + b.Property("Badge") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + b.Property("CatalogBrandId") .HasColumnType("integer"); diff --git a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.cs b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.cs similarity index 97% rename from samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.cs rename to samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.cs index 222e54e8e..6d0d4038d 100644 --- a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20241028194903_Initial.cs +++ b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/20260709111930_Initial.cs @@ -55,6 +55,7 @@ protected override void Up(MigrationBuilder migrationBuilder) Description = table.Column(type: "text", nullable: true), Price = table.Column(type: "numeric", nullable: false), PictureFileName = table.Column(type: "text", nullable: true), + Badge = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), CatalogTypeId = table.Column(type: "integer", nullable: false), CatalogBrandId = table.Column(type: "integer", nullable: false), AvailableStock = table.Column(type: "integer", nullable: false), diff --git a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/CatalogDbContextModelSnapshot.cs b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/CatalogDbContextModelSnapshot.cs index c91bb4d61..b85982bd3 100644 --- a/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/CatalogDbContextModelSnapshot.cs +++ b/samples/aspire-shop/AspireShop.CatalogDbManager/Migrations/CatalogDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -59,6 +59,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AvailableStock") .HasColumnType("integer"); + b.Property("Badge") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + b.Property("CatalogBrandId") .HasColumnType("integer"); diff --git a/samples/aspire-shop/AspireShop.CatalogService/AspireShop.CatalogService.csproj b/samples/aspire-shop/AspireShop.CatalogService/AspireShop.CatalogService.csproj index 10a96a560..f0407d3f8 100644 --- a/samples/aspire-shop/AspireShop.CatalogService/AspireShop.CatalogService.csproj +++ b/samples/aspire-shop/AspireShop.CatalogService/AspireShop.CatalogService.csproj @@ -13,6 +13,7 @@ + diff --git a/samples/aspire-shop/AspireShop.CatalogService/CatalogApi.cs b/samples/aspire-shop/AspireShop.CatalogService/CatalogApi.cs index e70794857..c211a9794 100644 --- a/samples/aspire-shop/AspireShop.CatalogService/CatalogApi.cs +++ b/samples/aspire-shop/AspireShop.CatalogService/CatalogApi.cs @@ -88,6 +88,7 @@ static async Task GetCatalogItems(int? catalogBrandId, CatalogDbContext item.Description, item.Price, item.AvailableStock, + item.Badge, item.CatalogBrand is { } brand ? new CatalogBrandDto(brand.Id, brand.Brand) : null, item.CatalogType is { } type ? new CatalogTypeDto(type.Id, type.Type) : null); } @@ -100,6 +101,7 @@ public record CatalogItemDto( string? Description, decimal Price, int AvailableStock, + string? Badge, CatalogBrandDto? CatalogBrand, CatalogTypeDto? CatalogType); diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/1.png b/samples/aspire-shop/AspireShop.CatalogService/Images/1.png index ee1e52331..83a5b2a99 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/1.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/1.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/10.png b/samples/aspire-shop/AspireShop.CatalogService/Images/10.png index 8d4660c7b..2b7a7fc17 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/10.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/10.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/11.png b/samples/aspire-shop/AspireShop.CatalogService/Images/11.png index a9f8d6592..6338612de 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/11.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/11.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/12.png b/samples/aspire-shop/AspireShop.CatalogService/Images/12.png index 9a083b985..1ec8bab36 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/12.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/12.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/13.png b/samples/aspire-shop/AspireShop.CatalogService/Images/13.png index 7ae7f60b0..e36d7993e 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/13.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/13.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/14.png b/samples/aspire-shop/AspireShop.CatalogService/Images/14.png index f81166783..fa6c3c5dc 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/14.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/14.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/15.png b/samples/aspire-shop/AspireShop.CatalogService/Images/15.png new file mode 100644 index 000000000..241ac39ca Binary files /dev/null and b/samples/aspire-shop/AspireShop.CatalogService/Images/15.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/2.png b/samples/aspire-shop/AspireShop.CatalogService/Images/2.png index 40b2a9e28..9c3133467 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/2.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/2.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/3.png b/samples/aspire-shop/AspireShop.CatalogService/Images/3.png index c5f68436e..47da3d35b 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/3.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/3.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/4.png b/samples/aspire-shop/AspireShop.CatalogService/Images/4.png index 2002cd716..d3b6e2ad2 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/4.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/4.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/5.png b/samples/aspire-shop/AspireShop.CatalogService/Images/5.png index bae3dab35..0bb481847 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/5.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/5.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/6.png b/samples/aspire-shop/AspireShop.CatalogService/Images/6.png index 3960c1e7d..221fe7a23 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/6.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/6.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/7.png b/samples/aspire-shop/AspireShop.CatalogService/Images/7.png index 2c0a89e30..cd1abea48 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/7.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/7.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/8.png b/samples/aspire-shop/AspireShop.CatalogService/Images/8.png index fede0db7f..b7a08edb4 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/8.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/8.png differ diff --git a/samples/aspire-shop/AspireShop.CatalogService/Images/9.png b/samples/aspire-shop/AspireShop.CatalogService/Images/9.png index 417ec0f28..58fe14e42 100644 Binary files a/samples/aspire-shop/AspireShop.CatalogService/Images/9.png and b/samples/aspire-shop/AspireShop.CatalogService/Images/9.png differ diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/AddToCart.razor b/samples/aspire-shop/AspireShop.Frontend/Components/AddToCart.razor index 9e4442d75..a56709a1d 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/AddToCart.razor +++ b/samples/aspire-shop/AspireShop.Frontend/Components/AddToCart.razor @@ -1,9 +1,9 @@ @inject BasketServiceClient BasketClient @inject NavigationManager Navigation - + - @@ -14,14 +14,29 @@ [EditorRequired] public required CatalogItem Item { get; set; } + // Discriminator so the same item can render more than one add-to-cart form on a single page + // (e.g. the product card and the quick-look modal) without two enhanced forms sharing a name. + [Parameter] + public string? Context { get; set; } + + [Parameter] + public string ButtonClass { get; set; } = "add-button"; + [SupplyParameterFromForm] public int ItemId { get; set; } + private string FormName => string.IsNullOrEmpty(Context) + ? $"addtocart-{Item.Id}" + : $"addtocart-{Context}-{Item.Id}"; + private async Task HandleAddToCart() { await BasketClient.AddToCartAsync("user", ItemId); - // Preserve query string - Navigation.NavigateTo($"/{new Uri(Navigation.Uri).Query}"); + // Re-request the current page (preserving the pagination query) so a freshly initialized + // component tree reflects the updated basket — e.g. the cart count in the top bar. The + // form is marked data-preserve-scroll so site.js keeps the scroll position across this + // enhanced refresh, avoiding a jarring jump to the top. + Navigation.Refresh(); } } diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/App.razor b/samples/aspire-shop/AspireShop.Frontend/Components/App.razor index 7566a3020..7c867f4a3 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/App.razor +++ b/samples/aspire-shop/AspireShop.Frontend/Components/App.razor @@ -10,6 +10,17 @@ + + diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/Icons.cs b/samples/aspire-shop/AspireShop.Frontend/Components/Icons.cs index b7b782da1..ac13e91b7 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/Icons.cs +++ b/samples/aspire-shop/AspireShop.Frontend/Components/Icons.cs @@ -38,6 +38,15 @@ public static class Icons public static readonly MarkupString GitHub = Svg( "M208.31,75.68A59.78,59.78,0,0,0,202.93,28,8,8,0,0,0,196,24a59.75,59.75,0,0,0-48,24H124A59.75,59.75,0,0,0,76,24a8,8,0,0,0-6.93,4,59.78,59.78,0,0,0-5.38,47.68A58.14,58.14,0,0,0,56,104v8a56.06,56.06,0,0,0,48.44,55.47A39.8,39.8,0,0,0,96,192v8H72a24,24,0,0,1-24-24A40,40,0,0,0,8,136a8,8,0,0,0,0,16,24,24,0,0,1,24,24,40,40,0,0,0,40,40H96v16a8,8,0,0,0,16,0V192a24,24,0,0,1,48,0v40a8,8,0,0,0,16,0V192a39.8,39.8,0,0,0-8.44-24.53A56.06,56.06,0,0,0,216,112v-8A58.14,58.14,0,0,0,208.31,75.68ZM200,112a40,40,0,0,1-40,40H112a40,40,0,0,1-40-40v-8a41.74,41.74,0,0,1,6.9-22.48A8,8,0,0,0,80,73.83a43.81,43.81,0,0,1,.79-33.58,43.88,43.88,0,0,1,32.32,20.06A8,8,0,0,0,119.82,64h32.35a8,8,0,0,0,6.74-3.69,43.87,43.87,0,0,1,32.32-20.06A43.81,43.81,0,0,1,192,73.83a8.09,8.09,0,0,0,1,7.65A41.72,41.72,0,0,1,200,104Z"); + public static readonly MarkupString Close = Svg( + "M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"); + + public static readonly MarkupString Sun = Svg( + "M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"); + + public static readonly MarkupString Moon = Svg( + "M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"); + private static MarkupString Svg(string path) => new( $""); } diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/Layout/AppFooter.razor b/samples/aspire-shop/AspireShop.Frontend/Components/Layout/AppFooter.razor index 378a9bac8..8758c66fb 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/Layout/AppFooter.razor +++ b/samples/aspire-shop/AspireShop.Frontend/Components/Layout/AppFooter.razor @@ -6,7 +6,9 @@ © @DateTime.Today.Year · MIT Licensed - @Message + + @Message + diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Cart.razor b/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Cart.razor index f4f8b0cf3..ae4a443d5 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Cart.razor +++ b/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Cart.razor @@ -1,5 +1,4 @@ @page "/cart" -@attribute [StreamRendering(true)] @inject BasketServiceClient BasketClient @inject CatalogServiceClient CatalogService @@ -17,10 +16,13 @@

Review the items the Basket service is holding for you

- - - Continue shopping - + @@ -99,7 +101,7 @@
@if (line.Item is not null) { - +
- + @@ -229,19 +231,26 @@ break; } - Navigation.NavigateTo("/cart"); + // Re-request the page so a freshly initialized component tree reflects the updated basket. + // The forms are marked data-preserve-scroll so site.js keeps the scroll position across the + // enhanced refresh. + Navigation.Refresh(); } private async Task HandleCheckout() { await BasketClient.CheckoutBasketAsync("user"); + + // Navigate so the success state is reflected in the URL (and survives a refresh). Navigation.NavigateTo("/cart?checkout=success"); } private async Task HandleClear() { await BasketClient.DeleteBasketAsync("user"); - Navigation.NavigateTo("/cart"); + + // Re-request the page so the empty-cart state renders from a fresh basket load. + Navigation.Refresh(); } private record CartLine(int ProductId, int Quantity, CatalogItem? Item); diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Home.razor b/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Home.razor index f07cd82ee..dc7c7abb9 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Home.razor +++ b/samples/aspire-shop/AspireShop.Frontend/Components/Pages/Home.razor @@ -1,5 +1,4 @@ @page "/" -@attribute [StreamRendering(true)] @inject CatalogServiceClient CatalogService @@ -15,7 +14,10 @@

Browse the product catalog and add items to your cart

- +
+ + +
@@ -32,13 +34,20 @@ @foreach (var item in data) {
- - @item.Name - - - Quick look - - +
+ + @if (item.Badge is { Length: > 0 } badge) + { + @badge + } +
@if (item.CatalogBrand is { Brand: var brand }) { @@ -69,6 +78,54 @@ }
+ + +
+
+ @item.Name + @if (item.Badge is { Length: > 0 } modalBadge) + { + @modalBadge + } + + + + View full image + + +
+
+ + @if (item.CatalogBrand is { Brand: var modalBrand }) + { +

@modalBrand

+ } +

@item.Name

+ @if (item.CatalogType is { Type: var modalType }) + { +

@modalType

+ } + @if (!string.IsNullOrWhiteSpace(item.Description)) + { +

@item.Description

+ } +
+ @item.Price.ToString("C") + @if (basketIsAvailable && item.AvailableStock > 0) + { + + } + else if (item.AvailableStock <= 0) + { + Out of stock + } +
+
+
+
} @@ -146,5 +203,13 @@ basketIsAvailable = isAvailable; } + private static string BadgeClass(string badge) => badge switch + { + "Special" => "product-badge--special", + "New arrival" => "product-badge--new", + "og:image" => "product-badge--og", + _ => "" + }; + record PaginationInfo(int FirstId, int NextId, bool HasPreviousPage, bool HasNextPage); } diff --git a/samples/aspire-shop/AspireShop.Frontend/Components/ThemeToggle.razor b/samples/aspire-shop/AspireShop.Frontend/Components/ThemeToggle.razor new file mode 100644 index 000000000..f8d11288e --- /dev/null +++ b/samples/aspire-shop/AspireShop.Frontend/Components/ThemeToggle.razor @@ -0,0 +1,9 @@ +@* Light/dark theme toggle. Theme state lives on and is persisted to + localStorage by site.js; CSS swaps the visible icon based on the effective theme, so this + works even before the script runs and stays correct across enhanced navigation. *@ + + diff --git a/samples/aspire-shop/AspireShop.Frontend/Services/CatalogServiceClient.cs b/samples/aspire-shop/AspireShop.Frontend/Services/CatalogServiceClient.cs index 93e5e1a44..9f8d01279 100644 --- a/samples/aspire-shop/AspireShop.Frontend/Services/CatalogServiceClient.cs +++ b/samples/aspire-shop/AspireShop.Frontend/Services/CatalogServiceClient.cs @@ -45,6 +45,7 @@ public record CatalogItem public string? Description { get; init; } public decimal Price { get; init; } public int AvailableStock { get; init; } + public string? Badge { get; init; } public CatalogBrandRef? CatalogBrand { get; init; } public CatalogTypeRef? CatalogType { get; init; } } diff --git a/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.css b/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.css index 2630f35b8..127d345a2 100644 --- a/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.css +++ b/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.css @@ -35,7 +35,7 @@ } @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme]) { --bg: #15110d; --bg-tint: radial-gradient(120% 80% at 80% -10%, #241a12 0%, rgba(36, 26, 18, 0) 55%); --surface: #1f1811; @@ -59,6 +59,36 @@ } } +/* Manual theme override (set on by the toggle). When present, it wins over + the OS preference above so the choice sticks regardless of system settings. */ +:root[data-theme="light"] { + color-scheme: light; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #15110d; + --bg-tint: radial-gradient(120% 80% at 80% -10%, #241a12 0%, rgba(36, 26, 18, 0) 55%); + --surface: #1f1811; + --surface-2: #271e16; + --ink: #f1e8d9; + --ink-soft: #cdbfa9; + --muted: #a3947f; + --line: #352a1f; + --line-strong: #4a3a2a; + --accent: #ec9c81; + --accent-strong: #f0a98f; + --accent-hover: #f6c2ab; + --accent-contrast: #20120c; + --accent-soft: #2e2017; + --price: #93c8a4; + --price-strong: #93c8a4; + --brass: #ceae6c; + --focus: #f0a98f; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); + --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 24px 60px rgba(0, 0, 0, .45); +} + * { box-sizing: border-box; } @@ -70,6 +100,9 @@ html { body { margin: 0; min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; font-family: var(--font-body); color: var(--ink); background: var(--bg-tint), var(--bg); @@ -288,11 +321,83 @@ h1:focus-visible { 100% { transform: scale(1); opacity: 1; } } +/* --- Top bar actions (theme toggle + cart) ------------------------------ */ +.topbar-actions { + display: inline-flex; + align-items: center; + gap: .6rem; +} + +/* The brand tagline's margin-right:auto pushes the actions to the right on wide screens. + On narrow screens the tagline is hidden, so the wrapper claims the space itself. */ +.topbar-actions .cart { + margin-left: 0; +} + +/* --- Theme toggle -------------------------------------------------------- */ +.theme-toggle { + width: 2.6rem; + height: 2.6rem; + display: inline-grid; + place-items: center; + padding: 0; + border-radius: 50%; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: transform .2s var(--ease), box-shadow .2s var(--ease), + border-color .2s var(--ease), background .2s var(--ease), color .2s var(--ease); +} + +.theme-toggle:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--accent); + color: var(--accent-strong); +} + +.theme-toggle:active { + transform: translateY(0); +} + +.theme-toggle-icon { + display: none; + line-height: 0; +} + +.theme-toggle-icon .ph { + width: 1.25rem; + height: 1.25rem; +} + +/* Show the icon for the action the user can take: a moon in light mode (switch to dark), + a sun in dark mode (switch to light). Effective theme = explicit override, else OS. */ +:root[data-theme="light"] .theme-toggle-icon--moon, +:root[data-theme="dark"] .theme-toggle-icon--sun { + display: inline-flex; +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) .theme-toggle-icon--moon { + display: inline-flex; + } +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .theme-toggle-icon--sun { + display: inline-flex; + } +} + /* --- Shell / collection head -------------------------------------------- */ .shell { max-width: 1240px; + width: 100%; margin: 0 auto; padding: 2.75rem 1.5rem 4rem; + flex: 1 0 auto; } .collection-head { @@ -327,7 +432,6 @@ h1:focus-visible { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1.5rem; - view-transition-name: catalog-grid; } .product { @@ -355,6 +459,20 @@ h1:focus-visible { overflow: hidden; } +.product-media-btn { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + background: none; + cursor: pointer; + color: inherit; + font: inherit; +} + .product-media img { width: 100%; height: 100%; @@ -392,11 +510,56 @@ h1:focus-visible { } .product-media:hover .product-zoom, -.product-media:focus-visible .product-zoom { +.product-media-btn:focus-visible .product-zoom { opacity: 1; transform: translateY(0); } +/* --- Product badge (corner pill) ---------------------------------------- */ +.product-badge { + position: absolute; + top: .75rem; + left: .75rem; + z-index: 2; + display: inline-flex; + align-items: center; + padding: .3rem .65rem; + border-radius: 999px; + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + line-height: 1; + pointer-events: none; + background: color-mix(in srgb, var(--ink) 86%, transparent); + color: var(--bg); + border: 1px solid transparent; + box-shadow: var(--shadow-sm); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} + +.product-badge--new { + background: color-mix(in srgb, var(--price) 90%, black 6%); + color: var(--accent-contrast); +} + +.product-badge--special { + background: color-mix(in srgb, var(--accent-strong) 92%, black 4%); + color: var(--accent-contrast); +} + +.product-badge--og { + background: color-mix(in srgb, var(--brass) 90%, black 6%); + color: var(--accent-contrast); +} + +/* Inside the quick-look modal the badge sits statically above the meta. */ +.quickview-media .product-badge { + top: 1rem; + left: 1rem; +} + .product-body { display: flex; flex-direction: column; @@ -457,6 +620,14 @@ h1:focus-visible { transform: translateY(0) scale(.97); } +/* Full-width, slightly larger add-to-cart used inside the quick-look modal. */ +.add-button--block { + width: 100%; + justify-content: center; + padding: .72rem 1.1rem; + font-size: .95rem; +} + .add-icon { font-size: 1.05rem; display: inline-flex; @@ -710,27 +881,6 @@ h1:focus-visible { line-height: 1; } -/* --- View transitions (progressive enhancement) ------------------------- */ -@view-transition { - navigation: auto; -} - -::view-transition-old(catalog-grid) { - animation: vt-fade-out .25s var(--ease) both; -} - -::view-transition-new(catalog-grid) { - animation: vt-fade-in .35s var(--ease) both; -} - -@keyframes vt-fade-out { - to { opacity: 0; transform: translateY(-6px); } -} - -@keyframes vt-fade-in { - from { opacity: 0; transform: translateY(10px); } -} - /* --- Product card enrichment -------------------------------------------- */ .product-brand { margin: 0; @@ -1184,12 +1334,240 @@ h1:focus-visible { font-size: .92rem; } +/* --- Quick-look modal --------------------------------------------------- */ +.quickview { + width: min(1340px, 94vw); + max-width: 94vw; + max-height: 92vh; + padding: 0; + border: none; + background: transparent; + color: var(--ink); + box-shadow: none; + overflow: visible; + opacity: 0; + transform: translateY(12px) scale(.98); + transition: opacity .25s var(--ease), transform .25s var(--ease), + overlay .25s var(--ease) allow-discrete, display .25s var(--ease) allow-discrete; +} + +.quickview[open] { + opacity: 1; + transform: translateY(0) scale(1); +} + +@starting-style { + .quickview[open] { + opacity: 0; + transform: translateY(12px) scale(.98); + } +} + +.quickview::backdrop { + background: color-mix(in srgb, #1a120b 55%, transparent); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + opacity: 0; + transition: opacity .25s var(--ease), + overlay .25s var(--ease) allow-discrete, display .25s var(--ease) allow-discrete; +} + +.quickview[open]::backdrop { + opacity: 1; +} + +@starting-style { + .quickview[open]::backdrop { + opacity: 0; + } +} + +.quickview-panel { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr); + gap: 1.25rem; + align-items: center; + max-height: 92vh; +} + +.quickview-close { + position: absolute; + top: .75rem; + right: .75rem; + z-index: 3; + width: 2.2rem; + height: 2.2rem; + display: inline-grid; + place-items: center; + padding: 0; + border-radius: 50%; + border: 1px solid var(--line-strong); + background: color-mix(in srgb, var(--surface) 85%, transparent); + color: var(--ink); + cursor: pointer; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + transition: transform .2s var(--ease), background .2s var(--ease), + color .2s var(--ease), border-color .2s var(--ease); +} + +.quickview-close:hover { + background: var(--accent-strong); + color: var(--accent-contrast); + border-color: transparent; + transform: rotate(90deg); +} + +.quickview-close .ph { + width: 1.1rem; + height: 1.1rem; +} + +.quickview-media { + position: relative; + align-self: center; + background: var(--surface-2); + aspect-ratio: 1 / 1; + max-height: 92vh; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-md); +} + +.quickview-media img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.quickview-body { + position: relative; + align-self: center; + display: flex; + flex-direction: column; + gap: .5rem; + padding: 1.7rem 1.8rem; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + max-height: 92vh; + overflow-y: auto; +} + +.quickview-name { + font-size: 1.6rem; + font-weight: 500; + color: var(--ink); +} + +.quickview-type { + margin: 0; + font-size: .78rem; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--muted); + font-weight: 600; +} + +.quickview-desc { + margin: .35rem 0 0; + color: var(--ink-soft); + font-size: 1rem; + line-height: 1.6; +} + +.quickview-foot { + margin-top: auto; + padding-top: 1.1rem; + display: flex; + flex-direction: column; + gap: .9rem; +} + +.quickview-price { + font-size: 1.4rem; +} + +.quickview-oos { + align-self: flex-start; + font-size: .8rem; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); +} + +.quickview-zoom { + position: absolute; + inset: 0; + display: grid; + place-items: end center; + padding-bottom: 1.1rem; + text-decoration: none; + background: linear-gradient(to top, color-mix(in srgb, var(--ink) 55%, transparent), transparent 42%); + opacity: 0; + transition: opacity .2s var(--ease); +} + +.quickview-media:hover .quickview-zoom, +.quickview-zoom:focus-visible { + opacity: 1; +} + +.quickview-zoom-pill { + display: inline-flex; + align-items: center; + gap: .45rem; + padding: .55rem 1rem; + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 90%, transparent); + color: var(--ink); + font-weight: 600; + font-size: .88rem; + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + box-shadow: var(--shadow-sm); + transform: translateY(8px); + transition: transform .2s var(--ease); +} + +.quickview-media:hover .quickview-zoom-pill, +.quickview-zoom:focus-visible .quickview-zoom-pill { + transform: translateY(0); +} + +@media (max-width: 760px) { + .quickview { + width: min(460px, 94vw); + } + + .quickview-panel { + grid-template-columns: 1fr; + gap: 1rem; + align-items: stretch; + max-height: 92vh; + overflow-y: auto; + } + + .quickview-media { + aspect-ratio: 16 / 11; + max-height: 46vh; + } +} + /* --- Responsive ---------------------------------------------------------- */ @media (max-width: 760px) { .brand-tagline { display: none; } + .topbar-actions { + margin-left: auto; + } + .topbar-inner { padding: .8rem 1.1rem; } @@ -1235,10 +1613,4 @@ h1:focus-visible { .product:hover { transform: none; } .product:hover .product-media img { transform: none; } - - ::view-transition-group(*), - ::view-transition-old(*), - ::view-transition-new(*) { - animation: none !important; - } } diff --git a/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.js b/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.js new file mode 100644 index 000000000..ff4cad55c --- /dev/null +++ b/samples/aspire-shop/AspireShop.Frontend/wwwroot/site.js @@ -0,0 +1,137 @@ +// Small progressive-enhancement helpers for the static-SSR Aspire Shop frontend. +// +// Everything here uses event delegation on `document`, so it keeps working across Blazor's +// enhanced navigation (which morphs the DOM in place without re-running page scripts). + +(function () { + "use strict"; + + function effectiveTheme() { + var explicit = document.documentElement.getAttribute("data-theme"); + if (explicit === "light" || explicit === "dark") { + return explicit; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function toggleTheme() { + var next = effectiveTheme() === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { + localStorage.setItem("aspireshop-theme", next); + } catch (e) { + /* localStorage unavailable */ + } + } + + // Lock background scrolling while a quick-look modal is open. The native blocks + // pointer interaction with the page but does NOT stop wheel/touch scrolling of the body, + // so we toggle overflow on and pad for the now-missing scrollbar to avoid a shift. + var scrollLocked = false; + + function lockScroll() { + if (scrollLocked) { + return; + } + var gap = window.innerWidth - document.documentElement.clientWidth; + document.documentElement.style.overflow = "hidden"; + if (gap > 0) { + document.documentElement.style.paddingRight = gap + "px"; + } + scrollLocked = true; + } + + function unlockScroll() { + if (!scrollLocked) { + return; + } + document.documentElement.style.overflow = ""; + document.documentElement.style.paddingRight = ""; + scrollLocked = false; + } + + function syncScrollLock() { + if (document.querySelector("dialog.quickview[open]")) { + lockScroll(); + } else { + unlockScroll(); + } + } + + document.addEventListener("click", function (event) { + // Theme toggle. + var themeToggle = event.target.closest("[data-theme-toggle]"); + if (themeToggle) { + event.preventDefault(); + toggleTheme(); + return; + } + + // Open a quick-look modal. + var opener = event.target.closest("[data-quickview]"); + if (opener) { + var dialog = document.getElementById(opener.getAttribute("data-quickview")); + if (dialog && typeof dialog.showModal === "function" && !dialog.open) { + event.preventDefault(); + dialog.showModal(); + lockScroll(); + } + return; + } + + // Close button inside a modal. + var closer = event.target.closest("[data-quickview-close]"); + if (closer) { + var owning = closer.closest("dialog"); + if (owning) { + owning.close(); + } + return; + } + + // Light-dismiss: a click that lands on the dialog element itself is a backdrop click. + if (window.HTMLDialogElement && event.target instanceof HTMLDialogElement && event.target.classList.contains("quickview")) { + event.target.close(); + } + }); + + // Release the scroll lock whenever a quick-look dialog closes. `close` doesn't bubble, so we + // listen in the capture phase to catch it for any product's dialog (close button, backdrop, Esc). + document.addEventListener("close", function (event) { + if (window.HTMLDialogElement && event.target instanceof HTMLDialogElement && event.target.classList.contains("quickview")) { + unlockScroll(); + } + }, true); + + // Preserve scroll position across the enhanced page refresh that follows cart mutations + // (add-to-cart, quantity changes, clear). Those forms are marked data-preserve-scroll. Without + // this, the post-mutation refresh can jump the page back to the top, which feels like a reload. + var pendingScroll = null; + + document.addEventListener("submit", function (event) { + var form = event.target; + if (form instanceof HTMLFormElement && form.hasAttribute("data-preserve-scroll")) { + pendingScroll = window.scrollY; + } + }, true); + + function restoreScroll() { + if (pendingScroll !== null) { + var y = pendingScroll; + pendingScroll = null; + window.scrollTo(0, y); + } + } + + // After an enhanced refresh, reconcile the scroll lock before restoring scroll: a modal + // add-to-cart morphs the dialog's `open` attribute away without firing a `close` event, so + // without this the page could stay locked. Unlock first so scrollTo can take effect. + function onEnhancedLoad() { + syncScrollLock(); + restoreScroll(); + } + + if (window.Blazor && typeof window.Blazor.addEventListener === "function") { + window.Blazor.addEventListener("enhancedload", onEnhancedLoad); + } +})(); diff --git a/samples/aspire-shop/README.md b/samples/aspire-shop/README.md index f85a361c6..21fd66ae2 100644 --- a/samples/aspire-shop/README.md +++ b/samples/aspire-shop/README.md @@ -3,7 +3,10 @@ ![Screenshot of the product catalog in the Aspire Shop sample](./images/aspireshop-frontend-light.png#gh-light-mode-only) ![Screenshot of the product catalog in the Aspire Shop sample](./images/aspireshop-frontend-dark.png#gh-dark-mode-only) -Browse a product catalog served by the Catalog service and add items to a cart held by the Basket service. +Browse a product catalog served by the Catalog service and add items to a cart held by the Basket service. Built with [Aspire](https://aspire.dev). + +![Screenshot of more products in the Aspire Shop catalog](./images/aspireshop-frontend-2-light.png#gh-light-mode-only) +![Screenshot of more products in the Aspire Shop catalog](./images/aspireshop-frontend-2-dark.png#gh-dark-mode-only) ![Screenshot of the shopping cart in the Aspire Shop sample](./images/aspireshop-cart-light.png#gh-light-mode-only) ![Screenshot of the shopping cart in the Aspire Shop sample](./images/aspireshop-cart-dark.png#gh-dark-mode-only) diff --git a/samples/aspire-shop/images/aspireshop-cart-dark.png b/samples/aspire-shop/images/aspireshop-cart-dark.png index ba2b17f2c..cdee98742 100644 Binary files a/samples/aspire-shop/images/aspireshop-cart-dark.png and b/samples/aspire-shop/images/aspireshop-cart-dark.png differ diff --git a/samples/aspire-shop/images/aspireshop-cart-light.png b/samples/aspire-shop/images/aspireshop-cart-light.png index 5ac8337e8..5eb0349ad 100644 Binary files a/samples/aspire-shop/images/aspireshop-cart-light.png and b/samples/aspire-shop/images/aspireshop-cart-light.png differ diff --git a/samples/aspire-shop/images/aspireshop-frontend-2-dark.png b/samples/aspire-shop/images/aspireshop-frontend-2-dark.png new file mode 100644 index 000000000..c789bad44 Binary files /dev/null and b/samples/aspire-shop/images/aspireshop-frontend-2-dark.png differ diff --git a/samples/aspire-shop/images/aspireshop-frontend-2-light.png b/samples/aspire-shop/images/aspireshop-frontend-2-light.png new file mode 100644 index 000000000..1a9d1fdb2 Binary files /dev/null and b/samples/aspire-shop/images/aspireshop-frontend-2-light.png differ diff --git a/samples/aspire-shop/images/aspireshop-frontend-dark.png b/samples/aspire-shop/images/aspireshop-frontend-dark.png index 6e893a0ad..ecdc02bb1 100644 Binary files a/samples/aspire-shop/images/aspireshop-frontend-dark.png and b/samples/aspire-shop/images/aspireshop-frontend-dark.png differ diff --git a/samples/aspire-shop/images/aspireshop-frontend-light.png b/samples/aspire-shop/images/aspireshop-frontend-light.png index d5688ee74..41e78cebc 100644 Binary files a/samples/aspire-shop/images/aspireshop-frontend-light.png and b/samples/aspire-shop/images/aspireshop-frontend-light.png differ