Update product service and detail view with gallery support

This commit is contained in:
masoodafar-web
2025-11-28 09:10:22 +03:30
parent 7d545a2849
commit 82ddafda33
4 changed files with 279 additions and 38 deletions

View File

@@ -14,9 +14,8 @@
<PackageReference Include="Foursat.FrontOffice.BFF.User.Protobuf" Version="0.0.116" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserAddress.Protobuf" Version="0.0.114" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserOrder.Protobuf" Version="0.0.114" />
<PackageReference Include="FrontOffice.BFF.Products.Protobuf" Version="0.0.11" />
<PackageReference Include="FrontOffice.BFF.ShopingCart.Protobuf" Version="0.0.15" />
<PackageReference Include="FrontOffice.BFF.UserWallet.Protobuf" Version="0.0.11" />
<PackageReference Include="Foursat.FrontOffice.BFF.ShopingCart.Protobuf" Version="0.0.15" />
<PackageReference Include="Foursat.FrontOffice.BFF.UserWallet.Protobuf" Version="0.0.12" />
<PackageReference Include="MudBlazor" Version="8.14.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Mapster" Version="7.4.0" />
@@ -27,6 +26,10 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../FrontOffice.BFF/src/Protobufs/FrontOffice.BFF.Products.Protobuf/FrontOffice.BFF.Products.Protobuf.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components" />
</ItemGroup>

View File

