Update product service and detail view with gallery support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,7 +26,26 @@ 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">
|
||||
@@ -37,14 +58,16 @@ else
|
||||
<!-- 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>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user