Files
prefab-page-detail/Prefab.Web.Client/Pages/Browse/Product.razor
2025-10-27 21:39:50 -04:00

423 lines
17 KiB
Plaintext

@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);
}