This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
using System.Net;
using FluentValidation;
using Prefab.Domain.Exceptions;
using Prefab.Endpoints;
using Prefab.Web.Client.Models;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Pages;
using Prefab.Web.Client.Services;
namespace Prefab.Web.Gateway;
public static class Categories
{
public class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/bff/categories/page",
async (ICategoriesPageService service, CancellationToken cancellationToken) =>
{
var result = await service.GetPage(cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Categories));
}
}
public class PageService(Prefab.Shared.Catalog.IModuleClient moduleClient) : ICategoriesPageService
{
public async Task<Result<CategoriesPageModel>> GetPage(CancellationToken cancellationToken)
{
try
{
var response = await moduleClient.Category.GetCategories(cancellationToken);
var model = new CategoriesPageModel
{
Categories = response.Categories.Select(x => new CategoryListItemModel
{
Id = x.Id,
Name = x.Name,
ParentName = x.ParentName
})
};
return Result<CategoriesPageModel>.Success(model);
}
catch (Exception exception)
{
var problem = exception switch
{
ValidationException validationException => ResultProblem.FromValidation(validationException),
DomainException domainException => ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity),
_ => ResultProblem.Unexpected("Failed to retrieve categories.", exception)
};
return Result<CategoriesPageModel>.Failure(problem);
}
}
}
}

206
Prefab.Web/Gateway/Home.cs Normal file
View File

