Init
This commit is contained in:
60
Prefab.Web.Client/Components/Catalog/CategoryCard.razor
Normal file
60
Prefab.Web.Client/Components/Catalog/CategoryCard.razor
Normal file
@@ -0,0 +1,60 @@
|
||||
<Card Variant="CardVariant.Category"
|
||||
CssClass="@CssClass"
|
||||
LinkHref="@LinkHref"
|
||||
LinkTarget="@LinkTarget"
|
||||
LinkRel="@LinkRel"
|
||||
ImagePlaceholderText="@ImagePlaceholderText"
|
||||
OnLinkClick="HandleLinkClickAsync">
|
||||
<Image>
|
||||
@if (!string.IsNullOrWhiteSpace(Category.ImageUrl))
|
||||
{
|
||||
<img src="@Category.ImageUrl"
|
||||
alt="@Category.Title"
|
||||
loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="category-card__image-placeholder">
|
||||
@ImagePlaceholderText
|
||||
</div>
|
||||
}
|
||||
</Image>
|
||||
|
||||
<Heading>
|
||||
@Category.Title
|
||||
</Heading>
|
||||
|
||||
<Description>
|
||||
@if (HasSecondaryText)
|
||||
{
|
||||
@Category.SecondaryText
|
||||
}
|
||||
</Description>
|
||||
</Card>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public CategoryCardModel Category { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ImagePlaceholderText { get; set; } = "Image coming soon";
|
||||
|
||||
[Parameter]
|
||||
public string? LinkTarget { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? LinkRel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CategoryCardModel> OnCategorySelected { get; set; }
|
||||
|
||||
private string? LinkHref => string.IsNullOrWhiteSpace(Category.Url) ? null : Category.Url;
|
||||
|
||||
private bool HasSecondaryText => !string.IsNullOrWhiteSpace(Category.SecondaryText);
|
||||
|
||||
private Task HandleLinkClickAsync(MouseEventArgs args) =>
|
||||
OnCategorySelected.HasDelegate ? OnCategorySelected.InvokeAsync(Category) : Task.CompletedTask;
|
||||
}
|
||||
14
Prefab.Web.Client/Components/Catalog/CategoryCard.razor.css
Normal file
14
Prefab.Web.Client/Components/Catalog/CategoryCard.razor.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.category-card__image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: #f5f5f5;
|
||||
color: #6c757d;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
163
Prefab.Web.Client/Components/Catalog/ProductCard.razor
Normal file
163
Prefab.Web.Client/Components/Catalog/ProductCard.razor
Normal file
@@ -0,0 +1,163 @@
|
||||
@using System.Globalization
|
||||
@using System.Linq
|
||||
@using Prefab.Web.Client.Models.Shared
|
||||
|
||||
<Card Variant="CardVariant.Product"
|
||||
Layout="@Layout"
|
||||
CssClass="@CssClass"
|
||||
ImagePlaceholderText="@ImagePlaceholderText"
|
||||
AdditionalAttributes="RootAttributes"
|
||||
ShowBadges="@ShowBadgesSection"
|
||||
ShowMeta="@ShowMetaSection"
|
||||
ShowRating="@ShouldRenderRating"
|
||||
ShowDescription="@ShowDescriptionSection"
|
||||
ShowFooter="false"
|
||||
Prices="@PricesTemplate">
|
||||
<Badges>
|
||||
@if (Product.IsOnSale)
|
||||
{
|
||||
<div class="product-card__badge product-card__badge--style--sale">Sale</div>
|
||||
}
|
||||
@foreach (var badge in Product.Badges.Where(static badge => !string.IsNullOrWhiteSpace(badge)))
|
||||
{
|
||||
<div class="product-card__badge">@badge</div>
|
||||
}
|
||||
</Badges>
|
||||
<BadgesContent>
|
||||
@BadgesContent?.Invoke(Product)
|
||||
</BadgesContent>
|
||||
<Image>
|
||||
<a href="@Product.Url">
|
||||
<img src="@ImageSource"
|
||||
alt="@ImageAltText"
|
||||
loading="lazy" />
|
||||
</a>
|
||||
</Image>
|
||||
<Meta>
|
||||
@if (!string.IsNullOrWhiteSpace(Product.CategoryName))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Product.CategoryUrl))
|
||||
{
|
||||
<a href="@Product.CategoryUrl">@Product.CategoryName</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Product.CategoryName
|
||||
}
|
||||
}
|
||||
</Meta>
|
||||
<Heading>
|
||||
<a href="@Product.Url">
|
||||
@Product.Title
|
||||
</a>
|
||||
</Heading>
|
||||
<Rating>
|
||||
@if (Product.ReviewCount > 0)
|
||||
{
|
||||
<div class="product-card__rating-title">@GetRatingTitle()</div>
|
||||
}
|
||||
<div class="product-card__rating-stars">
|
||||
<div class="rating" aria-label="@RatingAriaLabel">
|
||||
<div class="rating__body">
|
||||
@foreach (var star in Enumerable.Range(1, TotalStars))
|
||||
{
|
||||
var isActive = star <= Math.Clamp(Product.Rating, 0, TotalStars);
|
||||
<svg class="rating__star @(isActive ? "rating__star--active" : string.Empty)"
|
||||
width="13px"
|
||||
height="12px"
|
||||
aria-hidden="true">
|
||||
<g class="rating__fill">
|
||||
<use xlink:href="images/sprite.svg#star-normal"></use>
|
||||
</g>
|
||||
<g class="rating__stroke">
|
||||
<use xlink:href="images/sprite.svg#star-normal-stroke"></use>
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Rating>
|
||||
<Description>
|
||||
@DescriptionContent?.Invoke(Product)
|
||||
</Description>
|
||||
</Card>
|
||||
|
||||
@code {
|
||||
private static readonly CultureInfo PriceCulture = CultureInfo.CurrentCulture;
|
||||
private const string PlaceholderImageDataUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA0MDAgNDAwJz48cmVjdCB3aWR0aD0nNDAwJyBoZWlnaHQ9JzQwMCcgZmlsbD0nI2YzZjNmMycvPjx0ZXh0IHg9JzUwJScgeT0nNTAlJyBkb21pbmFudC1iYXNlbGluZT0nbWlkZGxlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nT3BlbiBTYW5zLCBBcmlhbCwgc2Fucy1zZXJpZicgZm9udC1zaXplPScyOCcgZmlsbD0nIzliOWI5Yic+SW1hZ2UgY29taW5nIHNvb248L3RleHQ+PC9zdmc+";
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public ProductCardModel Product { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public string Layout { get; set; } = "grid";
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ImagePlaceholderText { get; set; } = "Image coming soon";
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<ProductCardModel>? BadgesContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment<ProductCardModel>? DescriptionContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ShowPrice { get; set; }
|
||||
|
||||
private bool HasBadges => Product.IsOnSale || Product.Badges.Any(static badge => !string.IsNullOrWhiteSpace(badge));
|
||||
|
||||
private bool ShouldRenderRating => Product.Rating > 0 || Product.ReviewCount > 0;
|
||||
|
||||
private bool ShowBadgesSection => HasBadges || BadgesContent is not null;
|
||||
|
||||
private bool ShowMetaSection => !string.IsNullOrWhiteSpace(Product.CategoryName);
|
||||
|
||||
private bool ShowDescriptionSection => DescriptionContent is not null;
|
||||
|
||||
private RenderFragment? PricesTemplate => !ShowPrice || !Product.HasPrice
|
||||
? null
|
||||
: @<text>
|
||||
<div class="product-card__price">
|
||||
<span class="product-card__price-new">@FormatMoney(Product.FromPrice!)</span>
|
||||
@if (Product.OldPrice.HasValue)
|
||||
{
|
||||
<span class="product-card__price-old">@FormatDecimal(Product.OldPrice.Value)</span>
|
||||
}
|
||||
</div>
|
||||
</text>;
|
||||
|
||||
private string RatingAriaLabel => Product.Rating > 0
|
||||
? $"{Math.Clamp(Product.Rating, 0, TotalStars)} out of {TotalStars}"
|
||||
: "No rating yet";
|
||||
|
||||
private IReadOnlyDictionary<string, object>? RootAttributes =>
|
||||
string.IsNullOrWhiteSpace(Product.Sku)
|
||||
? null
|
||||
: new Dictionary<string, object>
|
||||
{
|
||||
["data-sku"] = Product.Sku
|
||||
};
|
||||
|
||||
private string FormatMoney(MoneyModel money) => money.Amount.ToString("C", PriceCulture);
|
||||
|
||||
private string FormatDecimal(decimal value) => value.ToString("C", PriceCulture);
|
||||
|
||||
private string GetRatingTitle() => $"Reviews ({Math.Max(Product.ReviewCount, 0)})";
|
||||
|
||||
private string ImageSource => string.IsNullOrWhiteSpace(Product.PrimaryImageUrl)
|
||||
? PlaceholderImageDataUrl
|
||||
: Product.PrimaryImageUrl!;
|
||||
|
||||
private string ImageAltText => string.IsNullOrWhiteSpace(Product.PrimaryImageUrl)
|
||||
? ImagePlaceholderText
|
||||
: Product.Title;
|
||||
|
||||
private const int TotalStars = 5;
|
||||
}
|
||||
|
||||
|
||||
|
||||
241
Prefab.Web.Client/Components/Shared/Card.razor
Normal file
241
Prefab.Web.Client/Components/Shared/Card.razor
Normal file
@@ -0,0 +1,241 @@
|
||||
<div class="@RootClass" @attributes="AdditionalAttributes">
|
||||
@if (Variant == CardVariant.Product)
|
||||
{
|
||||
if (ShowBadges && (Badges is not null || BadgesContent is not null))
|
||||
{
|
||||
<div class="product-card__badges-list">
|
||||
@Badges
|
||||
@BadgesContent
|
||||
</div>
|
||||
}
|
||||
|
||||
if (ShowActions && Actions is not null)
|
||||
{
|
||||
<div class="product-card__actions">
|
||||
<div class="product-card__actions-list">
|
||||
@Actions
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="product-card__image">
|
||||
@if (Image is not null)
|
||||
{
|
||||
@Image
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ImagePlaceholderText))
|
||||
{
|
||||
<span class="product-card__image-placeholder">@ImagePlaceholderText</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="product-card__info">
|
||||
@if (ShowMeta && Meta is not null)
|
||||
{
|
||||
<div class="product-card__category">
|
||||
@Meta
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowHeading && Heading is not null)
|
||||
{
|
||||
<div class="product-card__name">
|
||||
@Heading
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowRating && Rating is not null)
|
||||
{
|
||||
<div class="product-card__rating">
|
||||
@Rating
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowDescription && Description is not null)
|
||||
{
|
||||
<div class="product-card__description">
|
||||
@Description
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowPrices && Prices is not null)
|
||||
{
|
||||
<div class="product-card__prices-list">
|
||||
@Prices
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowFooter && Footer is not null)
|
||||
{
|
||||
<div class="product-card__buttons">
|
||||
<div class="product-card__buttons-list">
|
||||
@Footer
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (Variant == CardVariant.Category)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(LinkHref))
|
||||
{
|
||||
<a href="@LinkHref" target="@LinkTarget" rel="@LinkRel" @onclick="HandleLinkClickAsync">
|
||||
@CategoryContent
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@CategoryContent
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const string ProductRootClass = "product-card";
|
||||
private const string ProductLayoutGridClass = "product-card--layout--grid";
|
||||
private const string ProductLayoutListClass = "product-card--layout--list";
|
||||
private const string CategoryRootClass = "card";
|
||||
private const string CategoryCardClass = "category-card";
|
||||
|
||||
[Parameter]
|
||||
public CardVariant Variant { get; set; } = CardVariant.Product;
|
||||
|
||||
[Parameter]
|
||||
public string Layout { get; set; } = "grid";
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ImagePlaceholderText { get; set; } = "Image coming soon";
|
||||
|
||||
[Parameter]
|
||||
public string? LinkHref { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? LinkTarget { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? LinkRel { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> OnLinkClick { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Badges { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? BadgesContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Actions { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Image { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Meta { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Heading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Rating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Description { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Prices { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? Footer { get; set; }
|
||||
|
||||
[Parameter(CaptureUnmatchedValues = true)]
|
||||
public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool ShowBadges { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowActions { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowMeta { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowHeading { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowRating { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowDescription { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowPrices { get; set; } = true;
|
||||
|
||||
[Parameter]
|
||||
public bool ShowFooter { get; set; } = true;
|
||||
|
||||
private string RootClass => Variant switch
|
||||
{
|
||||
CardVariant.Category => BuildClass(CategoryRootClass, CategoryCardClass, CssClass),
|
||||
_ => BuildClass(ProductRootClass, ResolveLayoutClass(), CssClass)
|
||||
};
|
||||
|
||||
private string ResolveLayoutClass() =>
|
||||
string.Equals(Layout, "list", StringComparison.OrdinalIgnoreCase)
|
||||
? ProductLayoutListClass
|
||||
: ProductLayoutGridClass;
|
||||
|
||||
private static string BuildClass(params string?[] values)
|
||||
{
|
||||
var parts = new List<string>(values.Length);
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
parts.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private RenderFragment CategoryContent => @<text>
|
||||
@if (Image is not null)
|
||||
{
|
||||
<div class="category-card__image">
|
||||
@Image
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ImagePlaceholderText))
|
||||
{
|
||||
<div class="category-card__image">@ImagePlaceholderText</div>
|
||||
}
|
||||
|
||||
@if (Heading is not null)
|
||||
{
|
||||
<div class="category-card__name">
|
||||
@Heading
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Description is not null)
|
||||
{
|
||||
<div class="category-card__products">
|
||||
@Description
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Footer is not null)
|
||||
{
|
||||
@Footer
|
||||
}
|
||||
</text>;
|
||||
|
||||
private Task HandleLinkClickAsync(MouseEventArgs args) =>
|
||||
OnLinkClick.HasDelegate ? OnLinkClick.InvokeAsync(args) : Task.CompletedTask;
|
||||
}
|
||||
33
Prefab.Web.Client/Components/Shared/CardGrid.razor
Normal file
33
Prefab.Web.Client/Components/Shared/CardGrid.razor
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="@ContainerClass">
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string View { get; set; } = "grid";
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private string ContainerClass => string.Join(" ", BuildClasses());
|
||||
|
||||
private IEnumerable<string> BuildClasses()
|
||||
{
|
||||
yield return "products-view__list";
|
||||
yield return "products-list";
|
||||
yield return ResolveLayoutClass();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CssClass))
|
||||
{
|
||||
yield return CssClass;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveLayoutClass() =>
|
||||
string.Equals(View, "list", StringComparison.OrdinalIgnoreCase)
|
||||
? "products-list--layout--list"
|
||||
: "products-list--layout--grid-3";
|
||||
}
|
||||
11
Prefab.Web.Client/Components/Shared/CardGridItem.razor
Normal file
11
Prefab.Web.Client/Components/Shared/CardGridItem.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@if (ChildContent is not null)
|
||||
{
|
||||
<div class="products-list__item">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
7
Prefab.Web.Client/Components/Shared/CardVariant.cs
Normal file
7
Prefab.Web.Client/Components/Shared/CardVariant.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Prefab.Web.Client.Components.Shared;
|
||||
|
||||
public enum CardVariant
|
||||
{
|
||||
Product,
|
||||
Category
|
||||
}
|
||||
302
Prefab.Web.Client/Layout/MainLayout.razor
Normal file
302
Prefab.Web.Client/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,302 @@
|
||||
@using Prefab.Web.Client.Services
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<TelerikRootComponent>
|
||||
<TelerikNotification @ref="GlobalNotification"
|
||||
HorizontalPosition="@NotificationHorizontalPosition.Right"
|
||||
VerticalPosition="@NotificationVerticalPosition.Top">
|
||||
</TelerikNotification>
|
||||
|
||||
<!-- site -->
|
||||
<div class="site">
|
||||
<div class="site__container">
|
||||
<!-- site__header -->
|
||||
<header class="site__header">
|
||||
<div class="header">
|
||||
<div class="header__body">
|
||||
<div class="search">
|
||||
<form class="search__form">
|
||||
<input class="search__input" type="search" placeholder="Search Query...">
|
||||
<button class="search__button" type="submit">
|
||||
<svg width="20px" height="20px">
|
||||
<use xlink:href="images/sprite.svg#search-20"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="search__button search-trigger" type="button">
|
||||
<svg width="20px" height="20px">
|
||||
<use xlink:href="images/sprite.svg#cross-20"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="header__mobilemenu" type="button">
|
||||
<svg width="22px" height="16px">
|
||||
<use xlink:href="images/sprite.svg#menu"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/" class="header__logo">
|
||||
<!-- logo -->
|
||||
<svg class="logo" xmlns="http://www.w3.org/2000/svg" width="82px" height="24px">
|
||||
@* <path d="M79.011,17.987 L79.011,15.978 C79.011,15.978 77.402,17.366 76.739,17.672 C76.074,17.979 75.110,17.987 74.043,17.987 C72.730,17.987 71.698,17.757 70.944,17.004 C70.191,16.252 70.001,15.282 70.001,13.892 C70.001,12.437 70.421,10.962 71.435,10.272 C72.450,9.581 73.878,9.020 75.924,8.947 L78.295,8.953 C78.295,7.563 77.586,6.768 76.168,6.768 C75.077,6.768 73.794,7.099 72.319,7.761 L71.085,5.235 C72.657,4.409 74.400,3.997 76.315,3.997 C78.149,3.997 79.555,4.397 80.532,5.198 C81.510,6 81.999,7.217 81.999,8.852 L81.999,17.987 L79.011,17.987 ZM78.295,11.636 L76.852,11.685 C75.769,11.718 74.963,11.914 74.434,12.273 C73.904,12.633 73.639,13.181 73.639,13.917 C73.639,14.971 74.242,15.297 75.448,15.297 C76.311,15.297 77.101,14.948 77.619,14.449 C78.135,13.951 78.295,13.590 78.295,12.764 L78.295,11.636 ZM57.289,24 C56.646,24 55,23.994 55,23.994 L55,20.995 C55,20.995 56.332,20.998 56.861,20.998 C59.189,20.998 59.962,17.898 59.962,17.898 L54,4 L58,4 L61.720,14.396 L65,4 L69,4 L62.989,19.741 C61.931,22.589 59.909,24 57.289,24 ZM49,0 L52,0 L52,18 L49,18 L49,0 ZM41.500,18 C40.163,18 38.953,17.368 38,16.358 L38,18 L35,18 L35,0 L38,0 L38,5.642 C38.953,4.632 40.163,4 41.500,4 C44.538,4 47,7.134 47,11 C47,14.866 44.538,18 41.500,18 ZM41,7 C39.343,7 38,8.791 38,11 C38,13.209 39.343,15 41,15 C42.657,15 44,13.209 44,11 C44,8.791 42.657,7 41,7 ZM25.157,14.338 C25.743,14.932 26.565,15.229 27.623,15.229 C28.444,15.229 29.222,15.144 29.954,14.973 C30.687,14.802 31.451,14.529 32,14.155 L32,17.036 C31.598,17.361 30.902,17.603 30.162,17.762 C29.421,17.921 28.518,18 27.452,18 C24,18 21,16 21,11 C21,8 22,4 27,4 C32,4 33,8 33,11 L33,12 L24.217,12 C24.257,13.162 24.571,13.744 25.157,14.338 ZM29.527,9 C29.510,8.081 29,7 27,7 C25,7 24.367,8.081 24.302,9 L29.527,9 ZM16,4.500 L10.977,18 L8,18 L3,4.500 L3,18 L0,18 L0,0 L5,0 L9.500,13.400 L14,0 L19,0 L19,18 L16,18 L16,4.500 Z"></path> *@
|
||||
<text x="2" y="16" font-family="Arial, sans-serif" font-size="22" font-weight="600" fill="currentColor">
|
||||
Prefab
|
||||
</text>
|
||||
<path class="logo__accent" d="M0,22 L52,22 L52,24 L0,24 L0,22"></path>
|
||||
</svg>
|
||||
<!-- logo / end -->
|
||||
</a>
|
||||
<NavMenu />
|
||||
<div class="header__spring"></div>
|
||||
<div class="header__indicator">
|
||||
<button type="button" class="header__indicator-button indicator search-trigger">
|
||||
<span class="indicator__area">
|
||||
<svg class="indicator__icon" width="20px" height="20px">
|
||||
<use xlink:href="images/sprite.svg#search-20"></use>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header__indicator">
|
||||
<a href="/wishlist" class="header__indicator-button indicator">
|
||||
<span class="indicator__area">
|
||||
<svg class="indicator__icon" width="20px" height="20px">
|
||||
<use xlink:href="images/sprite.svg#heart-20"></use>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header__indicator" data-dropdown-trigger="click">
|
||||
<a href="/cart" class="header__indicator-button indicator">
|
||||
<span class="indicator__area">
|
||||
<span class="indicator__value" data-testid="mini-cart-count">@* @MiniCount() *@</span>
|
||||
<svg class="indicator__icon" width="20px" height="20px">
|
||||
<use xlink:href="images/sprite.svg#cart-20"></use>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
<div class="header__indicator-dropdown">
|
||||
<div class="dropcart">
|
||||
@* <div class="dropcart__products-list">
|
||||
@if (miniCart?.Items?.Count > 0)
|
||||
{
|
||||
@foreach (var i in miniCart.Items)
|
||||
{
|
||||
<div class="dropcart__product">
|
||||
<div class="dropcart__product-image">
|
||||
<a href="/products">
|
||||
<img src="images/logo.png" alt="@i.Sku">
|
||||
</a>
|
||||
</div>
|
||||
<div class="dropcart__product-info">
|
||||
<div class="dropcart__product-name"><a href="/products">@DisplayName(i.Sku)</a></div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<TelerikNumericTextBox Value="@i.Quantity" Min="1" Format="0" Width="80px"
|
||||
ValueChanged="@(async (int v) => { await CartService.UpdateItemQuantityAsync(miniCartId, i.Id, v); miniCart = await CartService.GetCartAsync(miniCartId); StateHasChanged(); })" />
|
||||
<span>@i.UnitPrice.ToString("C2")</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="dropcart__product-remove button-remove" @onclick="() => RemoveMiniItem(i)">
|
||||
<svg width="10px" height="10px">
|
||||
<use xlink:href="images/sprite.svg#cross-10"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-2 text-muted">Your cart is empty.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="dropcart__totals">
|
||||
<table>
|
||||
<tr>
|
||||
<th>Subtotal</th>
|
||||
<td>@MiniSubtotal().ToString("C2")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Shipping</th>
|
||||
<td>Free Shipping</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Tax</th>
|
||||
<td>$0.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<td>@MiniSubtotal().ToString("C2")</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dropcart__buttons">
|
||||
<a class="btn btn-secondary" href="/cart">View Cart</a>
|
||||
<a class="btn btn-primary" href="/checkout">Checkout</a>
|
||||
</div> *@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- site__header / end -->
|
||||
<!-- site__body -->
|
||||
<div class="site__body">
|
||||
<!-- page -->
|
||||
<div class="page">
|
||||
<!-- page__body -->
|
||||
<div class="page__body">
|
||||
<ErrorBoundary @ref="_errorBoundary">
|
||||
<ChildContent>
|
||||
@Body
|
||||
</ChildContent>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<!-- page__body / end -->
|
||||
</div>
|
||||
<!-- page / end -->
|
||||
</div>
|
||||
<!-- site__body / end -->
|
||||
<!-- site__footer -->
|
||||
<footer class="site__footer">
|
||||
<div class="footer">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col">
|
||||
<div class="footer__widgets">
|
||||
<div class="row g-custom-30 justify-content-between">
|
||||
<div class="col-12 col-lg-4 col-sm-6 footer__aboutus">
|
||||
<div class="footer-aboutus">
|
||||
<div class="footer-aboutus__title">
|
||||
<h4 class="footer-aboutus__header decor-header">About Us</h4>
|
||||
</div>
|
||||
<div class="footer-aboutus__text">
|
||||
We are a leading provider of pre-fabricated solutions for the electrical and construction industries. Our products are designed to save time and reduce costs while maintaining the highest quality standards.
|
||||
</div>
|
||||
<ul class="footer-aboutus__contacts">
|
||||
<li class="footer-aboutus__contacts-item">
|
||||
<a href="">6504 111th Ave, Kenosha, WI 53142</a>
|
||||
</li>
|
||||
<li class="footer-aboutus__contacts-item"><a href="">info@wemsoftware.com</a></li>
|
||||
<li class="footer-aboutus__contacts-item"><a href="">+1 (810) 232 9797</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 col-sm-6 col-md-3">
|
||||
<div class="footer-links" data-collapse data-collapse-open-class="footer-links--open" data-collapse-item>
|
||||
<div class="footer-links__title">
|
||||
<h4 class="footer-links__header" data-collapse-trigger>
|
||||
Information
|
||||
<svg class="footer-links__header-arrow" width="9px" height="6px">
|
||||
<use xlink:href="images/sprite.svg#arrow-down-9x6"></use>
|
||||
</svg>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="footer-links__content" data-collapse-content>
|
||||
<ul class="footer-links__list">
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">About Us</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Delivery Information</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Privacy Policy</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Brands</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Contact Us</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Returns</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Site Map</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 col-sm-6 col-md-3">
|
||||
<div class="footer-links" data-collapse data-collapse-open-class="footer-links--open" data-collapse-item>
|
||||
<div class="footer-links__title">
|
||||
<h4 class="footer-links__header" data-collapse-trigger>
|
||||
My Account
|
||||
<svg class="footer-links__header-arrow" width="9px" height="6px">
|
||||
<use xlink:href="images/sprite.svg#arrow-down-9x6"></use>
|
||||
</svg>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="footer-links__content" data-collapse-content>
|
||||
<ul class="footer-links__list">
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">My Account</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Order History</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Wish List</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Newsletter</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Specials</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Gift Certificates</a></li>
|
||||
<li class="footer-links__item"><a href="" class="footer-links__link">Affiliate</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 footer__followus">
|
||||
<div class="footer-followus">
|
||||
<div class="footer-followus__title">
|
||||
<h4 class="footer-followus__header">Follow Us</h4>
|
||||
</div>
|
||||
<div class="footer-followus__text">
|
||||
Sign up for our newsletter to get the latest news, updates and special offers delivered directly to your inbox.
|
||||
</div>
|
||||
<form class="footer-followus__subscribe-form">
|
||||
<input type="email" class="form-control form-control--footer" placeholder="Email address">
|
||||
<button type="submit" class="btn btn-primary">Subscribe</button>
|
||||
</form>
|
||||
<ul class="footer-followus__social-links">
|
||||
<li class="footer-followus__social-link"><a href="#" target="_blank"><i class="fab fa-facebook-f"></i></a></li>
|
||||
<li class="footer-followus__social-link"><a href="#" target="_blank"><i class="fab fa-twitter"></i></a></li>
|
||||
<li class="footer-followus__social-link"><a href="#" target="_blank"><i class="fab fa-youtube"></i></a></li>
|
||||
<li class="footer-followus__social-link"><a href="#" target="_blank"><i class="fab fa-instagram"></i></a></li>
|
||||
<li class="footer-followus__social-link"><a href="#" target="_blank"><i class="fas fa-rss"></i></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-custom-30 justify-content-between">
|
||||
<div class="col-12 col-sm-auto">
|
||||
<div class="copyright">
|
||||
<!-- copyright -->
|
||||
<span class="copyright__text">Prefab.Server © 2023-@DateTime.Now.Year. All Rights Reserved.</span>
|
||||
<!-- copyright / end -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__payments col-auto">
|
||||
<img srcset="images/payments.png 1x, images/payments@2x.png 2x" src="images/payments.png" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- site__footer / end -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- site / end -->
|
||||
</TelerikRootComponent>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
private ErrorBoundary? _errorBoundary;
|
||||
private TelerikNotification GlobalNotification { get; set; } = new();
|
||||
|
||||
[Inject]
|
||||
public INotificationService NotificationService { get; set; } = null!;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_errorBoundary?.Recover();
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
NotificationService.Attach(GlobalNotification);
|
||||
}
|
||||
}
|
||||
20
Prefab.Web.Client/Layout/MainLayout.razor.css
Normal file
20
Prefab.Web.Client/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,20 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
409
Prefab.Web.Client/Layout/NavMenu.razor
Normal file
409
Prefab.Web.Client/Layout/NavMenu.razor
Normal file
@@ -0,0 +1,409 @@
|
||||
@rendermode InteractiveAuto
|
||||
@using System.Linq
|
||||
@using Prefab.Web.Client.Models.Shared
|
||||
@using Prefab.Web.Client.Services
|
||||
@implements IDisposable
|
||||
|
||||
<nav class="header__nav main-nav">
|
||||
<ul class="main-nav__list">
|
||||
<li class="main-nav__item ">
|
||||
<NavLink class="main-nav__link" href="/" Match="NavLinkMatch.All">
|
||||
Home
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
<li class="@ProductsMenuItemClass"
|
||||
@onmouseenter="HandleMouseEnter"
|
||||
@onmouseleave="HandleMouseLeave">
|
||||
<span class="main-nav__trigger"
|
||||
@onclick="OnProductsMenuLinkClick"
|
||||
@onclick:preventDefault>
|
||||
<NavLink id="productsMenuLink"
|
||||
class="main-nav__link"
|
||||
href="/catalog"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="@(_productsMenuOpen.ToString().ToLowerInvariant())"
|
||||
aria-controls="productsMenuPanel">
|
||||
<span>Products</span>
|
||||
<svg class="main-nav__link-arrow" width="9px" height="6px">
|
||||
<use xlink:href="images/sprite.svg#arrow-down-9x6"></use>
|
||||
</svg>
|
||||
</NavLink>
|
||||
</span>
|
||||
<div id="productsMenuPanel"
|
||||
class="main-nav__sub-megamenu"
|
||||
role="region"
|
||||
aria-hidden="@((!_productsMenuOpen).ToString().ToLowerInvariant())"
|
||||
tabindex="-1"
|
||||
@ref="_panel">
|
||||
@if (!_menuLoaded)
|
||||
{
|
||||
<div class="megamenu" role="menu" aria-live="polite">
|
||||
<div class="row g-custom-30">
|
||||
@for (var i = 0; i < 4; i++)
|
||||
{
|
||||
<div class="col-3">
|
||||
<TelerikSkeleton Width="100%" Height="20px" Class="mb-2" />
|
||||
<ul class="megamenu__links megamenu__links--sub">
|
||||
@for (var j = 0; j < 4; j++)
|
||||
{
|
||||
<li class="mb-2">
|
||||
<TelerikSkeleton Height="16px" Width="80%" />
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_menu is null || _menu.Columns.Count == 0)
|
||||
{
|
||||
<div class="mega-menu__empty" role="status">@(_errorMessage ?? "Navigation unavailable.")</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="megamenu" role="menu" aria-live="polite">
|
||||
<div class="row g-custom-30">
|
||||
@foreach (var column in _menu.Columns.Take(4))
|
||||
{
|
||||
<div class="col-3">
|
||||
<ul class="megamenu__links megamenu__links--root">
|
||||
<li>
|
||||
<NavLink href="@column.Root.Url">@column.Root.Name</NavLink>
|
||||
@if (column.Items is { Count: > 0 })
|
||||
{
|
||||
<ul class="megamenu__links megamenu__links--sub">
|
||||
@foreach (var item in column.Items)
|
||||
{
|
||||
<li><NavLink href="@item.Url">@item.Name</NavLink></li>
|
||||
}
|
||||
<li class="megamenu__see-all">
|
||||
<NavLink href="@column.SeeAllUrl">See all @column.Root.Name</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="@ProductsFlyoutItemClass"
|
||||
@onmouseenter="HandleFlyoutMouseEnter"
|
||||
@onmouseleave="HandleFlyoutMouseLeave">
|
||||
<span class="main-nav__trigger"
|
||||
@onclick="OnProductsFlyoutLinkClick"
|
||||
@onclick:preventDefault>
|
||||
<NavLink class="main-nav__link"
|
||||
href="/catalog"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="@(_productsFlyoutOpen.ToString().ToLowerInvariant())"
|
||||
aria-controls="productsFlyoutMenu">
|
||||
<span>Products</span>
|
||||
<svg class="main-nav__link-arrow" width="9px" height="6px">
|
||||
<use xlink:href="images/sprite.svg#arrow-down-9x6"></use>
|
||||
</svg>
|
||||
</NavLink>
|
||||
</span>
|
||||
<div id="productsFlyoutMenu"
|
||||
class="main-nav__sub-menu"
|
||||
role="region"
|
||||
aria-live="polite">
|
||||
@if (!_menuLoaded)
|
||||
{
|
||||
<div class="p-3" role="status">
|
||||
@for (var i = 0; i < 4; i++)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<TelerikSkeleton Width="180px" Height="16px" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_menu is null || _menu.Columns.Count == 0)
|
||||
{
|
||||
<div class="menu__empty p-3" role="status">@(_errorMessage ?? "Navigation unavailable.")</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="menu">
|
||||
@foreach (var column in GetFlyoutColumns())
|
||||
{
|
||||
var hasChildren = column.Items is { Count: > 0 };
|
||||
var hasSeeAll = !string.IsNullOrWhiteSpace(column.SeeAllUrl);
|
||||
<li class="menu__item">
|
||||
<NavLink class="menu__link"
|
||||
href="@column.Root.Url">
|
||||
@column.Root.Name
|
||||
@if (hasChildren)
|
||||
{
|
||||
<svg class="menu__link-arrow" width="6px" height="9px">
|
||||
<use xlink:href="images/sprite.svg#arrow-right-6x9"></use>
|
||||
</svg>
|
||||
}
|
||||
</NavLink>
|
||||
|
||||
@if (hasChildren)
|
||||
{
|
||||
<div class="menu__sub">
|
||||
<ul class="menu">
|
||||
@if (column.Items is { Count: > 0 })
|
||||
{
|
||||
@foreach (var item in column.Items)
|
||||
{
|
||||
<li class="menu__item">
|
||||
<NavLink class="menu__link" href="@item.Url">@item.Name</NavLink>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@if (hasSeeAll)
|
||||
{
|
||||
<li class="menu__item">
|
||||
<NavLink class="menu__link menu__link--see-all" href="@column.SeeAllUrl">
|
||||
See all @column.Root.Name
|
||||
</NavLink>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="main-nav__item">
|
||||
<NavLink class="main-nav__link" href="Contact">
|
||||
Contact Us
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private const string ProductsMenuCacheKey = "nav-menu/products";
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private INavMenuService NavMenuService { get; set; } = default!;
|
||||
|
||||
[Inject]
|
||||
private IServiceProvider ServiceProvider { get; set; } = default!;
|
||||
|
||||
private bool _productsMenuOpen;
|
||||
private bool _productsFlyoutOpen;
|
||||
private ElementReference _panel;
|
||||
|
||||
private NavMenuModel? _menu;
|
||||
private bool _menuLoaded;
|
||||
private string? _errorMessage;
|
||||
private IDisposable? _persistRegistration;
|
||||
private CancellationTokenSource? _loadCancellationSource;
|
||||
|
||||
private string ProductsMenuItemClass =>
|
||||
$"main-nav__item main-nav__item--with--menu {(_productsMenuOpen ? "main-nav__item--open" : string.Empty)}";
|
||||
|
||||
private string ProductsFlyoutItemClass =>
|
||||
$"main-nav__item main-nav__item--with--menu {(_productsFlyoutOpen ? "main-nav__item--open" : string.Empty)}";
|
||||
|
||||
private PersistentComponentState? AppState => ServiceProvider.GetService<PersistentComponentState>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
|
||||
var appState = AppState;
|
||||
if (appState is not null)
|
||||
{
|
||||
_persistRegistration = appState.RegisterOnPersisting(PersistAsync);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _menuLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (AppState is not null && AppState.TryTakeFromJson(ProductsMenuCacheKey, out NavMenuModel? cached) && cached is not null)
|
||||
{
|
||||
_menu = cached;
|
||||
}
|
||||
else
|
||||
{
|
||||
await LoadAsync(forceReload: true);
|
||||
}
|
||||
|
||||
_menuLoaded = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ToggleProductsMenuAsync()
|
||||
{
|
||||
if (_productsMenuOpen)
|
||||
{
|
||||
await CloseAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await OpenAsync(focusPanel: true);
|
||||
}
|
||||
|
||||
private Task OnProductsMenuLinkClick(MouseEventArgs _) => ToggleProductsMenuAsync();
|
||||
|
||||
private Task HandleMouseEnter() => OpenAsync(focusPanel: false);
|
||||
|
||||
private Task HandleMouseLeave() => CloseAsync();
|
||||
|
||||
private async Task HandleFlyoutMouseEnter()
|
||||
{
|
||||
await LoadAsync(forceReload: false);
|
||||
_productsFlyoutOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private Task HandleFlyoutMouseLeave()
|
||||
{
|
||||
if (!_productsFlyoutOpen)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_productsFlyoutOpen = false;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnProductsFlyoutLinkClick(MouseEventArgs _)
|
||||
{
|
||||
if (_productsFlyoutOpen)
|
||||
{
|
||||
_productsFlyoutOpen = false;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return HandleFlyoutMouseEnter();
|
||||
}
|
||||
|
||||
private async Task OpenAsync(bool focusPanel)
|
||||
{
|
||||
if (_productsMenuOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadAsync(forceReload: false);
|
||||
|
||||
_productsMenuOpen = true;
|
||||
StateHasChanged();
|
||||
|
||||
if (focusPanel)
|
||||
{
|
||||
await Task.Yield();
|
||||
await _panel.FocusAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private Task CloseAsync()
|
||||
{
|
||||
if (!_productsMenuOpen)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_productsMenuOpen = false;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
await CloseAsync();
|
||||
_productsFlyoutOpen = false;
|
||||
await LoadAsync(forceReload: true);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task LoadAsync(bool forceReload)
|
||||
{
|
||||
if (!forceReload && _menu is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadCancellationSource?.Cancel();
|
||||
_loadCancellationSource?.Dispose();
|
||||
_loadCancellationSource = new CancellationTokenSource();
|
||||
var token = _loadCancellationSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await NavMenuService.Get(2, token);
|
||||
if (result.IsSuccess && result.Value is { } model)
|
||||
{
|
||||
_menu = model;
|
||||
_errorMessage = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_menu = null;
|
||||
_errorMessage = result.Problem?.Detail ?? "Navigation unavailable.";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Swallow expected cancellation when a new request supersedes the current one.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_menu = null;
|
||||
_errorMessage = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistAsync()
|
||||
{
|
||||
var appState = AppState;
|
||||
if (_menu is not null && appState is not null)
|
||||
{
|
||||
appState.PersistAsJson(ProductsMenuCacheKey, _menu);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
_persistRegistration?.Dispose();
|
||||
_loadCancellationSource?.Cancel();
|
||||
_loadCancellationSource?.Dispose();
|
||||
}
|
||||
|
||||
private IEnumerable<NavMenuColumnModel> GetFlyoutColumns()
|
||||
{
|
||||
if (_menu is null || _menu.Columns.Count == 0)
|
||||
{
|
||||
return Array.Empty<NavMenuColumnModel>();
|
||||
}
|
||||
|
||||
return _menu.Columns;
|
||||
}
|
||||
}
|
||||
105
Prefab.Web.Client/Layout/NavMenu.razor.css
Normal file
105
Prefab.Web.Client/Layout/NavMenu.razor.css
Normal file
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
31
Prefab.Web.Client/Layout/ReconnectModal.razor
Normal file
31
Prefab.Web.Client/Layout/ReconnectModal.razor
Normal file
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible">
|
||||
Resume
|
||||
</button>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please reload the page.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
157
Prefab.Web.Client/Layout/ReconnectModal.razor.css
Normal file
157
Prefab.Web.Client/Layout/ReconnectModal.razor.css
Normal file
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
63
Prefab.Web.Client/Layout/ReconnectModal.razor.js
Normal file
63
Prefab.Web.Client/Layout/ReconnectModal.razor.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
35
Prefab.Web.Client/Models/Catalog/ProductListingModel.cs
Normal file
35
Prefab.Web.Client/Models/Catalog/ProductListingModel.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
|
||||
namespace Prefab.Web.Client.Models.Catalog;
|
||||
|
||||
public sealed class ProductListingModel
|
||||
{
|
||||
public ProductCategoryModel Category { get; set; } = new();
|
||||
|
||||
public IReadOnlyList<ProductCardModel> Products { get; set; } = Array.Empty<ProductCardModel>();
|
||||
|
||||
public int Total { get; set; }
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
|
||||
public string Sort { get; set; } = "name:asc";
|
||||
|
||||
public string View { get; set; } = "grid";
|
||||
}
|
||||
|
||||
public sealed class ProductCategoryModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? HeroImageUrl { get; set; }
|
||||
|
||||
public string? Icon { get; set; }
|
||||
}
|
||||
10
Prefab.Web.Client/Models/CategoryListItemModel.cs
Normal file
10
Prefab.Web.Client/Models/CategoryListItemModel.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Prefab.Web.Client.Models;
|
||||
|
||||
public class CategoryListItemModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string ParentName { get; set; } = string.Empty;
|
||||
}
|
||||
14
Prefab.Web.Client/Models/CategoryModel.cs
Normal file
14
Prefab.Web.Client/Models/CategoryModel.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Prefab.Web.Client.Models;
|
||||
|
||||
public class CategoryModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string ParentName { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
10
Prefab.Web.Client/Models/Home/HomePageModel.cs
Normal file
10
Prefab.Web.Client/Models/Home/HomePageModel.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
|
||||
namespace Prefab.Web.Client.Models.Home;
|
||||
|
||||
public sealed class HomePageModel
|
||||
{
|
||||
public IReadOnlyList<ProductCardModel> FeaturedProducts { get; set; } = Array.Empty<ProductCardModel>();
|
||||
|
||||
public IReadOnlyList<CategoryCardModel> LatestCategories { get; set; } = Array.Empty<CategoryCardModel>();
|
||||
}
|
||||
12
Prefab.Web.Client/Models/Shared/MoneyModel.cs
Normal file
12
Prefab.Web.Client/Models/Shared/MoneyModel.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Web.Client.Models.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a monetary amount with its associated currency.
|
||||
/// </summary>
|
||||
public sealed class MoneyModel
|
||||
{
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string Currency { get; set; } = "USD";
|
||||
}
|
||||
|
||||
28
Prefab.Web.Client/Models/Shared/NavMenuModel.cs
Normal file
28
Prefab.Web.Client/Models/Shared/NavMenuModel.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Prefab.Web.Client.Models.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the navigation menu payload exposed by the gateway.
|
||||
/// </summary>
|
||||
/// <param name="Depth">Requested depth used when building the menu.</param>
|
||||
/// <param name="MaxItemsPerColumn">Maximum number of child links per column.</param>
|
||||
/// <param name="Columns">Menu columns sourced from root categories.</param>
|
||||
public sealed record NavMenuModel(int Depth, int MaxItemsPerColumn, IReadOnlyList<NavMenuColumnModel> Columns);
|
||||
|
||||
/// <summary>
|
||||
/// Represents an individual column in the navigation menu.
|
||||
/// </summary>
|
||||
/// <param name="Root">The root category anchoring the column.</param>
|
||||
/// <param name="Items">Featured child categories.</param>
|
||||
/// <param name="HasMore">Whether additional children exist beyond the truncated list.</param>
|
||||
/// <param name="SeeAllUrl">Link to view the full set of categories for the root.</param>
|
||||
public sealed record NavMenuColumnModel(NavMenuLinkModel Root, IReadOnlyList<NavMenuLinkModel> Items, bool HasMore, string SeeAllUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a navigable link within the menu.
|
||||
/// </summary>
|
||||
/// <param name="Id">Identifier of the item.</param>
|
||||
/// <param name="Name">Display name.</param>
|
||||
/// <param name="Slug">Slug used for routing.</param>
|
||||
/// <param name="IsLeaf">True when the link represents a terminal category.</param>
|
||||
/// <param name="Url">Absolute or relative navigation target.</param>
|
||||
public sealed record NavMenuLinkModel(string Id, string Name, string Slug, bool IsLeaf, string Url);
|
||||
71
Prefab.Web.Client/Models/Shared/Result.cs
Normal file
71
Prefab.Web.Client/Models/Shared/Result.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace Prefab.Web.Client.Models.Shared;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using FluentValidation;
|
||||
|
||||
public sealed class ResultProblem
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
|
||||
public string? Detail { get; init; }
|
||||
|
||||
public int? StatusCode { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string[]>? Errors { get; init; }
|
||||
|
||||
public static ResultProblem FromValidation(ValidationException exception)
|
||||
{
|
||||
var errors = exception.Errors
|
||||
.GroupBy(error => string.IsNullOrWhiteSpace(error.PropertyName) ? string.Empty : error.PropertyName)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(error => error.ErrorMessage ?? string.Empty).ToArray());
|
||||
|
||||
return new ResultProblem
|
||||
{
|
||||
Title = "Validation failed.",
|
||||
Detail = "Request did not satisfy validation rules.",
|
||||
StatusCode = (int)HttpStatusCode.BadRequest,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
public static ResultProblem Unexpected(string title, Exception exception, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) =>
|
||||
new()
|
||||
{
|
||||
Title = title,
|
||||
Detail = exception.Message,
|
||||
StatusCode = (int)statusCode
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of an operation that produces a value of type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The payload type returned when the operation succeeds.</typeparam>
|
||||
public sealed class Result<T>
|
||||
{
|
||||
public Result()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsSuccess => Problem is null;
|
||||
|
||||
public ResultProblem? Problem { get; init; }
|
||||
|
||||
public int? StatusCode => Problem?.StatusCode;
|
||||
|
||||
public T? Value { get; init; }
|
||||
|
||||
public static Result<T> Success(T value) => new()
|
||||
{
|
||||
Value = value ?? throw new ArgumentNullException(nameof(value))
|
||||
};
|
||||
|
||||
public static Result<T> Failure(ResultProblem problem) => new()
|
||||
{
|
||||
Problem = problem ?? throw new ArgumentNullException(nameof(problem))
|
||||
};
|
||||
}
|
||||
11
Prefab.Web.Client/Models/Slide.cs
Normal file
11
Prefab.Web.Client/Models/Slide.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Prefab.Web.Client.Models;
|
||||
|
||||
public class Slide
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public string TextWidth { get; set; } = string.Empty;
|
||||
public string TextPosition { get; set; } = string.Empty;
|
||||
public string AltText { get; set; } = string.Empty;
|
||||
public string GoToUrl { get; set; } = string.Empty;
|
||||
}
|
||||
180
Prefab.Web.Client/Pages/Catalog/Products.razor
Normal file
180
Prefab.Web.Client/Pages/Catalog/Products.razor
Normal file
@@ -0,0 +1,180 @@
|
||||
@page "/catalog/products"
|
||||
@inherits ResultComponentBase<ProductListingModel>
|
||||
|
||||
<PageTitle>@PageTitle</PageTitle>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="block">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col">
|
||||
<div class="products-view">
|
||||
<TelerikSkeleton Width="100%" Height="240px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (Page.Products.Count == 0)
|
||||
{
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col">
|
||||
<ol class="page__header-breadcrumbs breadcrumb">
|
||||
<li class="breadcrumb-item"><NavLink href="/">Home</NavLink></li>
|
||||
<li class="breadcrumb-item"><NavLink href="/catalog">Catalog</NavLink></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@Page.Category.Name</li>
|
||||
</ol>
|
||||
<h1 class="page__header-title decor-header decor-header--align--center">@Page.Category.Name</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Page.Category.Description))
|
||||
{
|
||||
<p class="text-muted text-center">@Page.Category.Description</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page__body">
|
||||
<div class="block">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col">
|
||||
<div class="alert alert-info mb-0">No products are available in this category yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col">
|
||||
<ol class="page__header-breadcrumbs breadcrumb">
|
||||
<li class="breadcrumb-item"><NavLink href="/">Home</NavLink></li>
|
||||
<li class="breadcrumb-item"><NavLink href="/catalog">Catalog</NavLink></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">@Page.Category.Name</li>
|
||||
</ol>
|
||||
<h1 class="page__header-title decor-header decor-header--align--center">@Page.Category.Name</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Page.Category.Description))
|
||||
{
|
||||
<p class="text-muted text-center">@Page.Category.Description</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page__body">
|
||||
<div class="block">
|
||||
<div class="container container--max--xl">
|
||||
<div class="row g-custom-30">
|
||||
<div class="col-12 col-lg-3 order-1 order-lg-0">
|
||||
<div class="block">
|
||||
<aside class="sidebar">
|
||||
<div class="widget widget--card">
|
||||
<div class="widget__title">Filters</div>
|
||||
<div class="widget__body text-muted">
|
||||
Filter options are coming soon.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-9">
|
||||
<div class="products-view">
|
||||
<div class="products-view__options view-options">
|
||||
<div class="view-options__layout">
|
||||
<button type="button" class="view-options__layout-button view-options__layout-button--active" disabled aria-disabled="true">
|
||||
<svg width="14px" height="14px">
|
||||
<use xlink:href="images/sprite.svg#grid-14"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="view-options__layout-button" disabled aria-disabled="true">
|
||||
<svg width="14px" height="14px">
|
||||
<use xlink:href="images/sprite.svg#list-14"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="view-options__legend">@LegendText</div>
|
||||
<div class="view-options__divider"></div>
|
||||
<div class="view-options__control">
|
||||
<label class="view-options__control-label" for="view-options-sort">Sort By:</label>
|
||||
<div class="view-options__control-content">
|
||||
<select class="form-select form-select-sm" id="view-options-sort" disabled>
|
||||
<option selected>Default</option>
|
||||
<option>Name (A-Z)</option>
|
||||
<option>Name (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-options__control">
|
||||
<label class="view-options__control-label" for="view-options-show">Show:</label>
|
||||
<div class="view-options__control-content">
|
||||
<select class="form-select form-select-sm" id="view-options-show" disabled>
|
||||
<option selected>12</option>
|
||||
<option>24</option>
|
||||
<option>48</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardGrid View="grid">
|
||||
@foreach (var product in Page.Products)
|
||||
{
|
||||
<CardGridItem>
|
||||
<ProductCard Product="product" Layout="grid" />
|
||||
</CardGridItem>
|
||||
}
|
||||
</CardGrid>
|
||||
|
||||
@if (TotalPages > 1)
|
||||
{
|
||||
<div class="products-view__pagination">
|
||||
<nav aria-label="Product pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item @(HasPreviousPage ? string.Empty : "disabled")">
|
||||
<span class="page-link" aria-label="Previous" aria-disabled="@(!HasPreviousPage)">
|
||||
<svg width="6px" height="9px">
|
||||
<use xlink:href="images/sprite.svg#arrow-left-6x9"></use>
|
||||
</svg>
|
||||
<span class="visually-hidden">Previous</span>
|
||||
</span>
|
||||
</li>
|
||||
@foreach (var pageNumber in VisiblePageNumbers)
|
||||
{
|
||||
var isActive = pageNumber == CurrentPage;
|
||||
<li class="page-item @(isActive ? "active" : string.Empty)">
|
||||
<span class="page-link">@pageNumber</span>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(HasNextPage ? string.Empty : "disabled")">
|
||||
<span class="page-link" aria-label="Next" aria-disabled="@(!HasNextPage)">
|
||||
<svg width="6px" height="9px">
|
||||
<use xlink:href="images/sprite.svg#arrow-right-6x9"></use>
|
||||
</svg>
|
||||
<span class="visually-hidden">Next</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
107
Prefab.Web.Client/Pages/Catalog/Products.razor.cs
Normal file
107
Prefab.Web.Client/Pages/Catalog/Products.razor.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Models.Catalog;
|
||||
using Prefab.Web.Client.Services;
|
||||
|
||||
namespace Prefab.Web.Client.Pages.Catalog;
|
||||
|
||||
public partial class Products
|
||||
{
|
||||
private string? _currentSlug;
|
||||
|
||||
[Inject]
|
||||
private IProductListingService ProductListingService { get; set; } = null!;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "category-slug")]
|
||||
public string? CategorySlug { get; set; }
|
||||
|
||||
protected bool IsLoading { get; private set; } = true;
|
||||
|
||||
protected ProductListingModel Page { get; private set; } = new();
|
||||
|
||||
protected string PageTitle => string.IsNullOrWhiteSpace(Page.Category.Name)
|
||||
? "Products"
|
||||
: $"{Page.Category.Name} Products";
|
||||
|
||||
protected string LegendText
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = EffectiveTotal;
|
||||
if (total == 0 || Page.Products.Count == 0)
|
||||
{
|
||||
return "Showing 0 products";
|
||||
}
|
||||
|
||||
var pageSize = EffectivePageSize;
|
||||
var startIndex = ((CurrentPage - 1) * pageSize) + 1;
|
||||
var endIndex = Math.Min(total, startIndex + Page.Products.Count - 1);
|
||||
|
||||
return $"Showing {startIndex}-{endIndex} of {total} products";
|
||||
}
|
||||
}
|
||||
|
||||
protected int CurrentPage => Page.Page > 0 ? Page.Page : 1;
|
||||
|
||||
protected int TotalPages => EffectiveTotal == 0 ? 0 : (int)Math.Ceiling(EffectiveTotal / (double)EffectivePageSize);
|
||||
|
||||
protected bool HasPreviousPage => CurrentPage > 1;
|
||||
|
||||
protected bool HasNextPage => TotalPages > CurrentPage;
|
||||
|
||||
protected IReadOnlyList<int> VisiblePageNumbers => BuildVisiblePages();
|
||||
|
||||
private int EffectiveTotal => Page.Total > 0 ? Page.Total : Page.Products.Count;
|
||||
|
||||
private int EffectivePageSize => Page.PageSize > 0 ? Page.PageSize : Math.Max(Page.Products.Count, 1);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var normalizedSlug = string.IsNullOrWhiteSpace(CategorySlug)
|
||||
? null
|
||||
: CategorySlug.Trim().ToLowerInvariant();
|
||||
|
||||
if (string.Equals(_currentSlug, normalizedSlug, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentSlug = normalizedSlug;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedSlug))
|
||||
{
|
||||
Page = new ProductListingModel();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = true;
|
||||
var result = await Execute(ct => ProductListingService.GetCategoryProducts(normalizedSlug, ct), CancellationToken.None);
|
||||
|
||||
Page = result.IsSuccess && result.Value is { } value
|
||||
? value
|
||||
: new ProductListingModel();
|
||||
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
private IReadOnlyList<int> BuildVisiblePages()
|
||||
{
|
||||
if (TotalPages <= 1)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
const int maxPagesToShow = 5;
|
||||
var first = Math.Max(1, CurrentPage - 2);
|
||||
var last = Math.Min(TotalPages, first + maxPagesToShow - 1);
|
||||
first = Math.Max(1, last - maxPagesToShow + 1);
|
||||
|
||||
var pages = new List<int>(last - first + 1);
|
||||
for (var page = first; page <= last; page++)
|
||||
{
|
||||
pages.Add(page);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
6
Prefab.Web.Client/Pages/Categories.razor
Normal file
6
Prefab.Web.Client/Pages/Categories.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
@page "/categories"
|
||||
|
||||
@inherits CategoriesComponent
|
||||
|
||||
<PageTitle>Categories</PageTitle>
|
||||
|
||||
32
Prefab.Web.Client/Pages/Categories.razor.cs
Normal file
32
Prefab.Web.Client/Pages/Categories.razor.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Models;
|
||||
using Prefab.Web.Client.Services;
|
||||
|
||||
namespace Prefab.Web.Client.Pages;
|
||||
|
||||
public class CategoriesComponent : ResultComponentBase<CategoriesPageModel>
|
||||
{
|
||||
[Inject]
|
||||
protected ICategoriesPageService PageService { get; set; } = null!;
|
||||
|
||||
protected CategoriesPageModel Page { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var result = await Execute(PageService.GetPage, CancellationToken.None);
|
||||
|
||||
if (result.IsSuccess && result.Value is { } value)
|
||||
{
|
||||
Page = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
Page = new CategoriesPageModel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CategoriesPageModel
|
||||
{
|
||||
public IEnumerable<CategoryListItemModel> Categories { get; set; } = [];
|
||||
}
|
||||
18
Prefab.Web.Client/Pages/Counter.razor
Normal file
18
Prefab.Web.Client/Pages/Counter.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@page "/counter"
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
<h1>Counter</h1>
|
||||
|
||||
<p role="status">Current count: @currentCount</p>
|
||||
|
||||
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
|
||||
|
||||
@code {
|
||||
private int currentCount = 0;
|
||||
|
||||
private void IncrementCount()
|
||||
{
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
260
Prefab.Web.Client/Pages/Home.razor
Normal file
260
Prefab.Web.Client/Pages/Home.razor
Normal file
@@ -0,0 +1,260 @@
|
||||
@page "/"
|
||||
|
||||
@inherits HomeComponent
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<TelerikMediaQuery Media="(max-width: 576px)" OnChange="@OnExtraSmallChange"></TelerikMediaQuery>
|
||||
<TelerikMediaQuery Media="(min-width: 577px) and (max-width: 768px)" OnChange="@OnSmallChange"></TelerikMediaQuery>
|
||||
<TelerikMediaQuery Media="(min-width: 769px) and (max-width: 992px)" OnChange="@OnMediumChange"></TelerikMediaQuery>
|
||||
<TelerikMediaQuery Media="(min-width: 993px) and (max-width: 1200px)" OnChange="@OnLargeChange"></TelerikMediaQuery>
|
||||
<TelerikMediaQuery Media="(min-width: 1201px)" OnChange="@OnExtraLargeChange"></TelerikMediaQuery>
|
||||
|
||||
<!-- block-hero-slider -->
|
||||
<div class="block block-slider block-slider--featured">
|
||||
<div class="container container--max--xl">
|
||||
<div class="slider slider--with-dots">
|
||||
<TelerikCarousel Width="100%"
|
||||
Height="500px"
|
||||
Arrows="true"
|
||||
Pageable="true"
|
||||
LoopPages="true"
|
||||
AutomaticPageChange="true"
|
||||
AutomaticPageChangeInterval="3000"
|
||||
Data="@Slides">
|
||||
<Template>
|
||||
<div class="slide">
|
||||
@if (!string.IsNullOrWhiteSpace(context.GoToUrl))
|
||||
{
|
||||
<a href="@context.GoToUrl" class="slide-link">
|
||||
<picture>
|
||||
<source media="(min-width: 768px)"
|
||||
srcset='@($"images/slides/slide{context.Number}.jpg 1x, images/slides/slide{context.Number}@2x.jpg 2x")'>
|
||||
<source media="(max-width: 767px)"
|
||||
srcset="images/slides/slide@(context.Number)-portrait.jpg 1x,
|
||||
images/slides/slide@(context.Number)-portrait@2x.jpg 2x">
|
||||
<img src="images/slides/slide@(context.Number).jpg"
|
||||
alt="@(!string.IsNullOrWhiteSpace(context.AltText) ? context.AltText : $"Slide {context.Number}")"
|
||||
style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
</picture>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<picture>
|
||||
<source media="(min-width: 768px)"
|
||||
srcset='@($"images/slides/slide{context.Number}.jpg 1x, images/slides/slide{context.Number}@2x.jpg 2x")'>
|
||||
<source media="(max-width: 767px)"
|
||||
srcset="images/slides/slide@(context.Number)-portrait.jpg 1x,
|
||||
images/slides/slide@(context.Number)-portrait@2x.jpg 2x">
|
||||
<img src="images/slides/slide@(context.Number).jpg"
|
||||
alt="@(!string.IsNullOrWhiteSpace(context.AltText) ? context.AltText : $"Slide {context.Number}")"
|
||||
style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
</picture>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(context.Text))
|
||||
{
|
||||
<div class=@($"slide-text {context.TextPosition} {context.TextWidth}")>
|
||||
@if (!string.IsNullOrWhiteSpace(context.Text))
|
||||
{
|
||||
<h2>@context.Text</h2>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Template>
|
||||
</TelerikCarousel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- block-hero-slider / end -->
|
||||
|
||||
<!-- block-featured-products -->
|
||||
@if (IsFeaturedProductsLoading)
|
||||
{
|
||||
<div class="block block-products-carousel featured-products-block featured-products-block--loading">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Featured Products</h2>
|
||||
</div>
|
||||
<div class="block-products-carousel__slider slider slider--with-arrows">
|
||||
<div class="product-group" style="display: flex; flex-wrap: wrap; width: 100%;">
|
||||
@for (var i = 0; i < NumberOfFeaturedProductCardsPerGroup; i++)
|
||||
{
|
||||
<div class="product-wrapper" style="@CalculateWidthOfFeaturedProductCard()">
|
||||
<TelerikSkeleton Width="100%" Height="260px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Rectangle" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (FeaturedProductsHaveItemsToShow)
|
||||
{
|
||||
<div class="block block-products-carousel featured-products-block">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Featured Products</h2>
|
||||
</div>
|
||||
<div class="block-products-carousel__slider slider slider--with-arrows">
|
||||
<TelerikCarousel Width="100%"
|
||||
Height="350px"
|
||||
Arrows="true"
|
||||
Pageable="false"
|
||||
LoopPages="true"
|
||||
AutomaticPageChange="false"
|
||||
Data="@GetProductCardGroups()"
|
||||
Class="owl-carousel product-carousel-custom">
|
||||
<Template>
|
||||
<div class="product-group" style="display: flex; flex-wrap: wrap; width: 100%;">
|
||||
@foreach (var product in context)
|
||||
{
|
||||
<div class="product-wrapper" style="@CalculateWidthOfFeaturedProductCard()">
|
||||
<ProductCard Product="product"
|
||||
CssClass="catalog-card catalog-card--layout--grid" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Template>
|
||||
</TelerikCarousel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="block block-products-carousel featured-products-block featured-products-block--empty">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Featured Products</h2>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
Featured products will appear here once items are available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- block-featured-products / end -->
|
||||
|
||||
<!-- block-collections -->
|
||||
<div class="block block-collections">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Latest Collections</h2>
|
||||
</div>
|
||||
<div class="row g-custom-30">
|
||||
<div class="col-12 col-md-6 col-lg-5">
|
||||
<div class="block-collections__item block-collections__item--start">
|
||||
<div class="block-collections__info block-collections__info--top-start">
|
||||
<div class="block-collections__name">Smart Building Solutions</div>
|
||||
<div class="block-collections__description">
|
||||
Complete intelligent building automation systems featuring advanced lighting controls,
|
||||
occupancy sensors, and energy management solutions for modern commercial spaces.
|
||||
</div>
|
||||
<div class="block-collections__button">
|
||||
<a href="/catalog/smart-building" class="btn btn-primary">Explore Collection</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-collections__image">
|
||||
<a href="/catalog/smart-building">
|
||||
<picture>
|
||||
<source media="(min-width: 992px)" srcset="images/collections/collection1-lg.jpg 1x,
|
||||
images/collections/collection1-lg@2x.jpg 2x">
|
||||
<source media="(max-width: 991px)" srcset="images/collections/collection1.jpg 1x,
|
||||
images/collections/collection1@2x.jpg 2x">
|
||||
<img src="images/collections/collection1-lg.jpg" alt="Smart Building Solutions Collection">
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-7 pt-5 pt-md-0">
|
||||
<div class="block-collections__item block-collections__item--end">
|
||||
<div class="block-collections__image">
|
||||
<a href="/catalog/prefabricated-electrical-assemblies/pre-assembled-panels">
|
||||
<picture>
|
||||
<source media="(min-width: 992px)" srcset="images/collections/collection2-lg.jpg 1x,
|
||||
images/collections/collection2-lg@2x.jpg 2x">
|
||||
<source media="(max-width: 991px)" srcset="images/collections/collection2.jpg 1x,
|
||||
images/collections/collection2@2x.jpg 2x">
|
||||
<img src="images/collections/collection2-lg.jpg" alt="Pre-Assembled Panel Collection">
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
<div class="block-collections__info block-collections__info--bottom-end">
|
||||
<div class="block-collections__name">Pre-Assembled Panel Systems</div>
|
||||
<div class="block-collections__description">
|
||||
Ready-to-install electrical panels and distribution assemblies, factory-tested and certified.
|
||||
Reduce installation time by up to 70% with our professionally pre-wired solutions designed
|
||||
for commercial and industrial applications.
|
||||
</div>
|
||||
<div class="block-collections__button">
|
||||
<a href="/catalog/prefabricated-electrical-assemblies/pre-assembled-panels" class="btn btn-primary">View Panels</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- block-collections / end -->
|
||||
|
||||
<!-- block-shop-categories -->
|
||||
@if (AreCategoriesLoading)
|
||||
{
|
||||
<div class="block block-shop-categories home-categories-block home-categories-block--loading">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Latest Categories</h2>
|
||||
</div>
|
||||
<div class="categories-list">
|
||||
@for (var i = 0; i < NumberOfCategorySkeletons; i++)
|
||||
{
|
||||
<div class="card category-card">
|
||||
<div class="category-card__image">
|
||||
<TelerikSkeleton Width="100%" Height="180px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Rectangle" />
|
||||
</div>
|
||||
<div class="category-card__name">
|
||||
<TelerikSkeleton Width="70%" Height="20px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Text" />
|
||||
</div>
|
||||
<div class="category-card__products">
|
||||
<TelerikSkeleton Width="50%" Height="16px" ShapeType="@Telerik.Blazor.SkeletonShapeType.Text" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CategoriesHaveItemsToShow)
|
||||
{
|
||||
<div class="block block-shop-categories home-categories-block">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Latest Categories</h2>
|
||||
</div>
|
||||
<div class="categories-list">
|
||||
@foreach (var category in LatestCategories)
|
||||
{
|
||||
<CategoryCard Category="category" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="block block-shop-categories home-categories-block home-categories-block--empty">
|
||||
<div class="container container--max--xl">
|
||||
<div class="block__title">
|
||||
<h2 class="decor-header decor-header--align--center">Latest Categories</h2>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0">
|
||||
Categories will appear here once items are available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- block-shop-categories / end -->
|
||||
197
Prefab.Web.Client/Pages/Home.razor.cs
Normal file
197
Prefab.Web.Client/Pages/Home.razor.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Models.Catalog;
|
||||
using Prefab.Web.Client.Models.Home;
|
||||
using Prefab.Web.Client.Services;
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
|
||||
namespace Prefab.Web.Client.Pages;
|
||||
|
||||
public class HomeComponent : ResultComponentBase<HomePageModel>
|
||||
{
|
||||
private const int CategorySkeletonSlots = 4;
|
||||
|
||||
private readonly List<ProductCardModel> _featuredProducts = [];
|
||||
private readonly List<CategoryCardModel> _latestCategories = [];
|
||||
|
||||
[Inject]
|
||||
protected IHomePageService HomePageService { get; set; } = null!;
|
||||
|
||||
public IEnumerable<Slide> Slides => new List<Slide>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Number = 1,
|
||||
Text = "Take on more work, without adding more people.",
|
||||
TextWidth = "full",
|
||||
TextPosition = "top-right"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Number = 4,
|
||||
Text = "Real-time visibility to your assemblies.",
|
||||
TextWidth = "half",
|
||||
TextPosition = "center-right"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Number = 2,
|
||||
Text = "Explore, configure, order, install.",
|
||||
TextWidth = "full",
|
||||
TextPosition = "top-left"
|
||||
}
|
||||
};
|
||||
|
||||
protected IReadOnlyList<ProductCardModel> FeaturedProducts => _featuredProducts;
|
||||
|
||||
protected bool IsFeaturedProductsLoading { get; private set; } = true;
|
||||
|
||||
protected bool FeaturedProductsHaveItemsToShow => _featuredProducts.Count > 0;
|
||||
|
||||
protected int NumberOfFeaturedProductCardsPerGroup { get; private set; } = 4;
|
||||
|
||||
protected IReadOnlyList<CategoryCardModel> LatestCategories => _latestCategories;
|
||||
|
||||
protected bool AreCategoriesLoading { get; private set; } = true;
|
||||
|
||||
protected bool CategoriesHaveItemsToShow => _latestCategories.Count > 0;
|
||||
|
||||
protected int NumberOfCategorySkeletons => CategorySkeletonSlots;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadHomeContentAsync();
|
||||
}
|
||||
|
||||
protected IReadOnlyList<IReadOnlyList<ProductCardModel>> GetProductCardGroups()
|
||||
{
|
||||
if (!FeaturedProductsHaveItemsToShow)
|
||||
{
|
||||
return Array.Empty<IReadOnlyList<ProductCardModel>>();
|
||||
}
|
||||
|
||||
var groupSize = Math.Max(1, NumberOfFeaturedProductCardsPerGroup);
|
||||
var groups = new List<IReadOnlyList<ProductCardModel>>();
|
||||
|
||||
for (var index = 0; index < _featuredProducts.Count; index += groupSize)
|
||||
{
|
||||
var segment = _featuredProducts
|
||||
.Skip(index)
|
||||
.Take(groupSize)
|
||||
.ToList();
|
||||
|
||||
if (segment.Count > 0)
|
||||
{
|
||||
groups.Add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
protected string CalculateWidthOfFeaturedProductCard()
|
||||
{
|
||||
var groupSize = Math.Max(1, NumberOfFeaturedProductCardsPerGroup);
|
||||
var width = 100d / groupSize;
|
||||
return $"width: {width:0.##}%; padding: 0 10px; box-sizing: border-box;";
|
||||
}
|
||||
|
||||
#region Media Query Handlers
|
||||
|
||||
protected void OnExtraSmallChange(bool matches)
|
||||
{
|
||||
if (!matches)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NumberOfFeaturedProductCardsPerGroup = 1;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnSmallChange(bool matches)
|
||||
{
|
||||
if (!matches)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NumberOfFeaturedProductCardsPerGroup = 2;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnMediumChange(bool matches)
|
||||
{
|
||||
if (!matches)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NumberOfFeaturedProductCardsPerGroup = 2;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnLargeChange(bool matches)
|
||||
{
|
||||
if (!matches)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NumberOfFeaturedProductCardsPerGroup = 3;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void OnExtraLargeChange(bool matches)
|
||||
{
|
||||
if (!matches)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NumberOfFeaturedProductCardsPerGroup = 4;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task LoadHomeContentAsync()
|
||||
{
|
||||
IsFeaturedProductsLoading = true;
|
||||
AreCategoriesLoading = true;
|
||||
_featuredProducts.Clear();
|
||||
_latestCategories.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await Execute(HomePageService.Get, CancellationToken.None);
|
||||
|
||||
if (result.IsSuccess && result.Value is { } payload)
|
||||
{
|
||||
if (payload.FeaturedProducts.Count > 0)
|
||||
{
|
||||
_featuredProducts.AddRange(payload.FeaturedProducts);
|
||||
}
|
||||
|
||||
if (payload.LatestCategories.Count > 0)
|
||||
{
|
||||
_latestCategories.AddRange(payload.LatestCategories);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsFeaturedProductsLoading = false;
|
||||
AreCategoriesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public class Slide
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public string TextWidth { get; set; } = string.Empty;
|
||||
public string TextPosition { get; set; } = string.Empty;
|
||||
public string AltText { get; set; } = string.Empty;
|
||||
public string GoToUrl { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
246
Prefab.Web.Client/Pages/Home.razor.css
Normal file
246
Prefab.Web.Client/Pages/Home.razor.css
Normal file
@@ -0,0 +1,246 @@
|
||||
.slide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-text {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 5px rgba(0,0,0,0.7);
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.slide-text.full {
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.slide-text.half {
|
||||
width: 45%;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.slide-text.third {
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.slide-text.bottom-left {
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.slide-text.bottom-right {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slide-text.bottom-center {
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slide-text.top-left {
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.slide-text.top-right {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slide-text.top-center {
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slide-text.center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slide-text.center-left {
|
||||
top: 50%;
|
||||
left: 24px;
|
||||
transform: translateY(-50%);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.slide-text.center-right {
|
||||
top: 50%;
|
||||
right: 24px;
|
||||
transform: translateY(-50%);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.slide-text {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.slide-text.full,
|
||||
.slide-text.half,
|
||||
.slide-text.third {
|
||||
width: 70%;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.slide-text h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.slide-text p {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.slide-text {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.slide-text.full,
|
||||
.slide-text.half,
|
||||
.slide-text.third {
|
||||
width: 85%;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.slide-text h2 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.slide-text p {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.slide-text.top-left,
|
||||
.slide-text.bottom-left {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.slide-text.center-left {
|
||||
left: 18px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.slide-text.top-right,
|
||||
.slide-text.bottom-right {
|
||||
right: 18px;
|
||||
}
|
||||
|
||||
.slide-text.center-right {
|
||||
right: 18px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.slide-text.top-center,
|
||||
.slide-text.bottom-center {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.slide-text.center {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.slide-text {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.slide-text.full,
|
||||
.slide-text.half,
|
||||
.slide-text.third {
|
||||
width: calc(100% - 32px);
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.slide-text.top-left,
|
||||
.slide-text.bottom-left,
|
||||
.slide-text.center-left {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.slide-text.top-right,
|
||||
.slide-text.bottom-right,
|
||||
.slide-text.center-right {
|
||||
right: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.slide-text.top-center,
|
||||
.slide-text.bottom-center {
|
||||
width: calc(100% - 48px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.slide-text.center {
|
||||
width: calc(100% - 48px);
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.slide-text h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.slide-text p {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom styling for product carousel to remove border and background */
|
||||
::deep .product-carousel-custom.k-scrollview {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
::deep .product-carousel-custom .k-scrollview-wrap {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
::deep .product-carousel-custom .k-scrollview-prev,
|
||||
::deep .product-carousel-custom .k-scrollview-next {
|
||||
background-color: transparent !important;
|
||||
color: #ffd599 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
::deep .product-carousel-custom .k-scrollview-prev:hover,
|
||||
::deep .product-carousel-custom .k-scrollview-next:hover,
|
||||
::deep .product-carousel-custom .k-scrollview-prev:focus,
|
||||
::deep .product-carousel-custom .k-scrollview-next:focus {
|
||||
color: #576166 !important;
|
||||
}
|
||||
|
||||
::deep .product-carousel-custom .k-scrollview-prev .k-icon,
|
||||
::deep .product-carousel-custom .k-scrollview-next .k-icon {
|
||||
color: inherit !important;
|
||||
}
|
||||
12
Prefab.Web.Client/Pages/NotFound.razor
Normal file
12
Prefab.Web.Client/Pages/NotFound.razor
Normal file
@@ -0,0 +1,12 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
@rendermode InteractiveAuto
|
||||
|
||||
<PageTitle>Page Not Found</PageTitle>
|
||||
|
||||
<div class="block mt-4">
|
||||
<div class="container container--max--xl">
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
43
Prefab.Web.Client/Pages/ResultComponentBase.cs
Normal file
43
Prefab.Web.Client/Pages/ResultComponentBase.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Client.Services;
|
||||
|
||||
namespace Prefab.Web.Client.Pages;
|
||||
|
||||
public abstract class ResultComponentBase<T> : ComponentBase
|
||||
{
|
||||
[Inject]
|
||||
protected INotificationService NotificationService { get; set; } = null!;
|
||||
|
||||
protected async Task<Result<T>> Execute(
|
||||
Func<CancellationToken, Task<Result<T>>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await operation(cancellationToken);
|
||||
|
||||
if (!result.IsSuccess && result.Problem is { } problem)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(problem.Detail)
|
||||
? problem.Title ?? "An unexpected error occurred."
|
||||
: problem.Detail;
|
||||
|
||||
NotificationService.ShowError(message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var problem = ResultProblem.Unexpected("Failed to complete the requested operation.", exception);
|
||||
var fallback = problem.Detail ?? problem.Title ?? "Failed to complete the requested operation.";
|
||||
NotificationService.ShowError(fallback);
|
||||
return Result<T>.Failure(problem);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Prefab.Web.Client/Pages/Weather.razor
Normal file
63
Prefab.Web.Client/Pages/Weather.razor
Normal file
@@ -0,0 +1,63 @@
|
||||
@page "/weather"
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
<h1>Weather</h1>
|
||||
|
||||
<p>This component demonstrates showing data.</p>
|
||||
|
||||
@if (forecasts == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var forecast in forecasts)
|
||||
{
|
||||
<tr>
|
||||
<td>@forecast.Date.ToShortDateString()</td>
|
||||
<td>@forecast.TemperatureC</td>
|
||||
<td>@forecast.TemperatureF</td>
|
||||
<td>@forecast.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private WeatherForecast[]? forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Simulate asynchronous loading to demonstrate a loading indicator
|
||||
await Task.Delay(500);
|
||||
|
||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = startDate.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int TemperatureC { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
}
|
||||
24
Prefab.Web.Client/Prefab.Web.Client.csproj
Normal file
24
Prefab.Web.Client/Prefab.Web.Client.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telerik.UI.for.Blazor" Version="11.3.0" PrivateAssets="none" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Components\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
19
Prefab.Web.Client/Program.cs
Normal file
19
Prefab.Web.Client/Program.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using Prefab.Web.Client.Services;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||
});
|
||||
|
||||
builder.Services.AddTelerikBlazor();
|
||||
builder.Services.AddSingleton<INotificationService, NotificationService>();
|
||||
|
||||
builder.Services.AddScoped<ICategoriesPageService, CategoriesPageService>();
|
||||
builder.Services.AddScoped<INavMenuService, NavMenuService>();
|
||||
builder.Services.AddScoped<IProductListingService, ProductListingService>();
|
||||
builder.Services.AddScoped<IHomePageService, HomePageService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
6
Prefab.Web.Client/Routes.razor
Normal file
6
Prefab.Web.Client/Routes.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
32
Prefab.Web.Client/Services/HomePageService.cs
Normal file
32
Prefab.Web.Client/Services/HomePageService.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Prefab.Web.Client.Models.Home;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
|
||||
namespace Prefab.Web.Client.Services;
|
||||
|
||||
public interface IHomePageService
|
||||
{
|
||||
Task<Result<HomePageModel>> Get(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class HomePageService(HttpClient httpClient) : IHomePageService
|
||||
{
|
||||
public async Task<Result<HomePageModel>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync("bff/home", cancellationToken);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<Result<HomePageModel>>(cancellationToken: cancellationToken);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var status = (HttpStatusCode)response.StatusCode;
|
||||
var exception = new InvalidOperationException($"Response {(int)status} did not contain a valid payload.");
|
||||
var problem = ResultProblem.Unexpected("Failed to retrieve home page content.", exception, status);
|
||||
|
||||
return Result<HomePageModel>.Failure(problem);
|
||||
}
|
||||
}
|
||||
30
Prefab.Web.Client/Services/ICategoriesPageService.cs
Normal file
30
Prefab.Web.Client/Services/ICategoriesPageService.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Net.Http.Json;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Client.Pages;
|
||||
|
||||
namespace Prefab.Web.Client.Services;
|
||||
|
||||
public interface ICategoriesPageService
|
||||
{
|
||||
Task<Result<CategoriesPageModel>> GetPage(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class CategoriesPageService(HttpClient httpClient) : ICategoriesPageService
|
||||
{
|
||||
public async Task<Result<CategoriesPageModel>> GetPage(CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync("bff/categories/page", cancellationToken);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<Result<CategoriesPageModel>>(cancellationToken);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var exception = new InvalidOperationException($"Response {(int)response.StatusCode} did not contain a valid payload.");
|
||||
var unexpectedProblem = ResultProblem.Unexpected("Failed to retrieve categories.", exception, response.StatusCode);
|
||||
|
||||
return Result<CategoriesPageModel>.Failure(unexpectedProblem);
|
||||
}
|
||||
}
|
||||
60
Prefab.Web.Client/Services/INavMenuService.cs
Normal file
60
Prefab.Web.Client/Services/INavMenuService.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
|
||||
namespace Prefab.Web.Client.Services;
|
||||
|
||||
public interface INavMenuService
|
||||
{
|
||||
Task<Result<NavMenuModel>> Get(int depth, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NavMenuService : INavMenuService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public NavMenuService(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<Result<NavMenuModel>> Get(int depth, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedDepth = Math.Clamp(depth, 1, 2);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync($"/bff/nav-menu/{normalizedDepth}", cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<Result<NavMenuModel>>(cancellationToken: cancellationToken);
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (HttpStatusCode)response.StatusCode;
|
||||
var problem = ResultProblem.Unexpected(
|
||||
"Failed to load navigation menu.",
|
||||
new HttpRequestException($"Navigation menu request failed with status code {(int)statusCode} ({statusCode})."),
|
||||
statusCode);
|
||||
|
||||
return Result<NavMenuModel>.Failure(problem);
|
||||
}
|
||||
|
||||
var emptyProblem = ResultProblem.Unexpected(
|
||||
"Failed to load navigation menu.",
|
||||
new InvalidOperationException("Navigation menu response body was empty."),
|
||||
(HttpStatusCode)response.StatusCode);
|
||||
|
||||
return Result<NavMenuModel>.Failure(emptyProblem);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
var problem = ResultProblem.Unexpected("Failed to load navigation menu.", exception);
|
||||
return Result<NavMenuModel>.Failure(problem);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
Prefab.Web.Client/Services/INotificationService.cs
Normal file
78
Prefab.Web.Client/Services/INotificationService.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Telerik.Blazor;
|
||||
using Telerik.Blazor.Components;
|
||||
|
||||
namespace Prefab.Web.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for displaying notifications of various types, such as errors, successes, and warnings, within an
|
||||
/// application.
|
||||
/// </summary>
|
||||
/// <remarks>Implementations of this interface provide methods to present user-facing messages in a consistent
|
||||
/// manner. The interface supports attaching a notification component and displaying messages with different severity
|
||||
/// levels. This is typically used to inform users about the outcome of operations or to alert them to important
|
||||
/// events.</remarks>
|
||||
public interface INotificationService
|
||||
{
|
||||
void Attach(TelerikNotification notification);
|
||||
void ShowError(string message);
|
||||
void ShowSuccess(string message);
|
||||
void ShowWarning(string message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides functionality to display error, success, and warning notifications using a Telerik notification component.
|
||||
/// </summary>
|
||||
/// <remarks>This service queues notifications if the Telerik notification component is not yet attached, and
|
||||
/// displays them once the component becomes available. It is intended to be used as an abstraction for showing
|
||||
/// user-facing notifications in applications that utilize Telerik UI components.</remarks>
|
||||
public class NotificationService : INotificationService
|
||||
{
|
||||
private readonly Queue<NotificationModel> _pending = new();
|
||||
private TelerikNotification? _notification;
|
||||
|
||||
public void Attach(TelerikNotification notification)
|
||||
{
|
||||
_notification = notification ?? throw new ArgumentNullException(nameof(notification));
|
||||
FlushPending();
|
||||
}
|
||||
|
||||
public void ShowError(string message) =>
|
||||
Show(message, ThemeConstants.Notification.ThemeColor.Error);
|
||||
|
||||
public void ShowSuccess(string message) =>
|
||||
Show(message, ThemeConstants.Notification.ThemeColor.Success);
|
||||
|
||||
public void ShowWarning(string message) =>
|
||||
Show(message, ThemeConstants.Notification.ThemeColor.Warning);
|
||||
|
||||
private void Show(string message, string themeColor)
|
||||
{
|
||||
var model = new NotificationModel
|
||||
{
|
||||
Text = message,
|
||||
ThemeColor = themeColor,
|
||||
CloseAfter = 3000
|
||||
};
|
||||
|
||||
if (_notification is { } notification)
|
||||
{
|
||||
notification.Show(model);
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Enqueue(model);
|
||||
}
|
||||
|
||||
private void FlushPending()
|
||||
{
|
||||
if (_notification is not { } notification)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (_pending.Count > 0)
|
||||
{
|
||||
notification.Show(_pending.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Prefab.Web.Client/Services/IProductListingService.cs
Normal file
37
Prefab.Web.Client/Services/IProductListingService.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
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 IProductListingService
|
||||
{
|
||||
Task<Result<ProductListingModel>> GetCategoryProducts(string categorySlug, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ProductListingService(HttpClient httpClient) : IProductListingService
|
||||
{
|
||||
public async Task<Result<ProductListingModel>> GetCategoryProducts(string categorySlug, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(categorySlug);
|
||||
|
||||
using var response = await httpClient.GetAsync(
|
||||
$"/bff/catalog/categories/{Uri.EscapeDataString(categorySlug)}" +
|
||||
"/models",
|
||||
cancellationToken);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<Result<ProductListingModel>>(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 unexpectedProblem = ResultProblem.Unexpected("Failed to retrieve category products.", exception, statusCode);
|
||||
|
||||
return Result<ProductListingModel>.Failure(unexpectedProblem);
|
||||
}
|
||||
}
|
||||
12
Prefab.Web.Client/ViewModels/Catalog/CategoryCardModel.cs
Normal file
12
Prefab.Web.Client/ViewModels/Catalog/CategoryCardModel.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Web.Client.ViewModels.Catalog;
|
||||
|
||||
public sealed class CategoryCardModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
public string? SecondaryText { get; set; }
|
||||
}
|
||||
42
Prefab.Web.Client/ViewModels/Catalog/ProductCardModel.cs
Normal file
42
Prefab.Web.Client/ViewModels/Catalog/ProductCardModel.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
|
||||
namespace Prefab.Web.Client.ViewModels.Catalog;
|
||||
|
||||
public sealed class ProductCardModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
public string? PrimaryImageUrl { get; set; }
|
||||
|
||||
public string? SecondaryImageUrl { get; set; }
|
||||
|
||||
public string? CategoryName { get; set; }
|
||||
|
||||
public string? CategoryUrl { get; set; }
|
||||
|
||||
public MoneyModel? FromPrice { get; set; }
|
||||
|
||||
public bool IsPriced { get; set; }
|
||||
|
||||
public decimal? OldPrice { get; set; }
|
||||
|
||||
public bool IsOnSale { get; set; }
|
||||
|
||||
public int Rating { get; set; }
|
||||
|
||||
public int ReviewCount { get; set; }
|
||||
|
||||
public IList<string> Badges { get; set; } = new List<string>();
|
||||
|
||||
public string? Sku { get; set; }
|
||||
|
||||
public DateTimeOffset LastModifiedOn { get; set; }
|
||||
|
||||
public bool HasPrice => IsPriced && FromPrice is not null;
|
||||
}
|
||||
22
Prefab.Web.Client/_Imports.razor
Normal file
22
Prefab.Web.Client/_Imports.razor
Normal file
@@ -0,0 +1,22 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
|
||||
@using Prefab.Web.Client
|
||||
@using Prefab.Web.Client.Layout
|
||||
|
||||
@using Telerik.Blazor
|
||||
@using Telerik.Blazor.Components
|
||||
@using Telerik.SvgIcons
|
||||
@using Telerik.FontIcons
|
||||
|
||||
@using Prefab.Web.Client.Components.Catalog
|
||||
@using Prefab.Web.Client.Components.Shared
|
||||
@using Prefab.Web.Client.Models.Catalog
|
||||
@using Prefab.Web.Client.Pages
|
||||
@using Prefab.Web.Client.ViewModels.Catalog
|
||||
8
Prefab.Web.Client/wwwroot/appsettings.Development.json
Normal file
8
Prefab.Web.Client/wwwroot/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Prefab.Web.Client/wwwroot/appsettings.json
Normal file
8
Prefab.Web.Client/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user