Latest
This commit is contained in:
12
.idea/.idea.Prefab/.idea/dataSources.xml
generated
Normal file
12
.idea/.idea.Prefab/.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="prefab-sql" uuid="d6595059-952f-4469-9f86-61215edece2c">
|
||||||
|
<driver-ref>sqlserver.jb</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.jetbrains.jdbc.sqlserver.SqlServerDriver</jdbc-driver>
|
||||||
|
<jdbc-url>Server=127.0.0.1,14331;User ID=sa;Password=Nq7K3YR2;TrustServerCertificate=true</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"SqlServer": {
|
"SqlServer": {
|
||||||
"ContainerName": "prefab-sql",
|
"ContainerName": "prefab-sql",
|
||||||
"HostPort": 14330,
|
"HostPort": 14331,
|
||||||
"UseDataVolume": true
|
"UseDataVolume": true
|
||||||
},
|
},
|
||||||
"Parameters": {
|
"Parameters": {
|
||||||
|
|||||||
43
Prefab.Catalog.Api/Controllers/ProductsController.cs
Normal file
43
Prefab.Catalog.Api/Controllers/ProductsController.cs
Normal file
@@ -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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ public class GetProductDetail : SharedProductDetail
|
|||||||
.Include(model => model.Options).ThenInclude(option => option.Tiers)
|
.Include(model => model.Options).ThenInclude(option => option.Tiers)
|
||||||
.Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions)
|
.Include(model => model.RuleSets).ThenInclude(rule => rule.Conditions)
|
||||||
.Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition)
|
.Include(model => model.Attributes).ThenInclude(attribute => attribute.AttributeDefinition)
|
||||||
|
.Include(model => model.Categories).ThenInclude(category => category.Category)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (product is null)
|
if (product is null)
|
||||||
@@ -78,12 +79,22 @@ public class GetProductDetail : SharedProductDetail
|
|||||||
var specDtos = MapSpecs(product);
|
var specDtos = MapSpecs(product);
|
||||||
var genericAttributes = await LoadGenericAttributesAsync(db, product.Id, cancellationToken);
|
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(
|
var payload = new SharedProductDetail.ProductDetailDto(
|
||||||
product.Id,
|
product.Id,
|
||||||
product.Name,
|
product.Name,
|
||||||
product.Slug ?? normalizedSlug,
|
product.Slug ?? normalizedSlug,
|
||||||
product.Description,
|
product.Description,
|
||||||
product.Price,
|
product.Price,
|
||||||
|
product.Sku,
|
||||||
|
categoryDtos,
|
||||||
optionDtos,
|
optionDtos,
|
||||||
specDtos,
|
specDtos,
|
||||||
genericAttributes);
|
genericAttributes);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public abstract class GetProductDetail
|
|||||||
string Slug,
|
string Slug,
|
||||||
string? Description,
|
string? Description,
|
||||||
decimal? Price,
|
decimal? Price,
|
||||||
|
string? Sku,
|
||||||
|
IReadOnlyList<CategoryDto> Categories,
|
||||||
IReadOnlyList<OptionDefinitionDto> Options,
|
IReadOnlyList<OptionDefinitionDto> Options,
|
||||||
IReadOnlyList<SpecDto> Specs,
|
IReadOnlyList<SpecDto> Specs,
|
||||||
IReadOnlyDictionary<string, string>? GenericAttributes);
|
IReadOnlyDictionary<string, string>? GenericAttributes);
|
||||||
@@ -74,4 +76,9 @@ public abstract class GetProductDetail
|
|||||||
string? Value,
|
string? Value,
|
||||||
decimal? NumericValue,
|
decimal? NumericValue,
|
||||||
string? UnitCode);
|
string? UnitCode);
|
||||||
|
|
||||||
|
public sealed record CategoryDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string Slug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public sealed class ProductClientHttp(HttpClient httpClient) : IProductClient, I
|
|||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Prefab.Handler;
|
using Prefab.Handler;
|
||||||
using Prefab.Web.Data;
|
using Prefab.Web.Data;
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
|
|||||||
catalogProject,
|
catalogProject,
|
||||||
catalogPort,
|
catalogPort,
|
||||||
overrides: null,
|
overrides: null,
|
||||||
configureServices: ConfigureTestAuthentication);
|
configureServices: ConfigureCatalogServices);
|
||||||
|
|
||||||
await CatalogApi.InitializeAsync();
|
await CatalogApi.InitializeAsync();
|
||||||
catalogAddress = CatalogApi.HttpAddress;
|
catalogAddress = CatalogApi.HttpAddress;
|
||||||
@@ -68,7 +69,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
|
|||||||
prefabProject,
|
prefabProject,
|
||||||
prefabPort,
|
prefabPort,
|
||||||
overrides,
|
overrides,
|
||||||
ConfigureTestAuthentication);
|
ConfigureWebServices);
|
||||||
|
|
||||||
await PrefabWeb.InitializeAsync();
|
await PrefabWeb.InitializeAsync();
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ public class PrefabCompositeFixture : IAsyncLifetime
|
|||||||
["Prefab__Catalog__Client__BaseAddress"] = catalogAddress?.ToString().TrimEnd('/') ?? string.Empty
|
["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();
|
var descriptors = services.Where(d => d.ServiceType == typeof(AuthenticationStateProvider)).ToList();
|
||||||
foreach (var descriptor in descriptors)
|
foreach (var descriptor in descriptors)
|
||||||
@@ -125,6 +126,10 @@ public class PrefabCompositeFixture : IAsyncLifetime
|
|||||||
services.AddSingleton<AuthenticationStateProvider>(_ => _authenticationProvider);
|
services.AddSingleton<AuthenticationStateProvider>(_ => _authenticationProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected virtual void ConfigureCatalogServices(IServiceCollection services) => ConfigureTestAuthentication(services);
|
||||||
|
|
||||||
|
protected virtual void ConfigureWebServices(IServiceCollection services) => ConfigureTestAuthentication(services);
|
||||||
|
|
||||||
private static PrefabHarnessOptions ResolveDefaultOptions()
|
private static PrefabHarnessOptions ResolveDefaultOptions()
|
||||||
{
|
{
|
||||||
var modeEnv = Environment.GetEnvironmentVariable("PREFAB_TEST_MODE")?.Trim().ToLowerInvariant();
|
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<string, string?> GetWebOverrides(Uri? catalogAddress) =>
|
||||||
|
new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Prefab__Catalog__Client__Transport"] = "InProcess"
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override void ConfigureWebServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
base.ConfigureWebServices(services);
|
||||||
|
|
||||||
|
services.RemoveAll<Prefab.Shared.Catalog.IModuleClient>();
|
||||||
|
services.RemoveAll<Prefab.Shared.Catalog.Categories.ICategoryClient>();
|
||||||
|
services.RemoveAll<Prefab.Shared.Catalog.Products.IProductClient>();
|
||||||
|
services.RemoveAll<Prefab.Shared.Catalog.Products.IPriceQuoteClient>();
|
||||||
|
|
||||||
|
services.AddScoped<Prefab.Shared.Catalog.Categories.ICategoryClient>(_ => _categoryClient);
|
||||||
|
services.AddScoped<Prefab.Shared.Catalog.Products.IPriceQuoteClient>(_ => _priceQuoteClient);
|
||||||
|
services.AddScoped<Prefab.Shared.Catalog.Products.IProductClient>(_ =>
|
||||||
|
_productClient ?? throw new InvalidOperationException("Test product client has not been configured."));
|
||||||
|
services.AddScoped<Prefab.Shared.Catalog.IModuleClient>(sp =>
|
||||||
|
new Prefab.Catalog.ModuleClient(
|
||||||
|
sp.GetRequiredService<Prefab.Shared.Catalog.Categories.ICategoryClient>(),
|
||||||
|
sp.GetRequiredService<Prefab.Shared.Catalog.Products.IProductClient>(),
|
||||||
|
sp.GetRequiredService<Prefab.Shared.Catalog.Products.IPriceQuoteClient>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullCategoryClient : Prefab.Shared.Catalog.Categories.ICategoryClient
|
||||||
|
{
|
||||||
|
public Task<Prefab.Shared.Catalog.Categories.GetCategories.Response> GetCategories(
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Category retrieval is not configured for this test.");
|
||||||
|
|
||||||
|
public Task<Prefab.Shared.Catalog.Categories.CreateCategory.Response> 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<Prefab.Shared.Catalog.Products.QuotePrice.Response> Quote(
|
||||||
|
Prefab.Shared.Catalog.Products.QuotePrice.Request request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Price quote client is not configured for this test.");
|
||||||
|
|
||||||
|
public Task<Prefab.Shared.Catalog.Products.QuotePrice.Response> 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")]
|
[CollectionDefinition("Prefab.Debug")]
|
||||||
public sealed class PrefabDebugCollection : ICollectionFixture<PrefabCompositeFixture_Debug>
|
public sealed class PrefabDebugCollection : ICollectionFixture<PrefabCompositeFixture_Debug>
|
||||||
{
|
{
|
||||||
@@ -204,3 +282,8 @@ public sealed class PrefabEphemeralCollection : ICollectionFixture<PrefabComposi
|
|||||||
public sealed class PrefabInProcessCollection : ICollectionFixture<PrefabCompositeFixture_InProcess>
|
public sealed class PrefabInProcessCollection : ICollectionFixture<PrefabCompositeFixture_InProcess>
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition("Prefab.BffProduct")]
|
||||||
|
public sealed class PrefabBffProductCollection : ICollectionFixture<PrefabCompositeFixture_BffProduct>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|||||||
165
Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs
Normal file
165
Prefab.Tests/Integration/Web/Gateway/ProductDetailShould.cs
Normal file
@@ -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<PrefabCompositeFixture_BffProduct>
|
||||||
|
{
|
||||||
|
[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<Result<ProductDisplayModel>>(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<Result<ProductDisplayModel>>(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<GetProductDetail.OptionTierDto>(),
|
||||||
|
Values: optionValues,
|
||||||
|
Rules: null)
|
||||||
|
};
|
||||||
|
|
||||||
|
var specs = new[]
|
||||||
|
{
|
||||||
|
new GetProductDetail.SpecDto("Weight", "25 lb", null, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
var attributes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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.Response> 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.Response> GetCategory(string slug, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Category client is not required for this test.");
|
||||||
|
|
||||||
|
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Category tree is not required for this test.");
|
||||||
|
|
||||||
|
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult(response);
|
||||||
|
|
||||||
|
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Price quotes are not required for this test.");
|
||||||
|
|
||||||
|
public Task<QuotePrice.Response> 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.Response> 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.Response> GetCategory(string slug, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Category client is not required for this test.");
|
||||||
|
|
||||||
|
public Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Category tree is not required for this test.");
|
||||||
|
|
||||||
|
public Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken) =>
|
||||||
|
throw new CatalogNotFoundException("Product", slug);
|
||||||
|
|
||||||
|
public Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Price quotes are not required for this test.");
|
||||||
|
|
||||||
|
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Price quotes are not required for this test.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,4 +72,11 @@ public sealed class UrlPolicyShould
|
|||||||
|
|
||||||
UrlPolicy.Products(page, pageSize, sort, view).ShouldBe(url);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ public sealed class ProductCardShould : BunitContext
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Title = "Aluminum Chandelier",
|
Title = "Aluminum Chandelier",
|
||||||
Url = "/catalog/product/aluminum-chandelier",
|
Url = "/browse/product?slug=aluminum-chandelier",
|
||||||
Slug = "aluminum-chandelier",
|
Slug = "aluminum-chandelier",
|
||||||
CategoryName = "Chandeliers",
|
CategoryName = "Chandeliers",
|
||||||
CategoryUrl = "/catalog/chandeliers",
|
CategoryUrl = "/catalog/chandeliers",
|
||||||
|
|||||||
128
Prefab.Tests/Web.Client/Pages/ProductPageTests.cs
Normal file
128
Prefab.Tests/Web.Client/Pages/ProductPageTests.cs
Normal file
@@ -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<IProductDisplayService>(_ =>
|
||||||
|
new FakeProductDisplayService(Result<ProductDisplayModel>.Success(model)));
|
||||||
|
|
||||||
|
var navigation = Services.GetRequiredService<NavigationManager>();
|
||||||
|
navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", model.Slug));
|
||||||
|
|
||||||
|
var component = Render<Product>();
|
||||||
|
|
||||||
|
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<IProductDisplayService>(_ =>
|
||||||
|
new FakeProductDisplayService(Result<ProductDisplayModel>.Failure(new ResultProblem
|
||||||
|
{
|
||||||
|
Title = "Product not found.",
|
||||||
|
Detail = "Product 'unknown' was not found.",
|
||||||
|
StatusCode = (int)HttpStatusCode.NotFound
|
||||||
|
})));
|
||||||
|
|
||||||
|
var navigation = Services.GetRequiredService<NavigationManager>();
|
||||||
|
navigation.NavigateTo(navigation.GetUriWithQueryParameter("slug", "unknown"));
|
||||||
|
|
||||||
|
var component = Render<Product>();
|
||||||
|
|
||||||
|
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 = "<p>Sample description.</p>",
|
||||||
|
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<ProductDisplayModel.Tier>(),
|
||||||
|
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<ProductDisplayModel.OptionValue>())
|
||||||
|
],
|
||||||
|
Specs =
|
||||||
|
[
|
||||||
|
new ProductDisplayModel.Spec("Weight", "25 lb", null, null)
|
||||||
|
],
|
||||||
|
GenericAttributes = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["catalog.product.docs"] = """[{ "title": "Spec Sheet", "url": "https://example.com/spec.pdf" }]"""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class FakeProductDisplayService(Result<ProductDisplayModel> result) : IProductDisplayService
|
||||||
|
{
|
||||||
|
private readonly Result<ProductDisplayModel> _result = result;
|
||||||
|
|
||||||
|
public Task<Result<ProductDisplayModel>> Get(string slug, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Prefab.Web.Client/Components/Catalog/ProductDocs.razor
Normal file
42
Prefab.Web.Client/Components/Catalog/ProductDocs.razor
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@using System.Text.Json
|
||||||
|
|
||||||
|
@if (_documents.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="product-docs mt-4">
|
||||||
|
<h5 class="mb-3">Documents</h5>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
@foreach (var doc in _documents)
|
||||||
|
{
|
||||||
|
<li class="mb-2">
|
||||||
|
<a href="@doc.Url" target="_blank" rel="noopener noreferrer">@doc.Title</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Json { get; set; } = "[]";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private List<DocumentLink> _documents { get; set; } = new();
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_documents = JsonSerializer.Deserialize<List<DocumentLink>>(Json, SerializerOptions) ?? new List<DocumentLink>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_documents = new List<DocumentLink>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DocumentLink(string Title, string Url);
|
||||||
|
}
|
||||||
132
Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs
Normal file
132
Prefab.Web.Client/Models/Catalog/ProductDisplayModel.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
|
||||||
|
namespace Prefab.Web.Client.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-facing projection of the catalog product detail configuration payload.
|
||||||
|
/// </summary>
|
||||||
|
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<CategoryRef> Categories { get; init; } = Array.Empty<CategoryRef>();
|
||||||
|
|
||||||
|
public IReadOnlyList<OptionDefinition> Options { get; init; } = Array.Empty<OptionDefinition>();
|
||||||
|
|
||||||
|
public IReadOnlyList<Spec> Specs { get; init; } = Array.Empty<Spec>();
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string>? 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<Tier> Tiers,
|
||||||
|
IReadOnlyList<OptionValue> 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<Tier>(),
|
||||||
|
option.Values?
|
||||||
|
.Select(value => new OptionValue(
|
||||||
|
value.Id,
|
||||||
|
value.Code,
|
||||||
|
value.Label,
|
||||||
|
value.PriceDelta,
|
||||||
|
value.PriceDeltaKind))
|
||||||
|
.ToList() ?? new List<OptionValue>()))
|
||||||
|
.ToList() ?? new List<OptionDefinition>();
|
||||||
|
|
||||||
|
var specs = product.Specs?
|
||||||
|
.Select(spec => new Spec(
|
||||||
|
spec.Name,
|
||||||
|
spec.Value,
|
||||||
|
spec.NumericValue,
|
||||||
|
spec.UnitCode))
|
||||||
|
.ToList() ?? new List<Spec>();
|
||||||
|
|
||||||
|
var categories = product.Categories?
|
||||||
|
.Select(category => new CategoryRef(
|
||||||
|
category.Id,
|
||||||
|
category.Name,
|
||||||
|
category.Slug))
|
||||||
|
.ToList() ?? new List<CategoryRef>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
422
Prefab.Web.Client/Pages/Browse/Product.razor
Normal file
422
Prefab.Web.Client/Pages/Browse/Product.razor
Normal file
@@ -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
|
||||||
|
|
||||||
|
<PageTitle>@(Model?.Name ?? "Product") | Prefab</PageTitle>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pdp-skeleton--fallback {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdp-skeleton__block {
|
||||||
|
background-color: #f1f3f5;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdp-skeleton__block--image {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdp-skeleton__block--text {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdp-skeleton__block--options {
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container my-4">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
if (HasTelerikSkeleton)
|
||||||
|
{
|
||||||
|
<div class="pdp-skeleton">
|
||||||
|
<TelerikSkeleton Width="100%" Height="320px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Rectangle" />
|
||||||
|
<TelerikSkeleton Width="55%" Height="28px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Text" Class="mt-4" />
|
||||||
|
<TelerikSkeleton Width="100%" Height="220px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Rectangle" Class="mt-4" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="pdp-skeleton pdp-skeleton--fallback">
|
||||||
|
<div class="pdp-skeleton__block pdp-skeleton__block--image"></div>
|
||||||
|
<div class="pdp-skeleton__block pdp-skeleton__block--text"></div>
|
||||||
|
<div class="pdp-skeleton__block pdp-skeleton__block--options"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Problem is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>@(Problem.Title ?? "Product unavailable")</strong>
|
||||||
|
<div>@(Problem.Detail ?? "We couldn't load this product right now. Please try again later.")</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model is not null)
|
||||||
|
{
|
||||||
|
var product = Model;
|
||||||
|
var hasDocs = TryGetDocs(product, out var docsJson);
|
||||||
|
|
||||||
|
<div class="product product--layout--standard">
|
||||||
|
<div class="product__content">
|
||||||
|
<div class="product__gallery">
|
||||||
|
<div class="product-gallery">
|
||||||
|
<div class="product-gallery__featured">
|
||||||
|
<img src="/images/placeholder-600x600.png" alt="@product.Name" class="img-fluid" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product__info">
|
||||||
|
<div class="product__meta mb-2">
|
||||||
|
<ul class="product__meta-list list-unstyled mb-2">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(product.Sku))
|
||||||
|
{
|
||||||
|
<li><span class="text-muted">SKU:</span> @product.Sku</li>
|
||||||
|
}
|
||||||
|
@if (product.Categories.Any())
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<span class="text-muted">Category:</span>
|
||||||
|
@for (var index = 0; index < product.Categories.Count; index++)
|
||||||
|
{
|
||||||
|
var category = product.Categories[index];
|
||||||
|
<span>@category.Name</span>
|
||||||
|
if (index < product.Categories.Count - 1)
|
||||||
|
{
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="product__name">@product.Name</h1>
|
||||||
|
|
||||||
|
@if (product.BasePrice.HasValue)
|
||||||
|
{
|
||||||
|
<div class="product__price mt-3">
|
||||||
|
<span class="product__price-new">@FormatCurrency(product.BasePrice.Value)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(product.Description))
|
||||||
|
{
|
||||||
|
<div class="product__description mt-3">@((MarkupString)product.Description)</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (product.Options.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="product__options mt-4">
|
||||||
|
@foreach (var option in product.Options)
|
||||||
|
{
|
||||||
|
var optionIsChoice = option.DataType == (int)GetProductDetail.OptionDataType.Choice;
|
||||||
|
|
||||||
|
<div class="product__option mb-4">
|
||||||
|
<label class="product__option-label d-block fw-semibold">
|
||||||
|
@option.Name
|
||||||
|
@if (option.IsVariantAxis)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary ms-2">Axis</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (optionIsChoice)
|
||||||
|
{
|
||||||
|
<TelerikRadioGroup TValue="string" TItem="ChoiceItem"
|
||||||
|
Class="product__option-group"
|
||||||
|
Data="@BuildChoiceItems(option)"
|
||||||
|
TextField="@nameof(ChoiceItem.Text)"
|
||||||
|
ValueField="@nameof(ChoiceItem.Value)"
|
||||||
|
Value="@GetChoiceSelection(option.Id)"
|
||||||
|
ValueChanged="(string? value) => SetChoiceSelection(option.Id, value)" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="product__number-option d-flex align-items-center">
|
||||||
|
<TelerikNumericTextBox Value="@GetNumberSelection(option.Id)"
|
||||||
|
ValueChanged="(decimal? value) => SetNumberSelection(option.Id, value)"
|
||||||
|
Min="@option.Min"
|
||||||
|
Max="@option.Max"
|
||||||
|
Step="@ResolveStep(option)"
|
||||||
|
Format="n2"
|
||||||
|
Width="200px" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(option.Unit))
|
||||||
|
{
|
||||||
|
<small class="text-muted ms-2">Unit: @option.Unit</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (option.Tiers.Count > 0)
|
||||||
|
{
|
||||||
|
<small class="text-muted d-block mt-1">Tiered pricing data available.</small>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="product__actions mt-4 d-flex align-items-center">
|
||||||
|
<div class="product__actions-item me-3 d-flex align-items-center">
|
||||||
|
<label class="form-label me-2 mb-0">Quantity</label>
|
||||||
|
<TelerikNumericTextBox Value="@_quantity"
|
||||||
|
ValueChanged="(int value) => _quantity = Math.Max(1, value)"
|
||||||
|
Min="1"
|
||||||
|
Step="1"
|
||||||
|
Format="n0"
|
||||||
|
Width="150px" />
|
||||||
|
</div>
|
||||||
|
<div class="product__actions-item">
|
||||||
|
<TelerikButton Class="btn btn-primary btn-lg product__add-to-cart"
|
||||||
|
ThemeColor="ThemeColor.Primary"
|
||||||
|
Size="ButtonSize.Large">
|
||||||
|
Add to cart
|
||||||
|
</TelerikButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card product__tabs product-tabs product-tabs--layout--full mt-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<TelerikTabStrip TabPosition="TabPosition.Top" Class="product-tabs__strip">
|
||||||
|
<TabStripTab Title="Description">
|
||||||
|
<div class="product__tab-description">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(product.Description))
|
||||||
|
{
|
||||||
|
<div class="typography">@((MarkupString)product.Description)</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted">No description available.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasDocs && docsJson is not null)
|
||||||
|
{
|
||||||
|
<ProductDocs Json="@docsJson" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</TabStripTab>
|
||||||
|
<TabStripTab Title="Specification">
|
||||||
|
<div class="product__tab-specification">
|
||||||
|
@if (product.Specs.Any())
|
||||||
|
{
|
||||||
|
<table class="table table-striped">
|
||||||
|
<tbody>
|
||||||
|
@foreach (var spec in product.Specs)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<th scope="row">@spec.Name</th>
|
||||||
|
<td>@FormatSpecValue(spec)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted">No specifications were provided.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</TabStripTab>
|
||||||
|
</TelerikTabStrip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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<ProductDisplayModel>? _result;
|
||||||
|
private string? _currentSlug;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private PersistingComponentStateSubscription? _subscription;
|
||||||
|
private readonly Dictionary<Guid, string?> _choiceSelections = new();
|
||||||
|
private readonly Dictionary<Guid, decimal?> _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<PageState>(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<ProductDisplayModel>.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<ChoiceItem> 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<ProductDisplayModel> Result);
|
||||||
|
private sealed record ChoiceItem(string Value, string Text);
|
||||||
|
}
|
||||||
@@ -20,5 +20,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
|
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
|
||||||
|
<ProjectReference Include="..\Prefab.Shared\Prefab.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ builder.Services.AddScoped<ICategoriesPageService, CategoriesPageService>();
|
|||||||
builder.Services.AddScoped<INavMenuService, NavMenuService>();
|
builder.Services.AddScoped<INavMenuService, NavMenuService>();
|
||||||
builder.Services.AddScoped<IProductListingService, ProductListingService>();
|
builder.Services.AddScoped<IProductListingService, ProductListingService>();
|
||||||
builder.Services.AddScoped<IHomePageService, HomePageService>();
|
builder.Services.AddScoped<IHomePageService, HomePageService>();
|
||||||
|
builder.Services.AddScoped<IProductDisplayService, ProductDisplayService>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
34
Prefab.Web.Client/Services/ProductDisplayService.cs
Normal file
34
Prefab.Web.Client/Services/ProductDisplayService.cs
Normal file
@@ -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<Result<ProductDisplayModel>> Get(string slug, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProductDisplayService(HttpClient httpClient) : IProductDisplayService
|
||||||
|
{
|
||||||
|
public async Task<Result<ProductDisplayModel>> 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<Result<ProductDisplayModel>>(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<ProductDisplayModel>.Failure(problem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ using Prefab.Domain.Exceptions;
|
|||||||
using Prefab.Endpoints;
|
using Prefab.Endpoints;
|
||||||
using Prefab.Shared.Catalog;
|
using Prefab.Shared.Catalog;
|
||||||
using Prefab.Shared.Catalog.Products;
|
using Prefab.Shared.Catalog.Products;
|
||||||
|
using Prefab.Shared;
|
||||||
using Prefab.Web.Client.Models.Catalog;
|
using Prefab.Web.Client.Models.Catalog;
|
||||||
using Prefab.Web.Client.Models.Shared;
|
using Prefab.Web.Client.Models.Shared;
|
||||||
using Prefab.Web.Client.ViewModels.Catalog;
|
using Prefab.Web.Client.ViewModels.Catalog;
|
||||||
using Prefab.Web.Client.Services;
|
using Prefab.Web.Client.Services;
|
||||||
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
|
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
|
||||||
|
using CatalogNotFoundException = Prefab.Catalog.Domain.Exceptions.CatalogNotFoundException;
|
||||||
|
|
||||||
namespace Prefab.Web.Gateway;
|
namespace Prefab.Web.Gateway;
|
||||||
|
|
||||||
@@ -29,6 +31,18 @@ public static class Products
|
|||||||
return Results.Json(result, statusCode: status);
|
return Results.Json(result, statusCode: status);
|
||||||
})
|
})
|
||||||
.WithTags(nameof(Products));
|
.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<Result<ProductDisplayModel>> Get(string slug, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
{
|
||||||
|
return Result<ProductDisplayModel>.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<ProductDisplayModel>.Success(model);
|
||||||
|
}
|
||||||
|
catch (ValidationException validationException)
|
||||||
|
{
|
||||||
|
var problem = ResultProblem.FromValidation(validationException);
|
||||||
|
return Result<ProductDisplayModel>.Failure(problem);
|
||||||
|
}
|
||||||
|
catch (CatalogNotFoundException)
|
||||||
|
{
|
||||||
|
return Result<ProductDisplayModel>.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<ProductDisplayModel>.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<ProductDisplayModel>.Failure(problem);
|
||||||
|
}
|
||||||
|
catch (RemoteProblemException ex)
|
||||||
|
{
|
||||||
|
var problem = ResultProblem.Unexpected("Failed to retrieve product.", ex, ex.StatusCode);
|
||||||
|
return Result<ProductDisplayModel>.Failure(problem);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
var problem = ResultProblem.Unexpected("Failed to retrieve product.", exception);
|
||||||
|
return Result<ProductDisplayModel>.Failure(problem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ProductClientExtensions
|
||||||
|
{
|
||||||
|
public static Task<GetProductDetail.Response> GetProductConfig(
|
||||||
|
this IProductClient productClient,
|
||||||
|
string slug,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
productClient.GetProductDetail(slug, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class Module : IModule
|
|||||||
|
|
||||||
builder.Services.AddScoped<ICatalogDbContextFactory, CatalogDbContextFactory>();
|
builder.Services.AddScoped<ICatalogDbContextFactory, CatalogDbContextFactory>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<DataSeeder>();
|
//builder.Services.AddHostedService<DataSeeder>();
|
||||||
|
|
||||||
|
|
||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
@@ -47,6 +47,7 @@ public class Module : IModule
|
|||||||
builder.Services.AddScoped<ICategoriesPageService, Categories.PageService>();
|
builder.Services.AddScoped<ICategoriesPageService, Categories.PageService>();
|
||||||
builder.Services.AddScoped<Home.Service>();
|
builder.Services.AddScoped<Home.Service>();
|
||||||
builder.Services.AddScoped<Products.ListingService>();
|
builder.Services.AddScoped<Products.ListingService>();
|
||||||
|
builder.Services.AddScoped<Products.DetailService>();
|
||||||
builder.Services.AddScoped<IProductListingService>(sp => sp.GetRequiredService<Products.ListingService>());
|
builder.Services.AddScoped<IProductListingService>(sp => sp.GetRequiredService<Products.ListingService>());
|
||||||
builder.Services.AddScoped<IHomePageService>(sp => sp.GetRequiredService<Home.Service>());
|
builder.Services.AddScoped<IHomePageService>(sp => sp.GetRequiredService<Home.Service>());
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ builder.AddPrefab();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UsePrefab();
|
app.UsePrefab();
|
||||||
|
// if (app.Environment.IsDevelopment())
|
||||||
|
// {
|
||||||
|
// using var scope = app.Services.CreateScope();
|
||||||
|
// var seeder = scope.ServiceProvider.GetRequiredService<Prefab.Catalog.Data.Seeder>();
|
||||||
|
// var db = scope.ServiceProvider.GetRequiredService<Prefab.Catalog.Data.IModuleDb>();
|
||||||
|
// await seeder.Execute(scope.ServiceProvider, CancellationToken.None);
|
||||||
|
// }
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ public static class UrlPolicy
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a canonical catalog URL for navigating to a specific product detail page.
|
/// Builds a canonical catalog URL for navigating to a specific product detail page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="productSlug">The slug of the product.</param>
|
/// <param name="slug">The slug of the product.</param>
|
||||||
public static string BrowseProduct(string productSlug)
|
public static string BrowseProduct(string slug)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(productSlug);
|
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
|
||||||
return $"/catalog/product?product-slug={Uri.EscapeDataString(productSlug)}";
|
return $"/browse/product?slug={Uri.EscapeDataString(slug)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"PrefabDb": "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,14330;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": {
|
"Prefab": {
|
||||||
"Catalog": {
|
"Catalog": {
|
||||||
|
|||||||
Reference in New Issue
Block a user