Latest
This commit is contained in:
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>
|
||||
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
|
||||
<ProjectReference Include="..\Prefab.Shared\Prefab.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -15,5 +15,6 @@ builder.Services.AddScoped<ICategoriesPageService, CategoriesPageService>();
|
||||
builder.Services.AddScoped<INavMenuService, NavMenuService>();
|
||||
builder.Services.AddScoped<IProductListingService, ProductListingService>();
|
||||
builder.Services.AddScoped<IHomePageService, HomePageService>();
|
||||
builder.Services.AddScoped<IProductDisplayService, ProductDisplayService>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user