Files
prefab-page-detail/Prefab.Web/Gateway/Home.cs
2025-10-27 17:39:18 -04:00

207 lines
7.5 KiB
C#

using System.Net;
using FluentValidation;
using Prefab.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Client.Models.Catalog;
using Prefab.Web.Client.Models.Home;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Services;
using Prefab.Web.Client.ViewModels.Catalog;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway;
public static class Home
{
public sealed class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/bff/home",
async (Service service, CancellationToken cancellationToken) =>
{
var result = await service.Get(cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Home));
}
}
public sealed class Service(IModuleClient moduleClient, IProductListingService listingService) : IHomePageService
{
private const int MaxCategories = 8;
private const int MaxFeaturedProducts = 8;
public async Task<Result<HomePageModel>> Get(CancellationToken cancellationToken)
{
try
{
var response = await moduleClient.Product.GetCategoryTree(depth: 3, cancellationToken);
var nodes = response.Categories ?? [];
var leafNodes = Flatten(nodes)
.Where(node => node.IsLeaf && !string.IsNullOrWhiteSpace(node.Slug))
.ToList();
var categories = new List<CategoryCardModel>(MaxCategories);
var featuredProducts = new List<ProductCardModel>(MaxFeaturedProducts);
var seenCategoryIds = new HashSet<Guid>();
var seenProductKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await CollectAsync(
OrderCandidates(leafNodes.Where(node => node.IsFeatured)),
categories,
featuredProducts,
seenCategoryIds,
seenProductKeys,
cancellationToken);
if (categories.Count < MaxCategories || featuredProducts.Count < MaxFeaturedProducts)
{
await CollectAsync(
OrderCandidates(leafNodes.Where(node => !node.IsFeatured)),
categories,
featuredProducts,
seenCategoryIds,
seenProductKeys,
cancellationToken);
}
var model = new HomePageModel
{
LatestCategories = categories,
FeaturedProducts = featuredProducts
};
return Result<HomePageModel>.Success(model);
}
catch (ValidationException validationException)
{
var problem = ResultProblem.FromValidation(validationException);
return Result<HomePageModel>.Failure(problem);
}
catch (DomainException domainException)
{
var problem = ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity);
return Result<HomePageModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to retrieve home page content.", exception);
return Result<HomePageModel>.Failure(problem);
}
}
private async Task CollectAsync(
IEnumerable<GetCategoryModels.CategoryNodeDto> candidates,
ICollection<CategoryCardModel> categories,
ICollection<ProductCardModel> featuredProducts,
ISet<Guid> seenCategoryIds,
ISet<string> seenProductKeys,
CancellationToken cancellationToken)
{
foreach (var candidate in candidates)
{
if (categories.Count >= MaxCategories && featuredProducts.Count >= MaxFeaturedProducts)
{
break;
}
Result<ProductListingModel>? listingResult = null;
try
{
listingResult = await listingService.GetCategoryProducts(candidate.Slug, cancellationToken);
}
catch (DomainException)
{
continue;
}
if (!listingResult.IsSuccess || listingResult.Value is not { Products.Count: > 0 } listing)
{
continue;
}
if (categories.Count < MaxCategories && seenCategoryIds.Add(candidate.Id))
{
categories.Add(new CategoryCardModel
{
Title = candidate.Name,
Url = UrlBuilder.BrowseProducts(candidate.Slug),
ImageUrl = string.IsNullOrWhiteSpace(candidate.HeroImageUrl) ? null : candidate.HeroImageUrl,
SecondaryText = FormatProductCount(listing.Total)
});
}
if (featuredProducts.Count >= MaxFeaturedProducts)
{
continue;
}
foreach (var product in listing.Products)
{
var key = GetProductKey(product);
if (!seenProductKeys.Add(key))
{
continue;
}
featuredProducts.Add(product);
if (featuredProducts.Count >= MaxFeaturedProducts)
{
break;
}
}
}
}
private static string GetProductKey(ProductCardModel product)
{
if (!string.IsNullOrWhiteSpace(product.Sku))
{
return product.Sku!;
}
if (!string.IsNullOrWhiteSpace(product.Url))
{
return product.Url!;
}
return product.Title;
}
private static IEnumerable<GetCategoryModels.CategoryNodeDto> OrderCandidates(
IEnumerable<GetCategoryModels.CategoryNodeDto> candidates) =>
candidates
.OrderBy(candidate => candidate.DisplayOrder)
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase);
private static string FormatProductCount(int total) =>
total == 1 ? "1 Product" : $"{total} Products";
private static IEnumerable<GetCategoryModels.CategoryNodeDto> Flatten(
IEnumerable<GetCategoryModels.CategoryNodeDto> nodes)
{
foreach (var node in nodes)
{
yield return node;
if (node.Children is { Count: > 0 })
{
foreach (var child in Flatten(node.Children))
{
yield return child;
}
}
}
}
}
}