@@ -0,0 +1,206 @@
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;
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
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.Shared;
using Prefab.Web.Client.ViewModels.Catalog;
using Prefab.Web.Client.Services;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway;
public static class Products
{
public sealed class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet(
"/bff/catalog/categories/{slug}/models",
async (ListingService service, string slug, CancellationToken cancellationToken) =>
{
var result = await service.GetCategoryProducts(slug, cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithTags(nameof(Products));
}
}
public sealed class ListingService(IModuleClient moduleClient) : IProductListingService
{
public async Task<Result<ProductListingModel>> GetCategoryProducts(string categorySlug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(categorySlug);
try
{
var response = await moduleClient.Product.GetCategoryModels(
categorySlug.Trim(),
page: null,
pageSize: null,
sort: null,
direction: null,
cancellationToken);
var listing = MapListing(response.Result);
return Result<ProductListingModel>.Success(listing);
}
catch (ValidationException validationException)
{
var problem = ResultProblem.FromValidation(validationException);
return Result<ProductListingModel>.Failure(problem);
}
catch (DomainException domainException)
{
var problem = ResultProblem.Unexpected(domainException.Message, domainException, HttpStatusCode.UnprocessableEntity);
return Result<ProductListingModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to retrieve category products.", exception);
return Result<ProductListingModel>.Failure(problem);
}
}
private static ProductListingModel MapListing(GetCategoryModels.CategoryProductsResponse payload)
{
var category = payload.Category;
var categoryModel = new ProductCategoryModel
{
Id = category.Id,
Name = category.Name,
Slug = category.Slug,
Description = category.Description,
HeroImageUrl = category.HeroImageUrl,
Icon = category.Icon
};
var cards = payload.Products
.Select(product => new ProductCardModel
{
Id = product.Id,
Title = product.Name,
Url = UrlBuilder.BrowseProduct(product.Slug),
Slug = product.Slug,
PrimaryImageUrl = product.PrimaryImageUrl,
CategoryName = category.Name,
CategoryUrl = UrlBuilder.BrowseCategory(category.Slug),
FromPrice = product.FromPrice.HasValue
? new MoneyModel
{
Amount = product.FromPrice.Value
}
: null,
IsPriced = product.FromPrice.HasValue,
OldPrice = null,
IsOnSale = false,
Rating = 0,
ReviewCount = 0,
Badges = new List<string>(),
LastModifiedOn = product.LastModifiedOn
})
.ToList();
return new ProductListingModel
{
Category = categoryModel,
Products = cards,
Total = payload.Total,
Page = payload.Page,
PageSize = payload.PageSize
};
}
}
}

View File

@@ -0,0 +1,129 @@
using FluentValidation;
using Prefab.Endpoints;
using Prefab.Shared.Catalog;
using Prefab.Shared.Catalog.Products;
using Prefab.Web.Client.Models.Shared;
using Prefab.Web.Client.Services;
using UrlBuilder = Prefab.Web.UrlPolicy.UrlPolicy;
namespace Prefab.Web.Gateway.Shared;
public static class NavMenu
{
public class Endpoints : IEndpointRegistrar
{
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/bff/nav-menu/{depth:int}", async (INavMenuService service, int depth, CancellationToken cancellationToken) =>
{
var result = await service.Get(depth, cancellationToken);
var status = result.Problem?.StatusCode ?? (result.IsSuccess
? StatusCodes.Status200OK
: StatusCodes.Status500InternalServerError);
return Results.Json(result, statusCode: status);
})
.WithName(nameof(NavMenu));
}
}
public class Service(IModuleClient moduleClient) : INavMenuService
{
private const int MaxItemsPerColumn = 8;
public async Task<Result<NavMenuModel>> Get(int depth, CancellationToken cancellationToken)
{
var normalizedDepth = Math.Clamp(depth, 1, 2);
try
{
var response = await moduleClient.Product.GetCategoryTree(normalizedDepth, cancellationToken);
var columns = BuildColumns(response.Categories);
var model = new NavMenuModel(normalizedDepth, MaxItemsPerColumn, columns);
return Result<NavMenuModel>.Success(model);
}
catch (ValidationException exception)
{
var problem = ResultProblem.FromValidation(exception);
return Result<NavMenuModel>.Failure(problem);
}
catch (Exception exception)
{
var problem = ResultProblem.Unexpected("Failed to load navigation menu.", exception);
return Result<NavMenuModel>.Failure(problem);
}
}
private static IReadOnlyList<NavMenuColumnModel> BuildColumns(IReadOnlyList<GetCategoryModels.CategoryNodeDto>? roots)
{
if (roots is null || roots.Count == 0)
{
return [];
}
var columns = new List<NavMenuColumnModel>(roots.Count);
columns.AddRange(roots.Select(BuildColumn));
return columns;
}
private static NavMenuColumnModel BuildColumn(GetCategoryModels.CategoryNodeDto root)
{
var rootSlug = NormalizeSlug(root.Slug);
var isRootLeaf = root.IsLeaf;
var rootUrl = isRootLeaf
? UrlBuilder.BrowseProducts(rootSlug)
: UrlBuilder.BrowseCategory(rootSlug);
var rootLink = new NavMenuLinkModel(
root.Id.ToString(),
root.Name,
rootSlug,
isRootLeaf,
rootUrl);
var children = (root.Children ?? [])
.OrderBy(child => child.DisplayOrder)
.ThenBy(child => child.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var featured = children
.Where(child => child.IsFeatured)
.ToList();
var menuCandidates = featured.Count > 0 ? featured : children;
var items = menuCandidates
.Take(MaxItemsPerColumn)
.Select(ToLink)
.ToList();
var hasMore = menuCandidates.Count > items.Count;
var seeAllUrl = isRootLeaf
? UrlBuilder.BrowseProducts(rootSlug)
: UrlBuilder.BrowseCategory(rootSlug);
return new NavMenuColumnModel(rootLink, items, hasMore, seeAllUrl);
}
private static NavMenuLinkModel ToLink(GetCategoryModels.CategoryNodeDto node)
{
var slug = NormalizeSlug(node.Slug);
var isLeaf = node.IsLeaf;
var url = isLeaf
? UrlBuilder.BrowseProducts(slug)
: UrlBuilder.BrowseCategory(slug);
return new NavMenuLinkModel(
node.Id.ToString(),
node.Name,
slug,
isLeaf,
url);
}
private static string NormalizeSlug(string slug) =>
string.IsNullOrWhiteSpace(slug)
? string.Empty
: slug.Trim().ToLowerInvariant();
}
}