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

View File

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

View 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;
}

View 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;
}

View 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;
}

View 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";
}

View File

@@ -0,0 +1,11 @@
@if (ChildContent is not null)
{
<div class="products-list__item">
@ChildContent
</div>
}
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Prefab.Web.Client.Components.Shared;
public enum CardVariant
{
Product,
Category
}