@@ -7,7 +7,7 @@
{
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudStack AlignItems="AlignItems.Center">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<MudProgressCircular Color="Color.Primary" Indeterminate="true"/>
</MudStack>
</MudContainer>
}
@@ -15,7 +15,9 @@ else if (_product is null)
{
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudAlert Severity="Severity.Warning">محصول یافت نشد.</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Products)">بازگشت</MudButton>
<MudButton Class="mt-2" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack"
OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Products)">بازگشت
</MudButton>
</MudContainer>
}
else
@@ -24,27 +26,48 @@ else
<MudGrid Spacing="3">
<MudItem xs="12" md="6">
<MudPaper Class="rounded-lg pa-2">
<MudImage Src="@_product.ImageUrl" Alt="@_product.Title" Style="width:100%" Class="rounded-lg" />
@if (!string.IsNullOrWhiteSpace(MainImageUrl))
{
<div class="rounded-lg" style="width:100%;height:360px;background-image: url('@MainImageUrl');background-size: cover; background-position: center;border-radius: 0.5rem;color: transparent;" >@MainImageAlt</div>
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="360px" />
}
@if (_galleryItems.Count > 1)
{
<MudStack Row="true" Spacing="1" Class="mt-3" Style="overflow-x:auto;flex-wrap:wrap;">
@foreach (var item in _galleryItems)
{
<MudButton Variant="Variant.Text" Class="p-0" Style="min-width:0;" OnClick="() => SelectGalleryImage(item)">
<MudImage Src="@GetThumbnailUrl(item)" Alt="@item.Title" Class="rounded-sm" Style="@GetThumbnailStyle(item)" />
</MudButton>
}
</MudStack>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudStack Spacing="2">
<MudText Typo="Typo.h4">@_product.Title</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary">@_product.Description</MudText>
<MudDivider Class="my-2" />
<MudDivider Class="my-2"/>
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText>
<!-- Desktop/tablet actions -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudNumericField T="int" @bind-Value="_qty" Min="1" Max="20" Immediate="true" HideSpinButtons="true" Style="max-width:120px" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">افزودن به سبد</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Cart)">مشاهده سبد</MudButton>
<MudNumericField T="int" @bind-Value="_qty" Min="@MinQty" Max="@MaxQty" Immediate="true"
HideSpinButtons="true" Style="max-width:120px"/>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">افزودن به
سبد
</MudButton>
</MudStack>
</MudHidden>
<!-- Mobile actions: stacked and full-width -->
<!-- Mobile actions use sticky bar rendered below -->
</MudStack>
</MudItem>
@@ -52,11 +75,56 @@ else
</MudGrid>
</MudContainer>
<MudHidden Breakpoint="Breakpoint.MdAndUp" Style="position: absolute; bottom: 0; left: 0; width: 100%">
<MudStack Spacing="2 " Class="d-flex flex-row-">
<MudNumericField T="int" @bind-Value="_qty" Min="1" Max="20" Immediate="true" HideSpinButtons="true" Class="w-100-mobile" />
<MudButton Class="w-100-mobile" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">افزودن به سبد</MudButton>
<MudButton Class="w-100-mobile" Variant="Variant.Outlined" Color="Color.Secondary" OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Cart)">مشاهده سبد</MudButton>
</MudStack>
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudPaper Class="mud-width-full mud-elevation-3 pa-3"
Style="position:sticky;bottom:0;left:0;z-index:9;border-top-left-radius:1rem;border-top-right-radius:1rem;">
<MudGrid Class="align-center" Justify="Justify.SpaceBetween">
<MudItem xs="6">
<MudStack Spacing="1">
@if (HasDiscount && OriginalPrice is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption"
Class="mud-text-secondary mud-line-through">@FormatPrice(OriginalPrice.Value)</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Label="true">@($"٪{_product!.Discount}")</MudChip>
</MudStack>
}
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(TotalPrice)</MudText>
</MudStack>
</MudItem>
@if (IsInCart)
{
<MudItem xs="5" Class="">
<MudPaper Class="mud-width-full d-flex align-center justify-space-between px-2"
Elevation="1" Style="border-radius:1rem;">
<MudIconButton
Icon="@(CurrentCartQuantity == 1 ? Icons.Material.Filled.Delete : Icons.Material.Filled.Remove)"
Color="Color.Error" Variant="Variant.Text" OnClick="RemoveFromCart"/>
<MudText Typo="Typo.subtitle2" Class="mud-font-weight-bold">@CurrentCartQuantity</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary"
Variant="Variant.Text" Disabled="@(CurrentCartQuantity >= MaxQty)"
OnClick="AddToCart"/>
</MudPaper>
</MudItem>
}
else
{
<MudItem xs="6" >
<MudStack Spacing="1">
<MudButton Variant="Variant.Filled" Size="Size.Large" Color="Color.Error" Class="mud-width-full"
StartIcon="@Icons.Material.Filled.AddShoppingCart" OnClick="AddToCart">
افزودن به سبد
</MudButton>
</MudStack>
</MudItem>
}
</MudGrid>
</MudPaper>
</MudHidden>
}

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Components;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.Store;
public partial class ProductDetail : ComponentBase
public partial class ProductDetail : ComponentBase, IDisposable
{
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
@@ -13,19 +16,151 @@ public partial class ProductDetail : ComponentBase
private Product? _product;
private bool _loading;
private int _qty = 1;
private const int MinQty = 1;
private const int MaxQty = 20;
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
private ProductGalleryImage? _selectedGalleryImage;
private long TotalPrice => (_product?.Price ?? 0) * _qty;
private bool HasDiscount => _product is { Discount: > 0 and < 100 };
private long? OriginalPrice => HasDiscount && _product is not null
? (long)Math.Round(_product.Price / (1 - (_product.Discount / 100m)))
: null;
private CartItem? CurrentCartItem => _product is null
? null
: Cart.Items.FirstOrDefault(i => i.ProductId == _product.Id);
private bool IsInCart => CurrentCartItem is not null;
private int CurrentCartQuantity => CurrentCartItem?.Quantity ?? 0;
protected override async Task OnParametersSetAsync()
{
_loading = true;
_product = await ProductService.GetByIdAsync(id);
_loading = false;
if (_product is not null)
{
_galleryItems = BuildGalleryItems(_product);
_selectedGalleryImage = _galleryItems.FirstOrDefault();
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty);
}
else
{
_galleryItems = Array.Empty<ProductGalleryImage>();
}
StateHasChanged();
}
protected override void OnInitialized()
{
Cart.OnChange += HandleCartChanged;
}
private async Task AddToCart()
{
if (_product is null) return;
await Cart.Add(_product, _qty);
await Cart.Add(_product, 1);
}
private async Task RemoveFromCart()
{
if (_product is null) return;
_qty--;
await Cart.UpdateQuantity(CurrentCartItem.ProductId, _qty);
}
private void IncreaseLocalQty()
{
if (_qty < MaxQty)
{
_qty++;
}
}
private void DecreaseLocalQty()
{
if (_qty > MinQty)
{
_qty--;
}
}
// private async Task IncreaseCartQuantityAsync()
// {
// if (_product is null || CurrentCartItem is null) return;
// var target = Math.Min(CurrentCartItem.Quantity + 1, MaxQty);
// if (target != CurrentCartItem.Quantity)
// {
// await Cart.UpdateQuantity(_product.Id, target);
// }
// }
//
// private async Task RemoveFromCartAsync()
// {
// if (CurrentCartItem is null) return;
// await Cart.Remove(CurrentCartItem.cartId);
// }
private void HandleCartChanged()
{
if (_product is null) return;
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? MinQty, MinQty, MaxQty);
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
Cart.OnChange -= HandleCartChanged;
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private static IReadOnlyList<ProductGalleryImage> BuildGalleryItems(Product product)
{
if (product.Gallery is { Count: > 0 })
{
var result = new List<ProductGalleryImage>();
result.Add(new ProductGalleryImage(0, 0, product.Title, product.ImageUrl, product.ImageUrl));
result.AddRange(product.Gallery.Where(x => !string.IsNullOrWhiteSpace(x.ImageUrl)).ToList());
return result;
}
if (!string.IsNullOrWhiteSpace(product.ImageUrl))
{
return new[]
{
new ProductGalleryImage(0, 0, product.Title, product.ImageUrl, product.ImageUrl)
};
}
return Array.Empty<ProductGalleryImage>();
}
private string MainImageUrl => _selectedGalleryImage?.ImageUrl
?? _galleryItems.FirstOrDefault()?.ImageUrl
?? _product?.ImageUrl
?? string.Empty;
private string MainImageAlt => _selectedGalleryImage?.Title ?? _product?.Title ?? "تصویر محصول";
private string GetThumbnailUrl(ProductGalleryImage item)
=> string.IsNullOrWhiteSpace(item.ThumbnailUrl) ? item.ImageUrl : item.ThumbnailUrl;
private string GetThumbnailBorder(ProductGalleryImage item)
=> item == _selectedGalleryImage ? "2px solid rgba(30, 136, 229, 0.85)" : "1px solid rgba(0,0,0,0.12)";
private string GetThumbnailShadow(ProductGalleryImage item)
=> item == _selectedGalleryImage ? "0 0 0 2px rgba(30, 136, 229, 0.25)" : "none";
private string GetThumbnailStyle(ProductGalleryImage item)
=>
$"width:76px;height:76px;object-fit:cover;border:{GetThumbnailBorder(item)};box-shadow:{GetThumbnailShadow(item)};border-radius:0.5rem;";
private void SelectGalleryImage(ProductGalleryImage item)
{
if (_selectedGalleryImage == item) return;
_selectedGalleryImage = item;
}
}

