From 2fecd5b315262428c2e09f32baa6d71133f2dff5 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 27 Oct 2025 21:39:50 -0400 Subject: [PATCH] Latest --- .idea/.idea.Prefab/.idea/dataSources.xml | 12 + Prefab.AppHost/appsettings.Development.json | 2 +- .../Controllers/ProductsController.cs | 43 ++ .../App/Products/GetProductDetail.cs | 11 + .../Catalog/Products/GetProductDetail.cs | 7 + .../Catalog/Products/ProductClientHttp.cs | 2 +- .../Infrastructure/PrefabCompositeFixture.cs | 91 +++- .../Web/Gateway/ProductDetailShould.cs | 165 +++++++ Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs | 7 + .../Components/Catalog/ProductCardShould.cs | 2 +- .../Web.Client/Pages/ProductPageTests.cs | 128 ++++++ .../Components/Catalog/ProductDocs.razor | 42 ++ .../Models/Catalog/ProductDisplayModel.cs | 132 ++++++ Prefab.Web.Client/Pages/Browse/Product.razor | 422 ++++++++++++++++++ Prefab.Web.Client/Prefab.Web.Client.csproj | 1 + Prefab.Web.Client/Program.cs | 1 + .../Services/ProductDisplayService.cs | 34 ++ Prefab.Web/Gateway/Products.cs | 87 ++++ Prefab.Web/Module.cs | 3 +- Prefab.Web/Program.cs | 8 + Prefab.Web/UrlPolicy/UrlPolicy.cs | 8 +- Prefab.Web/appsettings.Development.json | 4 +- 22 files changed, 1198 insertions(+), 14 deletions(-) create mode 100644 .idea/.idea.Prefab/.idea/dataSources.xml create mode 100644 Prefab.Catalog.Api/Controllers/ProductsController.cs create mode 100644 Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs create mode 100644 Prefab.Tests/Web.Client/Pages/ProductPageTests.cs create mode 100644 Prefab.Web.Client/Components/Catalog/ProductDocs.razor create mode 100644 Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs create mode 100644 Prefab.Web.Client/Pages/Browse/Product.razor create mode 100644 Prefab.Web.Client/Services/ProductDisplayService.cs diff --git a/.idea/.idea.Prefab/.idea/dataSources.xml b/.idea/.idea.Prefab/.idea/dataSources.xml new file mode 100644 index 0000000..63eabd0 --- /dev/null +++ b/.idea/.idea.Prefab/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlserver.jb + true + com.jetbrains.jdbc.sqlserver.SqlServerDriver + Server=127.0.0.1,14331;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=true + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/Prefab.AppHost/appsettings.Development.json b/Prefab.AppHost/appsettings.Development.json index 2310e29..503237d 100644 --- a/Prefab.AppHost/appsettings.Development.json +++ b/Prefab.AppHost/appsettings.Development.json @@ -7,7 +7,7 @@ }, "SqlServer": { "ContainerName": "prefab-sql", - "HostPort": 14330, + "HostPort": 14331, "UseDataVolume": true }, "Parameters": { diff --git a/Prefab.Catalog.Api/Controllers/ProductsController.cs b/Prefab.Catalog.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..ec0e586 --- /dev/null +++ b/Prefab.Catalog.Api/Controllers/ProductsController.cs @@ -0,0 +1,43 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Prefab.Catalog.Domain.Exceptions; +using Prefab.Endpoints; +using Prefab.Shared; +using Prefab.Shared.Catalog.Products; + +namespace Prefab.Catalog.Api.Controllers; + +[ApiController] +[Route("catalog/products")] +public sealed class ProductsController : ControllerBase +{ + [HttpGet("{slug}/config")] + [ProducesResponseType(typeof(GetProductDetail.Response), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductConfig( + [FromRoute] string slug, + [FromServices] IProductClient productClient, + CancellationToken cancellationToken) + { + try + { + var response = await productClient.GetProductDetail(slug, cancellationToken); + return Ok(response); + } + catch (CatalogNotFoundException ex) + { + return NotFound(ApiProblemDetails.NotFound(ex.Resource, ex.Identifier, ex.Message)); + } + catch (RemoteProblemException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return NotFound(); + } + catch (ArgumentException argumentException) + { + return ValidationProblem( + title: "Invalid product slug.", + detail: argumentException.Message); + } + } +} diff --git a/Prefab.Catalog/App/Products/GetProductDetail.cs b/Prefab.Catalog/App/Products/GetProductDetail.cs index 8d8c1cc..91f455e 100644 --- a/Prefab.Catalog/App/Products/GetProductDetail.cs +++ b/Prefab.Catalog/App/Products/GetProductDetail.cs @@ -67,6 +67,7 @@ public class GetProductDetail : SharedProductDetail .Include(model => model.Options).ThenInclude(option => option.Tiers) .Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions) .Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition) + .Include(model => model.Categories).ThenInclude(category => category.Category) .FirstOrDefaultAsync(cancellationToken); if (product is null) @@ -78,12 +79,22 @@ public class GetProductDetail : SharedProductDetail var specDtos = MapSpecs(product); var genericAttributes = await LoadGenericAttributesAsync(db, product.Id, cancellationToken); + var categoryDtos = product.Categories + .Where(pc => pc.Category is not null) + .Select(pc => new SharedProductDetail.CategoryDto( + pc.Category.Id, + pc.Category.Name, + pc.Category!.Slug ?? string.Empty)) + .ToList(); + var payload = new SharedProductDetail.ProductDetailDto( product.Id, product.Name, product.Slug ?? normalizedSlug, product.Description, product.Price, + product.Sku, + categoryDtos, optionDtos, specDtos, genericAttributes); diff --git a/Prefab.Shared/Catalog/Products/GetProductDetail.cs b/Prefab.Shared/Catalog/Products/GetProductDetail.cs index e505500..5d141db 100644 --- a/Prefab.Shared/Catalog/Products/GetProductDetail.cs +++ b/Prefab.Shared/Catalog/Products/GetProductDetail.cs @@ -22,6 +22,8 @@ public abstract class GetProductDetail string Slug, string? Description, decimal? Price, + string? Sku, + IReadOnlyList Categories, IReadOnlyList Options, IReadOnlyList Specs, IReadOnlyDictionary? GenericAttributes); @@ -74,4 +76,9 @@ public abstract class GetProductDetail string? Value, decimal? NumericValue, string? UnitCode); + + public sealed record CategoryDto( + Guid Id, + string Name, + string Slug); } diff --git a/Prefab.Shared/Catalog/Products/ProductClientHttp.cs b/Prefab.Shared/Catalog/Products/ProductClientHttp.cs index 242f35f..fdcf095 100644 --- a/Prefab.Shared/Catalog/Products/ProductClientHttp.cs +++ b/Prefab.Shared/Catalog/Products/ProductClientHttp.cs @@ -86,7 +86,7 @@ public sealed class ProductClientHttp(HttpClient httpClient) : IProductClient, I { ArgumentException.ThrowIfNullOrWhiteSpace(slug); - using var response = await httpClient.GetAsync($"api/catalog/products/{slug.Trim()}", cancellationToken); + using var response = await httpClient.GetAsync($"catalog/products/{slug.Trim()}/config", cancellationToken); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs b/Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs index cac8e6a..ec9fea3 100644 --- a/Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs +++ b/Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs @@ -1,5 +1,6 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; using Prefab.Handler; using Prefab.Web.Data; @@ -50,7 +51,7 @@ public class PrefabCompositeFixture : IAsyncLifetime catalogProject, catalogPort, overrides: null, - configureServices: ConfigureTestAuthentication); + configureServices: ConfigureCatalogServices); await CatalogApi.InitializeAsync(); catalogAddress = CatalogApi.HttpAddress; @@ -68,7 +69,7 @@ public class PrefabCompositeFixture : IAsyncLifetime prefabProject, prefabPort, overrides, - ConfigureTestAuthentication); + ConfigureWebServices); await PrefabWeb.InitializeAsync(); } @@ -113,7 +114,7 @@ public class PrefabCompositeFixture : IAsyncLifetime ["Prefab__Catalog__Client__BaseAddress"] = catalogAddress?.ToString().TrimEnd('/') ?? string.Empty }; - private void ConfigureTestAuthentication(IServiceCollection services) + protected void ConfigureTestAuthentication(IServiceCollection services) { var descriptors = services.Where(d => d.ServiceType == typeof(AuthenticationStateProvider)).ToList(); foreach (var descriptor in descriptors) @@ -125,6 +126,10 @@ public class PrefabCompositeFixture : IAsyncLifetime services.AddSingleton(_ => _authenticationProvider); } + protected virtual void ConfigureCatalogServices(IServiceCollection services) => ConfigureTestAuthentication(services); + + protected virtual void ConfigureWebServices(IServiceCollection services) => ConfigureTestAuthentication(services); + private static PrefabHarnessOptions ResolveDefaultOptions() { var modeEnv = Environment.GetEnvironmentVariable("PREFAB_TEST_MODE")?.Trim().ToLowerInvariant(); @@ -190,6 +195,79 @@ public sealed class PrefabCompositeFixture_InProcess : PrefabCompositeFixture }; } +public sealed class PrefabCompositeFixture_BffProduct : PrefabCompositeFixture +{ + private Prefab.Shared.Catalog.Products.IProductClient? _productClient; + private readonly Prefab.Shared.Catalog.Categories.ICategoryClient _categoryClient = new NullCategoryClient(); + private readonly Prefab.Shared.Catalog.Products.IPriceQuoteClient _priceQuoteClient = new NullPriceQuoteClient(); + + public PrefabCompositeFixture_BffProduct() + : base(new PrefabHarnessOptions( + RunMode.DebugPersistent, + EnableAspireDashboard: true, + DisablePortRandomization: true)) + { + } + + public void UseProductClient(Prefab.Shared.Catalog.Products.IProductClient client) + { + _productClient = client ?? throw new ArgumentNullException(nameof(client)); + } + + protected override bool ShouldStartCatalog => false; + + protected override IDictionary GetWebOverrides(Uri? catalogAddress) => + new Dictionary + { + ["Prefab__Catalog__Client__Transport"] = "InProcess" + }; + + protected override void ConfigureWebServices(IServiceCollection services) + { + base.ConfigureWebServices(services); + + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddScoped(_ => _categoryClient); + services.AddScoped(_ => _priceQuoteClient); + services.AddScoped(_ => + _productClient ?? throw new InvalidOperationException("Test product client has not been configured.")); + services.AddScoped(sp => + new Prefab.Catalog.ModuleClient( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + } + + private sealed class NullCategoryClient : Prefab.Shared.Catalog.Categories.ICategoryClient + { + public Task GetCategories( + CancellationToken cancellationToken) => + throw new NotSupportedException("Category retrieval is not configured for this test."); + + public Task CreateCategory( + Prefab.Shared.Catalog.Categories.CreateCategory.Request request, + CancellationToken cancellationToken) => + throw new NotSupportedException("Category creation is not configured for this test."); + } + + private sealed class NullPriceQuoteClient : Prefab.Shared.Catalog.Products.IPriceQuoteClient + { + public Task Quote( + Prefab.Shared.Catalog.Products.QuotePrice.Request request, + CancellationToken cancellationToken) => + throw new NotSupportedException("Price quote client is not configured for this test."); + + public Task QuotePrice( + Prefab.Shared.Catalog.Products.QuotePrice.Request request, + CancellationToken cancellationToken) => + throw new NotSupportedException("Price quote client is not configured for this test."); + } +} + [CollectionDefinition("Prefab.Debug")] public sealed class PrefabDebugCollection : ICollectionFixture { @@ -204,3 +282,8 @@ public sealed class PrefabEphemeralCollection : ICollectionFixture { } + +[CollectionDefinition("Prefab.BffProduct")] +public sealed class PrefabBffProductCollection : ICollectionFixture +{ +} diff --git a/Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs b/Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs new file mode 100644 index 0000000..acb1398 --- /dev/null +++ b/Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Http.Json; +using Prefab.Catalog.Domain.Exceptions; +using Prefab.Shared.Catalog.Products; +using Prefab.Tests.Infrastructure; +using Prefab.Web.Client.Models.Catalog; +using Prefab.Web.Client.Models.Shared; +using Shouldly; + +namespace Prefab.Tests.Integration.Web.Gateway; + +[Collection("Prefab.BffProduct")] +[Trait(TraitName.Category, TraitCategory.Integration)] +public sealed class ProductDetailGatewayShould(PrefabCompositeFixture_BffProduct fixture) + : IClassFixture +{ + [Fact] + public async Task ReturnProductModelWhenFound() + { + fixture.UseProductClient(new SuccessProductClient(CreateSampleProductResponse())); + + var cancellationToken = TestContext.Current.CancellationToken; + var client = fixture.CreateHttpClientForWeb(); + + var response = await client.GetAsync("/bff/catalog/products/sample-widget", cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var payload = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + payload.ShouldNotBeNull(); + payload!.IsSuccess.ShouldBeTrue(payload.Problem?.Detail); + payload.Value.ShouldNotBeNull(); + payload.Value!.Slug.ShouldBe("sample-widget"); + payload.Value.Name.ShouldBe("Sample Widget"); + payload.Value.Sku.ShouldBe("SW-001"); + payload.Value.Categories.ShouldNotBeEmpty(); + } + + [Fact] + public async Task ReturnNotFoundWhenMissing() + { + fixture.UseProductClient(new NotFoundProductClient()); + + var cancellationToken = TestContext.Current.CancellationToken; + var client = fixture.CreateHttpClientForWeb(); + + var response = await client.GetAsync("/bff/catalog/products/missing-widget", cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + + var payload = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + payload.ShouldNotBeNull(); + payload!.IsSuccess.ShouldBeFalse(); + payload.Problem.ShouldNotBeNull(); + payload.Problem!.StatusCode.ShouldBe((int)HttpStatusCode.NotFound); + } + + private static GetProductDetail.Response CreateSampleProductResponse() + { + var optionValues = new[] + { + new GetProductDetail.OptionValueDto(Guid.NewGuid(), "red", "Red", 0m, (int)GetProductDetail.PriceDeltaKind.Absolute, null), + new GetProductDetail.OptionValueDto(Guid.NewGuid(), "blue", "Blue", 0m, (int)GetProductDetail.PriceDeltaKind.Absolute, null) + }; + + var optionDefinitions = new[] + { + new GetProductDetail.OptionDefinitionDto( + Guid.NewGuid(), + "color", + "Color", + (int)GetProductDetail.OptionDataType.Choice, + true, + Unit: null, + Min: null, + Max: null, + Step: null, + PricePerUnit: null, + Tiers: Array.Empty(), + Values: optionValues, + Rules: null) + }; + + var specs = new[] + { + new GetProductDetail.SpecDto("Weight", "25 lb", null, null) + }; + + var attributes = new Dictionary + { + ["catalog.product.docs"] = """[{ "title": "Spec Sheet", "url": "https://example.com/spec.pdf" }]""" + }; + + var categories = new[] + { + new GetProductDetail.CategoryDto(Guid.NewGuid(), "Lighting", "lighting") + }; + + var product = new GetProductDetail.ProductDetailDto( + Guid.NewGuid(), + "Sample Widget", + "sample-widget", + "Sample description.", + 42.50m, + "SW-001", + categories, + optionDefinitions, + specs, + attributes); + + return new GetProductDetail.Response(product); + } + + private sealed class SuccessProductClient(GetProductDetail.Response response) : IProductClient + { + public Task GetCategoryModels( + string slug, + int? page, + int? pageSize, + string? sort, + string? direction, + CancellationToken cancellationToken) => + throw new NotSupportedException("Category models are not required for this test."); + + public Task GetCategory(string slug, CancellationToken cancellationToken) => + throw new NotSupportedException("Category client is not required for this test."); + + public Task GetCategoryTree(int? depth, CancellationToken cancellationToken) => + throw new NotSupportedException("Category tree is not required for this test."); + + public Task GetProductDetail(string slug, CancellationToken cancellationToken) => + Task.FromResult(response); + + public Task Quote(QuotePrice.Request request, CancellationToken cancellationToken) => + throw new NotSupportedException("Price quotes are not required for this test."); + + public Task QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) => + throw new NotSupportedException("Price quotes are not required for this test."); + } + + private sealed class NotFoundProductClient : IProductClient + { + public Task GetCategoryModels( + string slug, + int? page, + int? pageSize, + string? sort, + string? direction, + CancellationToken cancellationToken) => + throw new NotSupportedException("Category models are not required for this test."); + + public Task GetCategory(string slug, CancellationToken cancellationToken) => + throw new NotSupportedException("Category client is not required for this test."); + + public Task GetCategoryTree(int? depth, CancellationToken cancellationToken) => + throw new NotSupportedException("Category tree is not required for this test."); + + public Task GetProductDetail(string slug, CancellationToken cancellationToken) => + throw new CatalogNotFoundException("Product", slug); + + public Task Quote(QuotePrice.Request request, CancellationToken cancellationToken) => + throw new NotSupportedException("Price quotes are not required for this test."); + + public Task QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) => + throw new NotSupportedException("Price quotes are not required for this test."); + } +} diff --git a/Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs b/Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs index 332ffa7..a13a9c7 100644 --- a/Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs +++ b/Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs @@ -72,4 +72,11 @@ public sealed class UrlPolicyShould UrlPolicy.Products(page, pageSize, sort, view).ShouldBe(url); } + + [Fact] + public void BuildBrowseProductUrl() + { + var url = UrlPolicy.BrowseProduct("bolt & nut"); + url.ShouldBe("/browse/product?slug=bolt%20%26%20nut"); + } } diff --git a/Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs b/Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs index 15c8384..56fbb70 100644 --- a/Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs +++ b/Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs @@ -103,7 +103,7 @@ public sealed class ProductCardShould : BunitContext { Id = Guid.NewGuid(), Title = "Aluminum Chandelier", - Url = "/catalog/product/aluminum-chandelier", + Url = "/browse/product?slug=aluminum-chandelier", Slug = "aluminum-chandelier", CategoryName = "Chandeliers", CategoryUrl = "/catalog/chandeliers", diff --git a/Prefab.Tests/Web.Client/Pages/ProductPageTests.cs b/Prefab.Tests/Web.Client/Pages/ProductPageTests.cs new file mode 100644 index 0000000..9b4b7c1 --- /dev/null +++ b/Prefab.Tests/Web.Client/Pages/ProductPageTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using Microsoft.AspNetCore.Components; +using System.Globalization; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Prefab.Web.Client.Models.Catalog; +using Prefab.Web.Client.Models.Shared; +using Prefab.Web.Client.Pages.Browse; +using Prefab.Web.Client.Services; +using Shouldly; + +namespace Prefab.Tests.Web.Client.Pages; + +[Trait(TraitName.Category, TraitCategory.Unit)] +public sealed class ProductPageTests : BunitContext +{ + [Fact] + public void ProductPage_Renders_Product_Details_From_Service() + { + var model = CreateSampleProduct(); + Services.AddScoped(_ => + new FakeProductDisplayService(Result.Success(model))); + + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", model.Slug)); + + var component = Render(); + + component.Markup.ShouldContain(model.Name); + model.Description.ShouldNotBeNull(); + component.Markup.ShouldContain(model.Description!); + component.FindAll("input[type=radio]").Count.ShouldBe(2); + component.Markup.ShouldContain("Quantity"); + component.FindAll(".product__add-to-cart").Count.ShouldBe(1); + component.Markup.ShouldContain("Documents"); + component.Markup.ShouldContain("Spec Sheet"); + component.Markup.ShouldContain("SW-001"); + component.Markup.ShouldContain("Lighting"); + var expectedPrice = model.BasePrice!.Value.ToString("C", CultureInfo.CurrentCulture); + component.Markup.ShouldContain(expectedPrice); + } + + [Fact] + public void ProductPage_Shows_Problem_When_NotFound() + { + Services.AddScoped(_ => + new FakeProductDisplayService(Result.Failure(new ResultProblem + { + Title = "Product not found.", + Detail = "Product 'unknown' was not found.", + StatusCode = (int)HttpStatusCode.NotFound + }))); + + var navigation = Services.GetRequiredService(); + navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", "unknown")); + + var component = Render(); + + component.Markup.ShouldContain("Product not found."); + component.FindAll(".alert-warning").Count.ShouldBe(1); + } + + private static ProductDisplayModel CreateSampleProduct() => new() + { + Id = Guid.NewGuid(), + Slug = "sample-widget", + Name = "Sample Widget", + Description = "

Sample description.

", + BasePrice = 42.50m, + Sku = "SW-001", + Categories = + [ + new ProductDisplayModel.CategoryRef(Guid.NewGuid(), "Lighting", "lighting") + ], + Options = + [ + new ProductDisplayModel.OptionDefinition( + Id: Guid.NewGuid(), + Code: "color", + Name: "Color", + DataType: 0, + IsVariantAxis: true, + Unit: null, + Min: null, + Max: null, + Step: null, + PricePerUnit: null, + Tiers: Array.Empty(), + Values: + [ + new ProductDisplayModel.OptionValue(Guid.NewGuid(), "red", "Red", 0m, 0), + new ProductDisplayModel.OptionValue(Guid.NewGuid(), "blue", "Blue", 0m, 0) + ]), + new ProductDisplayModel.OptionDefinition( + Id: Guid.NewGuid(), + Code: "length", + Name: "Length", + DataType: 1, + IsVariantAxis: false, + Unit: "ft", + Min: 1, + Max: 10, + Step: 1, + PricePerUnit: null, + Tiers: + [ + new ProductDisplayModel.Tier(1, 5, 1, null) + ], + Values: Array.Empty()) + ], + Specs = + [ + new ProductDisplayModel.Spec("Weight", "25 lb", null, null) + ], + GenericAttributes = new Dictionary + { + ["catalog.product.docs"] = """[{ "title": "Spec Sheet", "url": "https://example.com/spec.pdf" }]""" + } + }; + + private sealed class FakeProductDisplayService(Result result) : IProductDisplayService + { + private readonly Result _result = result; + + public Task> Get(string slug, CancellationToken cancellationToken = default) => + Task.FromResult(_result); + } +} diff --git a/Prefab.Web.Client/Components/Catalog/ProductDocs.razor b/Prefab.Web.Client/Components/Catalog/ProductDocs.razor new file mode 100644 index 0000000..87bdb8c --- /dev/null +++ b/Prefab.Web.Client/Components/Catalog/ProductDocs.razor @@ -0,0 +1,42 @@ +@using System.Text.Json + +@if (_documents.Count > 0) +{ +
+
Documents
+
    + @foreach (var doc in _documents) + { +
  • + @doc.Title +
  • + } +
+
+} + +@code { + [Parameter] + public string Json { get; set; } = "[]"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private List _documents { get; set; } = new(); + + protected override void OnParametersSet() + { + try + { + _documents = JsonSerializer.Deserialize>(Json, SerializerOptions) ?? new List(); + } + catch + { + _documents = new List(); + } + } + + private sealed record DocumentLink(string Title, string Url); +} diff --git a/Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs b/Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs new file mode 100644 index 0000000..492efa0 --- /dev/null +++ b/Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using Prefab.Shared.Catalog.Products; + +namespace Prefab.Web.Client.Models.Catalog; + +/// +/// UI-facing projection of the catalog product detail configuration payload. +/// +public sealed class ProductDisplayModel +{ + public Guid Id { get; init; } + + public string Slug { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public string? Description { get; init; } + + public decimal? BasePrice { get; init; } + + public string? Sku { get; init; } + + public IReadOnlyList Categories { get; init; } = Array.Empty(); + + public IReadOnlyList Options { get; init; } = Array.Empty(); + + public IReadOnlyList Specs { get; init; } = Array.Empty(); + + public IReadOnlyDictionary? GenericAttributes { get; init; } + + public sealed record OptionDefinition( + Guid Id, + string Code, + string Name, + int DataType, + bool IsVariantAxis, + string? Unit, + decimal? Min, + decimal? Max, + decimal? Step, + decimal? PricePerUnit, + IReadOnlyList Tiers, + IReadOnlyList Values); + + public sealed record OptionValue( + Guid Id, + string Code, + string Label, + decimal? PriceDelta, + int PriceDeltaKind); + + public sealed record Tier( + decimal FromInclusive, + decimal? ToInclusive, + decimal UnitRate, + decimal? FlatDelta); + + public sealed record Spec( + string Name, + string? Value, + decimal? NumericValue, + string? UnitCode); + + public sealed record CategoryRef( + Guid Id, + string Name, + string Slug); + + public static ProductDisplayModel From(GetProductDetail.ProductDetailDto product) + { + ArgumentNullException.ThrowIfNull(product); + + var options = product.Options? + .Select(option => new OptionDefinition( + option.Id, + option.Code, + option.Name, + option.DataType, + option.IsVariantAxis, + option.Unit, + option.Min, + option.Max, + option.Step, + option.PricePerUnit, + option.Tiers? + .Select(tier => new Tier( + tier.FromInclusive, + tier.ToInclusive, + tier.UnitRate, + tier.FlatDelta)) + .ToList() ?? new List(), + option.Values? + .Select(value => new OptionValue( + value.Id, + value.Code, + value.Label, + value.PriceDelta, + value.PriceDeltaKind)) + .ToList() ?? new List())) + .ToList() ?? new List(); + + var specs = product.Specs? + .Select(spec => new Spec( + spec.Name, + spec.Value, + spec.NumericValue, + spec.UnitCode)) + .ToList() ?? new List(); + + var categories = product.Categories? + .Select(category => new CategoryRef( + category.Id, + category.Name, + category.Slug)) + .ToList() ?? new List(); + + return new ProductDisplayModel + { + Id = product.Id, + Slug = product.Slug, + Name = product.Name, + Description = product.Description, + BasePrice = product.Price, + Sku = product.Sku, + Categories = categories, + Options = options, + Specs = specs, + GenericAttributes = product.GenericAttributes + }; + } +} diff --git a/Prefab.Web.Client/Pages/Browse/Product.razor b/Prefab.Web.Client/Pages/Browse/Product.razor new file mode 100644 index 0000000..cd6245c --- /dev/null +++ b/Prefab.Web.Client/Pages/Browse/Product.razor @@ -0,0 +1,422 @@ +@page "/browse/product" +@using System.Globalization +@using System.Linq +@using System.Net +@using Microsoft.AspNetCore.Http +@using Prefab.Shared.Catalog.Products +@using Prefab.Web.Client.Components.Catalog +@using Prefab.Web.Client.Models.Catalog +@using Prefab.Web.Client.Models.Shared +@using Prefab.Web.Client.Services +@using Telerik.Blazor.Components +@using Telerik.Blazor +@inject IProductDisplayService ProductDisplayService +@inject PersistentComponentState? State +@implements IDisposable + +@(Model?.Name ?? "Product") | Prefab + + + +
+ @if (_isLoading) + { + if (HasTelerikSkeleton) + { +
+ + + +
+ } + else + { +
+
+
+
+
+ } + } + else if (Problem is not null) + { + + } + else if (Model is not null) + { + var product = Model; + var hasDocs = TryGetDocs(product, out var docsJson); + +
+
+ +
+
+
    + @if (!string.IsNullOrWhiteSpace(product.Sku)) + { +
  • SKU: @product.Sku
  • + } + @if (product.Categories.Any()) + { +
  • + Category: + @for (var index = 0; index < product.Categories.Count; index++) + { + var category = product.Categories[index]; + @category.Name + if (index < product.Categories.Count - 1) + { + , + } + } +
  • + } +
+
+ +

@product.Name

+ + @if (product.BasePrice.HasValue) + { +
+ @FormatCurrency(product.BasePrice.Value) +
+ } + + @if (!string.IsNullOrWhiteSpace(product.Description)) + { +
@((MarkupString)product.Description)
+ } + + @if (product.Options.Count > 0) + { +
+ @foreach (var option in product.Options) + { + var optionIsChoice = option.DataType == (int)GetProductDetail.OptionDataType.Choice; + +
+ + + @if (optionIsChoice) + { + + } + else + { +
+ + @if (!string.IsNullOrWhiteSpace(option.Unit)) + { + Unit: @option.Unit + } +
+ @if (option.Tiers.Count > 0) + { + Tiered pricing data available. + } + } +
+ } +
+ } + +
+
+ + +
+
+ + Add to cart + +
+
+
+
+ +
+
+ + +
+ @if (!string.IsNullOrWhiteSpace(product.Description)) + { +
@((MarkupString)product.Description)
+ } + else + { +

No description available.

+ } + + @if (hasDocs && docsJson is not null) + { + + } +
+
+ +
+ @if (product.Specs.Any()) + { + + + @foreach (var spec in product.Specs) + { + + + + + } + +
@spec.Name@FormatSpecValue(spec)
+ } + else + { +

No specifications were provided.

+ } +
+
+
+
+
+
+ } +
+ +@code { + private const string DocsAttributeKey = "catalog.product.docs"; + private const string StateKey = "browse-product-state"; + private static readonly bool HasTelerikSkeleton = + Type.GetType("Telerik.Blazor.Components.TelerikSkeleton, Telerik.UI.for.Blazor") is not null; + + [SupplyParameterFromQuery(Name = "slug")] + public string? Slug { get; set; } + + private Result? _result; + private string? _currentSlug; + private bool _isLoading = true; + private PersistingComponentStateSubscription? _subscription; + private readonly Dictionary _choiceSelections = new(); + private readonly Dictionary _numberSelections = new(); + private int _quantity = 1; + + private ProductDisplayModel? Model => _result?.Value; + private ResultProblem? Problem => _result?.Problem; + + protected override async Task OnInitializedAsync() + { + if (State is not null) + { + _subscription = State.RegisterOnPersisting(PersistStateAsync); + + if (State.TryTakeFromJson(StateKey, out var restored) && restored is not null) + { + _currentSlug = restored.Slug; + _result = restored.Result; + _isLoading = false; + + if (_result?.Value is { } restoredModel) + { + PrepareSelections(restoredModel); + } + } + } + + await base.OnInitializedAsync(); + } + + protected override async Task OnParametersSetAsync() + { + if (string.IsNullOrWhiteSpace(Slug)) + { + _currentSlug = null; + _result = Result.Failure(new ResultProblem + { + Title = "Product not specified.", + Detail = "A product slug is required to load this page.", + StatusCode = (int)HttpStatusCode.BadRequest + }); + _isLoading = false; + return; + } + + var normalizedSlug = Slug.Trim(); + if (string.Equals(_currentSlug, normalizedSlug, StringComparison.OrdinalIgnoreCase)) + { + _isLoading = false; + return; + } + + _isLoading = true; + var result = await ProductDisplayService.Get(normalizedSlug); + _result = result; + _currentSlug = normalizedSlug; + + if (result.IsSuccess && result.Value is not null) + { + PrepareSelections(result.Value); + } + else + { + ClearSelections(); + } + + _isLoading = false; + } + + private Task PersistStateAsync() + { + if (_currentSlug is not null && _result is not null && State is not null) + { + State.PersistAsJson(StateKey, new PageState(_currentSlug, _result)); + } + + return Task.CompletedTask; + } + + private static string FormatSpecValue(ProductDisplayModel.Spec spec) + { + var raw = spec.Value; + if (!string.IsNullOrWhiteSpace(raw)) + { + return raw; + } + + var numericValue = spec.NumericValue; + if (numericValue.HasValue) + { + var formatted = numericValue.Value.ToString("0.##", CultureInfo.InvariantCulture); + var unit = spec.UnitCode; + return string.IsNullOrWhiteSpace(unit) ? formatted : $"{formatted} {unit}"; + } + + return string.Empty; + } + + private static string FormatCurrency(decimal amount) => + amount.ToString("C", CultureInfo.CurrentCulture); + + private string? GetChoiceSelection(Guid optionId) => + _choiceSelections.TryGetValue(optionId, out var value) ? value : null; + + private void SetChoiceSelection(Guid optionId, string? value) => + _choiceSelections[optionId] = value; + + private decimal? GetNumberSelection(Guid optionId) => + _numberSelections.TryGetValue(optionId, out var value) ? value : null; + + private void SetNumberSelection(Guid optionId, decimal? value) => + _numberSelections[optionId] = value; + + private static IEnumerable BuildChoiceItems(ProductDisplayModel.OptionDefinition option) => + option.Values.Select(value => + new ChoiceItem( + string.IsNullOrWhiteSpace(value.Code) ? value.Id.ToString("N") : value.Code, + value.Label)); + + private static decimal? ResolveStep(ProductDisplayModel.OptionDefinition option) => + option.Step ?? 1m; + + private void PrepareSelections(ProductDisplayModel product) + { + _choiceSelections.Clear(); + _numberSelections.Clear(); + _quantity = 1; + + foreach (var option in product.Options) + { + if (option.DataType == (int)GetProductDetail.OptionDataType.Choice) + { + var defaultValue = option.Values.FirstOrDefault(); + _choiceSelections[option.Id] = defaultValue?.Code ?? defaultValue?.Id.ToString("N"); + } + else + { + decimal? initial = option.Min ?? option.Max ?? 0m; + _numberSelections[option.Id] = initial; + } + } + } + + private void ClearSelections() + { + _choiceSelections.Clear(); + _numberSelections.Clear(); + _quantity = 1; + } + + private static bool TryGetDocs(ProductDisplayModel product, out string? docsJson) + { + docsJson = null; + return product.GenericAttributes is not null && + product.GenericAttributes.TryGetValue(DocsAttributeKey, out docsJson) && + !string.IsNullOrWhiteSpace(docsJson); + } + + public void Dispose() + { + _subscription?.Dispose(); + } + + private sealed record PageState(string Slug, Result Result); + private sealed record ChoiceItem(string Value, string Text); +} diff --git a/Prefab.Web.Client/Prefab.Web.Client.csproj b/Prefab.Web.Client/Prefab.Web.Client.csproj index 00f15da..423a612 100644 --- a/Prefab.Web.Client/Prefab.Web.Client.csproj +++ b/Prefab.Web.Client/Prefab.Web.Client.csproj @@ -20,5 +20,6 @@ + diff --git a/Prefab.Web.Client/Program.cs b/Prefab.Web.Client/Program.cs index c2d632c..a106454 100644 --- a/Prefab.Web.Client/Program.cs +++ b/Prefab.Web.Client/Program.cs @@ -15,5 +15,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/Prefab.Web.Client/Services/ProductDisplayService.cs b/Prefab.Web.Client/Services/ProductDisplayService.cs new file mode 100644 index 0000000..095feef --- /dev/null +++ b/Prefab.Web.Client/Services/ProductDisplayService.cs @@ -0,0 +1,34 @@ +using System.Net; +using System.Net.Http.Json; +using Prefab.Web.Client.Models.Catalog; +using Prefab.Web.Client.Models.Shared; + +namespace Prefab.Web.Client.Services; + +public interface IProductDisplayService +{ + Task> Get(string slug, CancellationToken cancellationToken = default); +} + +public sealed class ProductDisplayService(HttpClient httpClient) : IProductDisplayService +{ + public async Task> Get(string slug, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(slug); + + using var response = await httpClient.GetAsync( + $"/bff/catalog/products/{Uri.EscapeDataString(slug.Trim())}", + cancellationToken); + + var payload = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + if (payload is not null) + { + return payload; + } + + var statusCode = (HttpStatusCode)response.StatusCode; + var exception = new InvalidOperationException($"Response {(int)statusCode} did not contain a valid payload."); + var problem = ResultProblem.Unexpected("Failed to retrieve product.", exception, statusCode); + return Result.Failure(problem); + } +} diff --git a/Prefab.Web/Gateway/Products.cs b/Prefab.Web/Gateway/Products.cs index 664d829..2611e18 100644 --- a/Prefab.Web/Gateway/Products.cs +++ b/Prefab.Web/Gateway/Products.cs @@ -4,11 +4,13 @@ using Prefab.Domain.Exceptions; using Prefab.Endpoints; using Prefab.Shared.Catalog; using Prefab.Shared.Catalog.Products; +using Prefab.Shared; using Prefab.Web.Client.Models.Catalog; using Prefab.Web.Client.Models.Shared; using Prefab.Web.Client.ViewModels.Catalog; using Prefab.Web.Client.Services; using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy; +using CatalogNotFoundException = Prefab.Catalog.Domain.Exceptions.CatalogNotFoundException; namespace Prefab.Web.Gateway; @@ -29,6 +31,18 @@ public static class Products return Results.Json(result, statusCode: status); }) .WithTags(nameof(Products)); + + endpoints.MapGet( + "/bff/catalog/products/{slug}", + async (DetailService service, string slug, CancellationToken cancellationToken) => + { + var result = await service.Get(slug, cancellationToken); + var status = result.Problem?.StatusCode ?? (result.IsSuccess + ? StatusCodes.Status200OK + : StatusCodes.Status500InternalServerError); + return Results.Json(result, statusCode: status); + }) + .WithTags(nameof(Products)); } } @@ -117,4 +131,77 @@ public static class Products }; } } + + public sealed class DetailService(IModuleClient moduleClient) + { + public async Task> Get(string slug, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(slug)) + { + return Result.Failure(new ResultProblem + { + Title = "Invalid product slug.", + Detail = "Product slug is required.", + StatusCode = StatusCodes.Status400BadRequest + }); + } + + var normalizedSlug = slug.Trim(); + + try + { + var response = await moduleClient.Product.GetProductConfig(normalizedSlug, cancellationToken); + var product = response.Product ?? throw new InvalidOperationException("Product payload was empty."); + var model = ProductDisplayModel.From(product); + return Result.Success(model); + } + catch (ValidationException validationException) + { + var problem = ResultProblem.FromValidation(validationException); + return Result.Failure(problem); + } + catch (CatalogNotFoundException) + { + return Result.Failure(new ResultProblem + { + Title = "Product not found.", + Detail = $"Product '{normalizedSlug}' was not found.", + StatusCode = StatusCodes.Status404NotFound + }); + } + catch (RemoteProblemException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return Result.Failure(new ResultProblem + { + Title = "Product not found.", + Detail = $"Product '{normalizedSlug}' was not found.", + StatusCode = StatusCodes.Status404NotFound + }); + } + catch (DomainException domainException) + { + var problem = ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity); + return Result.Failure(problem); + } + catch (RemoteProblemException ex) + { + var problem = ResultProblem.Unexpected("Failed to retrieve product.", ex, ex.StatusCode); + return Result.Failure(problem); + } + catch (Exception exception) + { + var problem = ResultProblem.Unexpected("Failed to retrieve product.", exception); + return Result.Failure(problem); + } + } + } +} + +internal static class ProductClientExtensions +{ + public static Task GetProductConfig( + this IProductClient productClient, + string slug, + CancellationToken cancellationToken) => + productClient.GetProductDetail(slug, cancellationToken); } diff --git a/Prefab.Web/Module.cs b/Prefab.Web/Module.cs index 8a5a68e..024673f 100644 --- a/Prefab.Web/Module.cs +++ b/Prefab.Web/Module.cs @@ -37,7 +37,7 @@ public class Module : IModule builder.Services.AddScoped(); - builder.Services.AddHostedService(); + //builder.Services.AddHostedService(); builder.Services.AddScoped(); @@ -47,6 +47,7 @@ public class Module : IModule builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(sp => sp.GetRequiredService()); diff --git a/Prefab.Web/Program.cs b/Prefab.Web/Program.cs index d244d00..519df87 100644 --- a/Prefab.Web/Program.cs +++ b/Prefab.Web/Program.cs @@ -18,6 +18,14 @@ builder.AddPrefab(); var app = builder.Build(); app.UsePrefab(); +// if (app.Environment.IsDevelopment()) +// { +// using var scope = app.Services.CreateScope(); +// var seeder = scope.ServiceProvider.GetRequiredService(); +// var db = scope.ServiceProvider.GetRequiredService(); +// await seeder.Execute(scope.ServiceProvider, CancellationToken.None); +// } + app.MapDefaultEndpoints(); // Configure the HTTP request pipeline. diff --git a/Prefab.Web/UrlPolicy/UrlPolicy.cs b/Prefab.Web/UrlPolicy/UrlPolicy.cs index a177d67..f5e5291 100644 --- a/Prefab.Web/UrlPolicy/UrlPolicy.cs +++ b/Prefab.Web/UrlPolicy/UrlPolicy.cs @@ -41,11 +41,11 @@ public static class UrlPolicy /// /// Builds a canonical catalog URL for navigating to a specific product detail page. /// - /// The slug of the product. - public static string BrowseProduct(string productSlug) + /// The slug of the product. + public static string BrowseProduct(string slug) { - ArgumentException.ThrowIfNullOrWhiteSpace(productSlug); - return $"/catalog/product?product-slug={Uri.EscapeDataString(productSlug)}"; + ArgumentException.ThrowIfNullOrWhiteSpace(slug); + return $"/browse/product?slug={Uri.EscapeDataString(slug)}"; } /// diff --git a/Prefab.Web/appsettings.Development.json b/Prefab.Web/appsettings.Development.json index 6b0f2a9..0ec435a 100644 --- a/Prefab.Web/appsettings.Development.json +++ b/Prefab.Web/appsettings.Development.json @@ -6,8 +6,8 @@ } }, "ConnectionStrings": { - "PrefabDb": "Server=127.0.0.1,14330;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True", - "PrefabDbReadOnly": "Server=127.0.0.1,14330;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True" + "PrefabDb": "Server=127.0.0.1,14331;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True", + "PrefabDbReadOnly": "Server=127.0.0.1,14331;Initial Catalog=prefab-db;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=True;Encrypt=False;MultipleActiveResultSets=True" }, "Prefab": { "Catalog": {