View File

@@ -11,18 +11,20 @@ public record Product(
long Price,
int Discount = 0,
int Rate = 0,
int RemainingCount = 0);
int RemainingCount = 0)
{
public IReadOnlyList<ProductGalleryImage> Gallery { get; init; } = Array.Empty<ProductGalleryImage>();
}
public record ProductGalleryImage(
long ProductGalleryId,
long ProductImageId,
string Title,
string ImageUrl,
string ThumbnailUrl);
public class ProductService
{
private static readonly List<Product> _seed = new()
{
new(1, "ماسک پزشکی سه‌لایه", "ماسک سه‌لایه با فیلتراسیون بالا مناسب برای محیط‌های عمومی.", "/images/store/mask.jpg", 49000),
new(2, "دستکش لاتکس", "دستکش لاتکس مناسب معاینات پزشکی، بدون پودر.", "/images/store/gloves.jpg", 89000),
new(3, "محلول ضدعفونی کننده", "محلول ضدعفونی بر پایه الکل ۷۰٪ مناسب دست و سطوح.", "/images/store/sanitizer.jpg", 69000),
new(4, "تب‌سنج دیجیتال", "تب‌سنج دیجیتال با دقت بالا و نمایشگر LCD.", "/images/store/thermometer.jpg", 299000),
new(5, "فشارسنج دیجیتال", "فشارسنج بازویی دیجیتال با حافظه داخلی.", "/images/store/bp-monitor.jpg", 1259000)
};
private readonly ConcurrentDictionary<long, Product> _cache = new();
private readonly ProductsContract.ProductsContractClient _client;
@@ -30,7 +32,7 @@ public class ProductService
public ProductService(ProductsContract.ProductsContractClient client)
{
_client = client;
foreach (var p in _seed) _cache[p.Id] = p;
}
public async Task<List<Product>> GetProductsAsync(string? query = null)
@@ -67,10 +69,13 @@ public class ProductService
try
{
// No single-get endpoint exposed: fetch list and pick
var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest { Filter = new GetAllProductsByFilterFilter() });
var list = MapAndCache(resp.Models);
return list.FirstOrDefault(x => x.Id == id);
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
if (resp == null)
{
return null;
}
return MapAndCache(resp);
}
catch
{
@@ -99,4 +104,34 @@ public class ProductService
}
return list;
}
private Product MapAndCache(GetProductsResponse model)
{
var gallery = model.Gallery
.Select(item => new ProductGalleryImage(
ProductGalleryId: item.ProductGalleryId,
ProductImageId: item.ProductImageId,
Title: item.Title ?? string.Empty,
ImageUrl: BuildUrl(item.ImagePath),
ThumbnailUrl: BuildUrl(item.ImageThumbnailPath)))
.ToList();
var product = new Product(
Id: model.Id,
Title: model.Title ?? string.Empty,
Description: model.Description ?? string.Empty,
ImageUrl: BuildUrl(model.ImagePath),
Price: model.Price,
Discount: model.Discount,
Rate: model.Rate,
RemainingCount: model.RemainingCount)
{
Gallery = gallery
};
_cache[product.Id] = product;
return product;
}
private static string BuildUrl(string? path) => string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path;
}