feat: full FrontOffice updates - discount store, blog, payment gateway, UI improvements
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 4m55s

- Discount store pages (products, cart, orders, order detail)
- Blog pages and services
- Payment gateway callback page
- AppImage component, EmptyState, LoadingState, PageHeader
- DiscountCartService, DiscountOrderService, DiscountProductService
- BlogCategoryService, BlogPostService, SitePageService, ImageCacheService
- PhoneVerifyForm component
- Profile Hub page
- UI/UX improvements across all pages
- landing.js for homepage
- Config and routing updates
This commit is contained in:
masoodafar-web
2026-02-16 00:51:39 +03:30
parent f0d1156457
commit 6db83ccd90
104 changed files with 7668 additions and 1370 deletions
@@ -6,7 +6,12 @@ public class AuthDialogService
{
private readonly IDialogService _dialogService;
private readonly IDeviceDetector _deviceDetector;
private readonly DialogOptions _normalWidth = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
private readonly DialogOptions _dialogOptions = new()
{
MaxWidth = MaxWidth.ExtraSmall,
FullWidth = true,
CloseButton = true
};
public AuthDialogService(IDialogService dialogService, IDeviceDetector deviceDetector)
{
@@ -18,12 +23,7 @@ public class AuthDialogService
public async Task ShowAuthDialogAsync()
{
// Pick dialog size based on device type
var options = _deviceDetector.IsMobile()
? new DialogOptions() { FullScreen = true}
: _normalWidth;
var dialog = await _dialogService.ShowAsync<Shared.AuthDialog>("ورود به حساب کاربری", options);
var dialog = await _dialogService.ShowAsync<Shared.AuthDialog>("ورود به حساب کاربری", _dialogOptions);
var result = await dialog.Result;
if (!result.Canceled)
@@ -54,7 +54,7 @@ public class AuthService
}
public bool IsCompleteRegister()
{
InitUserAuthInfo().GetAwaiter();
InitUserAuthInfo().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(_userAuthInfo.NationalCode) && !string.IsNullOrWhiteSpace(_userAuthInfo.FirstName) && !string.IsNullOrWhiteSpace(_userAuthInfo.LastName) && _userAuthInfo.IsSignMainContract)
{
return true;
@@ -0,0 +1,60 @@
namespace FrontOffice.Main.Utilities;
/// <summary>
/// Service for fetching active blog categories (customer-facing).
/// </summary>
public class BlogCategoryService
{
private readonly CMSMicroservice.Protobuf.Protos.BlogCategory.BlogCategoryContract.BlogCategoryContractClient _client;
public BlogCategoryService(CMSMicroservice.Protobuf.Protos.BlogCategory.BlogCategoryContract.BlogCategoryContractClient client)
{
_client = client;
}
/// <summary>
/// Get all active blog categories (for blog sidebar / filter).
/// </summary>
public async Task<List<BlogCategoryDto>> GetActiveCategoriesAsync()
{
try
{
var response = await _client.GetActiveBlogCategoriesAsync(
new CMSMicroservice.Protobuf.Protos.BlogCategory.GetActiveBlogCategoriesRequest());
if (response?.Categories == null || response.Categories.Count == 0)
return new();
return response.Categories
.OrderBy(c => c.SortOrder)
.Select(c => new BlogCategoryDto
{
Id = c.Id,
Title = c.Title,
Slug = c.Slug,
Description = c.Description,
IconName = c.IconName,
PostCount = c.PostCount
})
.ToList();
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"BlogCategoryService.GetActiveCategoriesAsync error: {ex.Message}");
#endif
return new();
}
}
}
// ── DTO ──
public class BlogCategoryDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Description { get; set; }
public string? IconName { get; set; }
public int PostCount { get; set; }
}
@@ -0,0 +1,204 @@
namespace FrontOffice.Main.Utilities;
/// <summary>
/// Service for fetching published blog posts (customer-facing).
/// Used by Landing Page (latest posts) and future Blog pages.
/// </summary>
public class BlogPostService
{
private readonly CMSMicroservice.Protobuf.Protos.BlogPost.BlogPostContract.BlogPostContractClient _client;
public BlogPostService(CMSMicroservice.Protobuf.Protos.BlogPost.BlogPostContract.BlogPostContractClient client)
{
_client = client;
}
/// <summary>
/// Get featured blog posts (for landing page hero / highlights).
/// </summary>
public async Task<List<BlogPostCardDto>> GetFeaturedPostsAsync(int count = 3)
{
try
{
var response = await _client.GetFeaturedBlogPostsAsync(
new CMSMicroservice.Protobuf.Protos.BlogPost.GetFeaturedBlogPostsRequest { Count = count });
return MapToCardDtos(response?.Models);
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"BlogPostService.GetFeaturedPostsAsync error: {ex.Message}");
#endif
return new();
}
}
/// <summary>
/// Get latest published posts with optional category filter.
/// </summary>
public async Task<BlogPostListResult> GetPublishedPostsAsync(int page = 1, int pageSize = 9, string? search = null, long? categoryId = null)
{
try
{
var request = new CMSMicroservice.Protobuf.Protos.BlogPost.GetPublishedBlogPostsRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = page,
PageSize = pageSize
}
};
if (!string.IsNullOrWhiteSpace(search))
request.SearchTerm = search;
if (categoryId.HasValue)
request.CategoryId = categoryId.Value;
var response = await _client.GetPublishedBlogPostsAsync(request);
return new BlogPostListResult
{
Posts = MapToCardDtos(response?.Models),
TotalCount = (int)(response?.MetaData?.TotalCount ?? 0),
TotalPages = (int)(response?.MetaData?.TotalPage ?? 0),
CurrentPage = page
};
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"BlogPostService.GetPublishedPostsAsync error: {ex.Message}");
#endif
return new();
}
}
/// <summary>
/// Get a single blog post by slug (for blog detail page).
/// </summary>
public async Task<BlogPostDetailDto?> GetBySlugAsync(string slug)
{
try
{
var response = await _client.GetBlogPostBySlugAsync(
new CMSMicroservice.Protobuf.Protos.BlogPost.GetBlogPostBySlugRequest { Slug = slug });
if (response == null || response.Id <= 0) return null;
return new BlogPostDetailDto
{
Id = response.Id,
Title = response.Title,
Slug = response.Slug,
Summary = response.Summary,
HtmlContent = response.HtmlContent,
FeaturedImagePath = response.FeaturedImagePath,
FeaturedImageThumbnailPath = response.FeaturedImageThumbnailPath,
PublishedAt = response.PublishedAt?.ToDateTime(),
ViewCount = response.ViewCount,
IsFeatured = response.IsFeatured,
Categories = response.Categories?.Select(c => new BlogCategoryInfo { Id = c.Id, Title = c.Title, Slug = c.Slug }).ToList() ?? new(),
Tags = response.Tags?.Select(t => new BlogTagInfo { Id = t.Id, Title = t.Title, Name = t.Name }).ToList() ?? new()
};
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"BlogPostService.GetBySlugAsync error: {ex.Message}");
#endif
return null;
}
}
/// <summary>
/// Increment view count for a blog post.
/// </summary>
public async Task IncrementViewCountAsync(long postId)
{
try
{
await _client.IncrementViewCountAsync(
new CMSMicroservice.Protobuf.Protos.BlogPost.IncrementViewCountRequest { Id = postId });
}
catch
{
// Fire and forget — don't fail the page
}
}
// ── Helpers ──
private static List<BlogPostCardDto> MapToCardDtos(
Google.Protobuf.Collections.RepeatedField<CMSMicroservice.Protobuf.Protos.BlogPost.BlogPostListItem>? models)
{
if (models == null || models.Count == 0) return new();
return models.Select(m => new BlogPostCardDto
{
Id = m.Id,
Title = m.Title,
Slug = m.Slug,
Summary = m.Summary,
ThumbnailUrl = m.FeaturedImageThumbnailPath,
PublishedAt = m.PublishedAt?.ToDateTime(),
ViewCount = m.ViewCount,
IsFeatured = m.IsFeatured,
Categories = m.Categories?.Select(c => new BlogCategoryInfo { Id = c.Id, Title = c.Title, Slug = c.Slug }).ToList() ?? new()
}).ToList();
}
}
// ── DTOs ──
public class BlogPostCardDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string? ThumbnailUrl { get; set; }
public DateTime? PublishedAt { get; set; }
public int ViewCount { get; set; }
public bool IsFeatured { get; set; }
public List<BlogCategoryInfo> Categories { get; set; } = new();
}
public class BlogPostDetailDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string? Summary { get; set; }
public string HtmlContent { get; set; } = string.Empty;
public string? FeaturedImagePath { get; set; }
public string? FeaturedImageThumbnailPath { get; set; }
public DateTime? PublishedAt { get; set; }
public int ViewCount { get; set; }
public bool IsFeatured { get; set; }
public List<BlogCategoryInfo> Categories { get; set; } = new();
public List<BlogTagInfo> Tags { get; set; } = new();
}
public class BlogPostListResult
{
public List<BlogPostCardDto> Posts { get; set; } = new();
public int TotalCount { get; set; }
public int TotalPages { get; set; }
public int CurrentPage { get; set; }
}
public class BlogCategoryInfo
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
}
public class BlogTagInfo
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
@@ -220,7 +220,7 @@ public class CartService
cartId:model.Id,
ProductId: model.ProductId,
Title: model.ProductTitle ?? string.Empty,
ImageUrl: string.IsNullOrWhiteSpace(model.ProductThumbnailPath) ? string.Empty : UrlUtility.DownloadUrl + model.ProductThumbnailPath,
ImageUrl: model.ProductThumbnailPath,
UnitPrice: model.ProductPrice,
Quantity: model.Count > 0 ? model.Count : 1)
{
+110 -35
View File
@@ -4,87 +4,162 @@ namespace FrontOffice.Main.Utilities;
public static class CustomMudTheme
{
// ── Shared font stack ──
private static readonly string[] VazirFontFamily = ["Vazir", "Tahoma", "Segoe UI", "Arial", "sans-serif"];
public static MudTheme CustomMudBlazorTheme { get; set; } = new()
{
// ═══════════════════════════════════════
// Light Palette
// ═══════════════════════════════════════
PaletteLight = new PaletteLight()
{
Primary = "#0380C0",
Background = "#F5F5F5",
AppbarBackground = "#F5F5F5",
TextPrimary = Colors.Gray.Darken3,
Primary = "#6366f1", // Indigo — هویت اصلی برند
PrimaryDarken = "#4f46e5",
PrimaryLighten = "#818cf8",
PrimaryContrastText = "#FFFFFF",
Secondary = "#8b5cf6", // Purple
SecondaryDarken = "#7c3aed",
SecondaryLighten = "#a78bfa",
SecondaryContrastText = "#FFFFFF",
Tertiary = "#06b6d4", // Cyan — accent
Info = "#3b82f6",
InfoContrastText = "#FFFFFF",
Success = "#10b981",
SuccessContrastText = "#FFFFFF",
Warning = "#f59e0b",
WarningContrastText = "#FFFFFF",
Error = "#ef4444",
ErrorContrastText = "#FFFFFF",
Background = "#f8f9fc", // Slightly cooler than plain gray
Surface = "#FFFFFF",
Divider = "#B2BFCB",
AppbarBackground = "#f8f9fc",
AppbarText = "#424242",
TextPrimary = "#1e293b", // Slate 800
TextSecondary = "#64748b", // Slate 500
TextDisabled = "#94a3b8", // Slate 400
Divider = "#e2e8f0", // Slate 200 — softer than old #B2BFCB
DrawerBackground = "#FFFFFF",
DrawerText = "#1e293b",
DrawerIcon = "#64748b",
},
// ═══════════════════════════════════════
// Dark Palette
// ═══════════════════════════════════════
PaletteDark = new PaletteDark()
{
Primary = "#818cf8", // Lighter indigo for dark BG
PrimaryDarken = "#6366f1",
PrimaryLighten = "#a5b4fc",
PrimaryContrastText = "#0f0f23",
Secondary = "#a78bfa",
SecondaryDarken = "#8b5cf6",
SecondaryLighten = "#c4b5fd",
SecondaryContrastText = "#0f0f23",
Tertiary = "#22d3ee",
Info = "#60a5fa",
InfoContrastText = "#0f172a",
Success = "#34d399",
SuccessContrastText = "#0f172a",
Warning = "#fbbf24",
WarningContrastText = "#0f172a",
Error = "#f87171",
ErrorContrastText = "#0f172a",
Background = "#0f172a", // Slate 900
Surface = "#1e293b", // Slate 800
AppbarBackground = "#0f172a",
AppbarText = "#e2e8f0",
TextPrimary = "#f1f5f9", // Slate 100
TextSecondary = "#94a3b8", // Slate 400
TextDisabled = "#475569", // Slate 600
Divider = "#334155", // Slate 700
DrawerBackground = "#1e293b",
DrawerText = "#f1f5f9",
DrawerIcon = "#94a3b8",
},
// ═══════════════════════════════════════
// Layout Properties
// ═══════════════════════════════════════
LayoutProperties = new LayoutProperties
{
DefaultBorderRadius = "12px",
AppbarHeight = "56px",
DrawerWidthLeft = "280px",
DrawerWidthRight = "280px",
},
// ═══════════════════════════════════════
// Typography
// ═══════════════════════════════════════
Typography = new Typography
{
Default = new DefaultTypography()
{
FontFamily = new[] { "Vazir", "Tahoma", "Segoe UI", "Arial", "sans-serif" }
},
Default = new DefaultTypography() { FontFamily = VazirFontFamily },
H1 = new H1Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "2rem", LineHeight = "1.70", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "2rem", LineHeight = "1.70",
FontWeight = "800", LetterSpacing = "normal"
},
H2 = new H2Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1.875rem", LineHeight = "1.65", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1.875rem", LineHeight = "1.65",
FontWeight = "800", LetterSpacing = "normal"
},
H3 = new H3Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1.5rem", LineHeight = "1.60", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1.5rem", LineHeight = "1.60",
FontWeight = "800", LetterSpacing = "normal"
},
H4 = new H4Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1.25rem", LineHeight = "1.55", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1.25rem", LineHeight = "1.55",
FontWeight = "800", LetterSpacing = "normal"
},
H5 = new H5Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1.125rem", LineHeight = "1.50", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1.125rem", LineHeight = "1.50",
FontWeight = "800", LetterSpacing = "normal"
},
H6 = new H6Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1rem", LineHeight = "1.45", FontWeight = "800",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1rem", LineHeight = "1.45",
FontWeight = "800", LetterSpacing = "normal"
},
Subtitle1 = new Subtitle1Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1rem", LineHeight = "1.62", FontWeight = "500",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1rem", LineHeight = "1.62",
FontWeight = "500", LetterSpacing = "normal"
},
Subtitle2 = new Subtitle2Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "0.875rem", LineHeight = "1.60", FontWeight = "500",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "0.875rem", LineHeight = "1.60",
FontWeight = "500", LetterSpacing = "normal"
},
Body1 = new Body1Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "1rem", LineHeight = "1.85", FontWeight = "400",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "1rem", LineHeight = "1.85",
FontWeight = "400", LetterSpacing = "normal"
},
Body2 = new Body2Typography()
{
FontFamily = new[] { "Vazir" }, FontSize = "0.875rem", LineHeight = "1.80", FontWeight = "400",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "0.875rem", LineHeight = "1.80",
FontWeight = "400", LetterSpacing = "normal"
},
Caption = new CaptionTypography()
{
FontFamily = new[] { "Vazir" }, FontSize = "0.75rem", LineHeight = "1.60", FontWeight = "400",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "0.75rem", LineHeight = "1.60",
FontWeight = "400", LetterSpacing = "normal"
},
Overline = new OverlineTypography()
{
FontFamily = new[] { "Vazir" }, FontSize = "0.75rem", LineHeight = "1.60", FontWeight = "500",
LetterSpacing = "normal"
FontFamily = VazirFontFamily, FontSize = "0.75rem", LineHeight = "1.60",
FontWeight = "500", LetterSpacing = "normal"
},
Button = new ButtonTypography()
{
FontFamily = new[] { "Vazir" }, FontSize = "0.875rem", LineHeight = "1.60", FontWeight = "600",
LetterSpacing = "normal", TextTransform = "none"
FontFamily = VazirFontFamily, FontSize = "0.875rem", LineHeight = "1.60",
FontWeight = "600", LetterSpacing = "normal", TextTransform = "none"
}
}
};
@@ -0,0 +1,151 @@
using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart;
using Blazored.LocalStorage;
namespace FrontOffice.Main.Utilities;
public record DiscountCartItem(
long ProductId,
string Title,
string ImageUrl,
long UnitPrice,
int MaxDiscountPercent,
int Quantity,
long TotalPrice,
long DiscountAmount,
long FinalPrice,
int RemainingCount);
public class DiscountCartService
{
private readonly DiscountShoppingCartContract.DiscountShoppingCartContractClient _client;
private readonly ILocalStorageService _localStorage;
private readonly List<DiscountCartItem> _items = new();
private const string TokenStorageKey = "auth:token";
private bool _isInitialized;
public event Action? OnChange;
public DiscountCartService(
DiscountShoppingCartContract.DiscountShoppingCartContractClient client,
ILocalStorageService localStorage)
{
_client = client;
_localStorage = localStorage;
}
public IReadOnlyList<DiscountCartItem> Items => _items.AsReadOnly();
public long TotalPrice => _items.Sum(i => i.TotalPrice);
public long TotalDiscount => _items.Sum(i => i.DiscountAmount);
public long FinalPrice => _items.Sum(i => i.FinalPrice);
public int Count => _items.Sum(i => i.Quantity);
public async Task AddAsync(long productId, int count = 1)
{
if (!await IsAuthenticatedAsync()) return;
try
{
await _client.AddToCartAsync(new AddToCartRequest
{
ProductId = productId,
Count = count
});
await LoadFromServerAsync();
}
catch { /* best-effort */ }
}
public async Task UpdateQuantityAsync(long productId, int newCount)
{
if (!await IsAuthenticatedAsync()) return;
if (newCount <= 0)
{
await RemoveAsync(productId);
return;
}
try
{
await _client.UpdateCartItemCountAsync(new UpdateCartItemCountRequest
{
ProductId = productId,
NewCount = newCount
});
await LoadFromServerAsync();
}
catch { /* best-effort */ }
}
public async Task RemoveAsync(long productId)
{
if (!await IsAuthenticatedAsync()) return;
try
{
await _client.RemoveFromCartAsync(new RemoveFromCartRequest
{
ProductId = productId
});
_items.RemoveAll(i => i.ProductId == productId);
Notify();
}
catch { /* best-effort */ }
}
public async Task ClearAsync()
{
if (!await IsAuthenticatedAsync()) return;
try
{
await _client.ClearCartAsync(new ClearCartRequest());
_items.Clear();
Notify();
}
catch { /* best-effort */ }
}
public async Task EnsureInitializedAsync()
{
if (_isInitialized) return;
if (await IsAuthenticatedAsync())
{
await LoadFromServerAsync();
}
_isInitialized = true;
}
private async Task LoadFromServerAsync()
{
if (!await IsAuthenticatedAsync()) return;
try
{
var response = await _client.GetUserCartAsync(new GetUserCartRequest());
_items.Clear();
foreach (var item in response.Items)
{
_items.Add(new DiscountCartItem(
ProductId: item.ProductId,
Title: item.ProductTitle ?? string.Empty,
ImageUrl: item.ProductImagePath,
UnitPrice: item.UnitPrice,
MaxDiscountPercent: item.MaxDiscountPercent,
Quantity: item.Count,
TotalPrice: item.TotalPrice,
DiscountAmount: item.DiscountAmount,
FinalPrice: item.FinalPrice,
RemainingCount: item.ProductRemainingCount));
}
Notify();
}
catch { /* fallback to local state */ }
}
private void Notify() => OnChange?.Invoke();
private async Task<bool> IsAuthenticatedAsync()
{
try
{
var token = await _localStorage.GetItemAsync<string>(TokenStorageKey);
return !string.IsNullOrWhiteSpace(token);
}
catch { return false; }
}
}
@@ -0,0 +1,209 @@
using CMSMicroservice.Protobuf.Protos.DiscountOrder;
using Google.Protobuf.WellKnownTypes;
using MudBlazor;
namespace FrontOffice.Main.Utilities;
// ── DTOs ──
public record DiscountOrderSummary(
long Id,
string OrderNumber,
long TotalPrice,
long DiscountBalanceUsed,
long GatewayAmount,
bool PaymentCompleted,
int DeliveryStatus,
string? TrackingCode,
int ItemsCount,
DateTime Created);
public record DiscountOrderDetail(
long Id,
string OrderNumber,
long TotalPrice,
long DiscountBalanceUsed,
long GatewayAmount,
bool PaymentCompleted,
string? TransactionId,
int DeliveryStatus,
string? TrackingCode,
string? Notes,
string? AdminNotes,
DiscountOrderAddress? Address,
List<DiscountOrderItem> Items,
DateTime Created);
public record DiscountOrderAddress(
long Id, string Title, string Address, string PostalCode, string? Phone);
public record DiscountOrderItem(
long ProductId,
string Title,
long UnitPrice,
int MaxDiscountPercent,
int Count,
long TotalPrice,
long DiscountAmount,
long FinalPrice);
public record PlaceDiscountOrderResult(
bool Success,
string Message,
long OrderId,
long GatewayAmount,
string? PaymentUrl);
public record DiscountOrderListResult(
List<DiscountOrderSummary> Orders,
int TotalCount,
int TotalPages,
int CurrentPage);
// ── Service ──
public class DiscountOrderService
{
private readonly DiscountOrderContract.DiscountOrderContractClient _client;
public DiscountOrderService(DiscountOrderContract.DiscountOrderContractClient client)
{
_client = client;
}
public async Task<PlaceDiscountOrderResult> PlaceOrderAsync(long addressId, long discountBalanceToUse, string? notes = null)
{
try
{
var request = new PlaceOrderRequest
{
UserAddressId = addressId,
DiscountBalanceToUse = discountBalanceToUse
};
if (!string.IsNullOrWhiteSpace(notes))
request.Notes = notes;
var response = await _client.PlaceOrderAsync(request);
return new PlaceDiscountOrderResult(
Success: response.Success,
Message: response.Message ?? string.Empty,
OrderId: response.OrderId,
GatewayAmount: response.GatewayAmount,
PaymentUrl: response.PaymentUrl);
}
catch (Exception ex)
{
return new PlaceDiscountOrderResult(false, ex.Message, 0, 0, null);
}
}
public async Task<bool> CompletePaymentAsync(long orderId, string? transactionId, bool success)
{
try
{
var response = await _client.CompleteOrderPaymentAsync(new CompleteOrderPaymentRequest
{
OrderId = orderId,
TransactionId = transactionId,
PaymentSuccess = success
});
return response.Success;
}
catch { return false; }
}
public async Task<DiscountOrderListResult> GetUserOrdersAsync(int page = 1, int pageSize = 10)
{
try
{
var response = await _client.GetUserOrdersAsync(new GetUserOrdersRequest
{
PageNumber = page,
PageSize = pageSize
});
var orders = response.Models.Select(m => new DiscountOrderSummary(
Id: m.Id,
OrderNumber: m.OrderNumber ?? string.Empty,
TotalPrice: m.TotalPrice,
DiscountBalanceUsed: m.DiscountBalanceUsed,
GatewayAmount: m.GatewayAmount,
PaymentCompleted: m.PaymentCompleted,
DeliveryStatus: (int)m.DeliveryStatus,
TrackingCode: m.TrackingCode,
ItemsCount: m.ItemsCount,
Created: m.Created?.ToDateTime() ?? DateTime.MinValue
)).ToList();
var totalCount = (int)(response.MetaData?.TotalCount ?? 0);
var totalPages = (int)(response.MetaData?.TotalPage ?? 0);
return new DiscountOrderListResult(orders, totalCount, totalPages, page);
}
catch
{
return new DiscountOrderListResult(new(), 0, 0, page);
}
}
public async Task<DiscountOrderDetail?> GetOrderByIdAsync(long orderId)
{
try
{
var r = await _client.GetOrderByIdAsync(new GetOrderByIdRequest { OrderId = orderId });
var address = r.Address != null
? new DiscountOrderAddress(r.Address.Id, r.Address.Title ?? "", r.Address.Address ?? "", r.Address.PostalCode ?? "", r.Address.Phone)
: null;
var items = r.Items.Select(i => new DiscountOrderItem(
ProductId: i.ProductId,
Title: i.ProductTitle ?? string.Empty,
UnitPrice: i.UnitPrice,
MaxDiscountPercent: i.MaxDiscountPercent,
Count: i.Count,
TotalPrice: i.TotalPrice,
DiscountAmount: i.DiscountAmount,
FinalPrice: i.FinalPrice
)).ToList();
return new DiscountOrderDetail(
Id: r.Id,
OrderNumber: r.OrderNumber ?? string.Empty,
TotalPrice: r.TotalPrice,
DiscountBalanceUsed: r.DiscountBalanceUsed,
GatewayAmount: r.GatewayAmount,
PaymentCompleted: r.PaymentCompleted,
TransactionId: r.TransactionId,
DeliveryStatus: (int)r.DeliveryStatus,
TrackingCode: r.TrackingCode,
Notes: r.Notes,
AdminNotes: r.AdminNotes,
Address: address,
Items: items,
Created: r.Created?.ToDateTime() ?? DateTime.MinValue);
}
catch
{
return null;
}
}
public static string GetDeliveryStatusText(int status) => status switch
{
0 => "در انتظار",
1 => "در حال پردازش",
2 => "ارسال شده",
3 => "تحویل داده شده",
4 => "لغو شده",
_ => "نامشخص"
};
public static Color GetDeliveryStatusColor(int status) => status switch
{
0 => Color.Warning,
1 => Color.Info,
2 => Color.Primary,
3 => Color.Success,
4 => Color.Error,
_ => Color.Default
};
}
@@ -0,0 +1,207 @@
using CMSMicroservice.Protobuf.Protos.DiscountProduct;
using CMSMicroservice.Protobuf.Protos.DiscountCategory;
using Google.Protobuf.WellKnownTypes;
namespace FrontOffice.Main.Utilities;
// ── DTOs ──
public record DiscountProductCard(
long Id,
string Title,
string ShortInfo,
long Price,
int MaxDiscountPercent,
string ImageUrl,
string ThumbnailUrl,
int RemainingCount,
int ViewCount,
bool IsActive,
DateTime Created);
public record DiscountProductDetail(
long Id,
string Title,
string ShortInfo,
string FullInfo,
long Price,
int MaxDiscountPercent,
string ImageUrl,
string ThumbnailUrl,
int RemainingCount,
int ViewCount,
int SortOrder,
bool IsActive,
List<DiscountCategoryInfo> Categories,
List<DiscountProductImageDto> Images,
DateTime Created);
public record DiscountCategoryInfo(long Id, string Name, string Title);
public record DiscountProductImageDto(
long Id,
string ImageUrl,
string ThumbnailUrl,
string? Title,
string? AltText,
int SortOrder);
public record DiscountCategoryNode(
long Id,
string Name,
string Title,
string? Description,
string? ImagePath,
long? ParentCategoryId,
int SortOrder,
bool IsActive,
int ProductCount,
List<DiscountCategoryNode> Children);
public record DiscountProductListResult(
List<DiscountProductCard> Products,
int TotalCount,
int TotalPages,
int CurrentPage);
// ── Service ──
public class DiscountProductService
{
private readonly DiscountProductContract.DiscountProductContractClient _productClient;
private readonly DiscountCategoryContract.DiscountCategoryContractClient _categoryClient;
public DiscountProductService(
DiscountProductContract.DiscountProductContractClient productClient,
DiscountCategoryContract.DiscountCategoryContractClient categoryClient)
{
_productClient = productClient;
_categoryClient = categoryClient;
}
public async Task<DiscountProductListResult> GetProductsAsync(
int page = 1, int pageSize = 12,
string? search = null, long? categoryId = null,
bool? inStock = null)
{
try
{
var request = new GetDiscountProductsRequest
{
PageNumber = page,
PageSize = pageSize,
IsActive = true // فقط محصولات فعال
};
if (!string.IsNullOrWhiteSpace(search))
request.SearchQuery = search;
if (categoryId.HasValue)
request.CategoryId = categoryId.Value;
if (inStock.HasValue)
request.InStock = inStock.Value;
var response = await _productClient.GetDiscountProductsAsync(request);
var products = response.Models.Select(m => new DiscountProductCard(
Id: m.Id,
Title: m.Title ?? string.Empty,
ShortInfo: m.ShortInfomation ?? string.Empty,
Price: m.Price,
MaxDiscountPercent: m.MaxDiscountPercent,
ImageUrl: BuildUrl(m.ImagePath),
ThumbnailUrl: BuildUrl(m.ThumbnailPath),
RemainingCount: m.RemainingCount,
ViewCount: m.ViewCount,
IsActive: m.IsActive,
Created: m.Created?.ToDateTime() ?? DateTime.MinValue
)).ToList();
var totalCount = (int)(response.MetaData?.TotalCount ?? 0);
var totalPages = (int)(response.MetaData?.TotalPage ?? 0);
return new DiscountProductListResult(products, totalCount, totalPages, page);
}
catch
{
return new DiscountProductListResult(new(), 0, 0, page);
}
}
public async Task<DiscountProductDetail?> GetByIdAsync(long productId)
{
try
{
var response = await _productClient.GetDiscountProductByIdAsync(
new GetDiscountProductByIdRequest { ProductId = productId });
var categories = response.Categories
.Select(c => new DiscountCategoryInfo(c.Id, c.Name ?? "", c.Title ?? ""))
.ToList();
// Load images
var imagesResponse = await _productClient.GetDiscountProductImagesAsync(
new GetDiscountProductImagesRequest { DiscountProductId = productId, OnlyActive = true });
var images = imagesResponse.Images
.OrderBy(i => i.SortOrder)
.Select(i => new DiscountProductImageDto(
Id: i.Id,
ImageUrl: BuildUrl(i.ImagePath),
ThumbnailUrl: BuildUrl(i.ThumbnailPath),
Title: i.Title,
AltText: i.AltText,
SortOrder: i.SortOrder))
.ToList();
return new DiscountProductDetail(
Id: response.Id,
Title: response.Title ?? string.Empty,
ShortInfo: response.ShortInfomation ?? string.Empty,
FullInfo: response.FullInformation ?? string.Empty,
Price: response.Price,
MaxDiscountPercent: response.MaxDiscountPercent,
ImageUrl: BuildUrl(response.ImagePath),
ThumbnailUrl: BuildUrl(response.ThumbnailPath),
RemainingCount: response.RemainingCount,
ViewCount: response.ViewCount,
SortOrder: response.SortOrder,
IsActive: response.IsActive,
Categories: categories,
Images: images,
Created: response.Created?.ToDateTime() ?? DateTime.MinValue);
}
catch
{
return null;
}
}
public async Task<List<DiscountCategoryNode>> GetCategoriesAsync(bool onlyActive = true)
{
try
{
var request = new GetDiscountCategoriesRequest();
if (onlyActive) request.IsActive = true;
var response = await _categoryClient.GetDiscountCategoriesAsync(request);
return response.Categories.Select(MapCategory).ToList();
}
catch
{
return new();
}
}
private static DiscountCategoryNode MapCategory(DiscountCategoryDto dto) => new(
Id: dto.Id,
Name: dto.Name ?? "",
Title: dto.Title ?? "",
Description: dto.Description,
ImagePath: dto.ImagePath,
ParentCategoryId: dto.ParentCategoryId,
SortOrder: dto.SortOrder,
IsActive: dto.IsActive,
ProductCount: dto.ProductCount,
Children: dto.Children.Select(MapCategory).ToList());
private static string BuildUrl(string? path)
=> path ?? string.Empty;
}
@@ -0,0 +1,122 @@
using System.Collections.Concurrent;
using CMSMicroservice.Protobuf.Protos.ImageResolver;
namespace FrontOffice.Main.Utilities;
/// <summary>
/// سرویس مرکزی resolve تصاویر — تمام تصاویر دینامیک FrontOffice از این سرویس عبور می‌کنند.
/// مسیرهای نسبی از طریق gRPC به CMS ارسال شده و base64 data-URI دریافت می‌شود.
/// نتایج در حافظه کش می‌شوند تا از فراخوانی مجدد جلوگیری شود.
/// </summary>
public class ImageCacheService
{
private readonly ImageResolverContract.ImageResolverContractClient _client;
private readonly ConcurrentDictionary<string, string> _cache = new();
public ImageCacheService(ImageResolverContract.ImageResolverContractClient client)
{
_client = client;
}
/// <summary>
/// تبدیل یک مسیر تصویر به base64 data-URI.
/// اگر مسیر خالی باشد → رشته خالی.
/// اگر قبلاً data-URI باشد → بدون تغییر.
/// اگر در کش باشد → از کش.
/// در غیر این صورت → فراخوانی gRPC.
/// </summary>
public async Task<string> ResolveAsync(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
// اگر قبلاً base64 هست (مثلاً از interceptor آمده)
if (path.StartsWith("data:", StringComparison.Ordinal))
return path;
// مسیرهای استاتیک (wwwroot) یا لینک‌های خارجی نیاز به resolve ندارند
if (path.StartsWith('/') || path.StartsWith("http://", StringComparison.Ordinal)
|| path.StartsWith("https://", StringComparison.Ordinal))
return path;
// جستجو در کش
if (_cache.TryGetValue(path, out var cached))
return cached;
try
{
var response = await _client.ResolveImagesAsync(
new ResolveImagesRequest { Paths = { path } });
var result = response?.Images?.FirstOrDefault()?.DataUri ?? string.Empty;
_cache.TryAdd(path, result);
return result;
}
catch
{
// در صورت خطا، رشته خالی برگردان تا UI خراب نشود
return string.Empty;
}
}
/// <summary>
/// تبدیل دسته‌ای مسیرهای تصویر به base64 data-URI.
/// مسیرهای کش‌شده و data-URI‌ها بدون فراخوانی gRPC برگردانده می‌شوند.
/// </summary>
public async Task<Dictionary<string, string>> ResolveBatchAsync(IEnumerable<string?> paths)
{
var result = new Dictionary<string, string>();
var toResolve = new List<string>();
foreach (var path in paths)
{
if (string.IsNullOrWhiteSpace(path))
continue;
if (path.StartsWith("data:", StringComparison.Ordinal)
|| path.StartsWith('/')
|| path.StartsWith("http://", StringComparison.Ordinal)
|| path.StartsWith("https://", StringComparison.Ordinal))
{
result[path] = path;
}
else if (_cache.TryGetValue(path, out var cached))
{
result[path] = cached;
}
else
{
toResolve.Add(path);
}
}
if (toResolve.Count == 0)
return result;
try
{
var response = await _client.ResolveImagesAsync(
new ResolveImagesRequest { Paths = { toResolve } });
if (response?.Images != null)
{
foreach (var img in response.Images)
{
_cache.TryAdd(img.OriginalPath, img.DataUri);
result[img.OriginalPath] = img.DataUri;
}
}
}
catch
{
// در صورت خطا، مسیرهای resolve نشده خالی بمانند
}
return result;
}
/// <summary>
/// پاکسازی تمام کش (مثلاً هنگام logout)
/// </summary>
public void ClearCache() => _cache.Clear();
}
@@ -44,7 +44,7 @@ public class OrderService
public async Task<List<GetUserOrderResponse>> GetOrdersAsync()
{
var result = await _userOrderContractClient.GetAllUserOrderByFilterAsync(new());
var result = await _userOrderContractClient.GetCustomerOrdersAsync(new());
if (result != null && result.Models.Count > 0)
{
foreach (var item in result.Models)
@@ -69,7 +69,7 @@ public class OrderService
var order = _orders.FirstOrDefault(o => o.Id == id);
if (order == null)
{
var result = await _userOrderContractClient.GetUserOrderAsync(new GetUserOrderRequest()
var result = await _userOrderContractClient.GetCustomerOrderAsync(new GetUserOrderRequest()
{
Id = id
});
@@ -54,7 +54,7 @@ public class PackageService
m.Id,
m.Title,
m.Description,
UrlUtility.DownloadUrl + m.ImagePath,
m.ImagePath,
m.Price))
.ToList();
}
@@ -84,7 +84,7 @@ public class PackageService
response.Id,
response.Title,
response.Description,
UrlUtility.DownloadUrl + response.ImagePath,
response.ImagePath,
response.Price);
}
catch (Exception ex)
@@ -141,7 +141,7 @@ public class ProductService
Title: m.Title ?? string.Empty,
Description: m.Description ?? string.Empty,
FullInformation: m.FullInformation ?? string.Empty,
ImageUrl: string.IsNullOrWhiteSpace(m.ImagePath) ? string.Empty : UrlUtility.DownloadUrl + m.ImagePath,
ImageUrl: m.ImagePath,
Price: m.Price,
Discount: m.Discount,
Rate: m.Rate,
@@ -244,7 +244,7 @@ public class ProductService
private sealed record CacheEntry(Product Product, DateTime Expiration);
private static string BuildUrl(string? path) =>
string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path;
path ?? string.Empty;
private static IReadOnlyList<ProductCategoryPathInfo> MapCategoryPaths(IEnumerable<ProductCategoryPath>? categories)
{
@@ -15,6 +15,7 @@ public static class RouteConstants
public static class Profile
{
public const string Index = "/profile";
public const string Hub = "/profile/hub";
public const string Personal = "/profile/personal";
public const string Addresses = "/profile/addresses";
public const string Settings = "/profile/settings";
@@ -66,6 +67,12 @@ public static class RouteConstants
public const string Index = "/contact";
}
public static class Blog
{
public const string Index = "/blog";
public const string Post = "/blog/"; // usage: /blog/{slug}
}
public static class Checkout
{
public const string Index = "/checkout";
@@ -82,4 +89,24 @@ public static class RouteConstants
public const string OrderTracking = "/order-tracking/"; // usage: /order-tracking/{id}
public const string Categories = "/categories";
}
public static class DiscountStore
{
public const string Products = "/discount-store";
public const string ProductDetail = "/discount-store/product/"; // usage: /discount-store/product/{id}
public const string Cart = "/discount-store/cart";
public const string Checkout = "/discount-store/checkout";
public const string Orders = "/discount-store/orders";
public const string OrderDetail = "/discount-store/order/"; // usage: /discount-store/order/{id}
}
/// <summary>
/// Gateway/chooser pages — when a concept exists in both stores
/// </summary>
public static class Gateway
{
public const string StoreChooser = "/stores";
public const string OrdersChooser = "/my-orders";
public const string CartChooser = "/my-cart";
}
}
@@ -0,0 +1,114 @@
using System.Text.Json;
namespace FrontOffice.Main.Utilities;
public class SitePageService
{
private readonly CMSMicroservice.Protobuf.Protos.SitePage.SitePageContract.SitePageContractClient _client;
public SitePageService(CMSMicroservice.Protobuf.Protos.SitePage.SitePageContract.SitePageContractClient client)
{
_client = client;
}
public async Task<SitePageDto?> GetByKeyAsync(string pageKey)
{
try
{
var response = await _client.GetSitePageByKeyAsync(
new CMSMicroservice.Protobuf.Protos.SitePage.GetSitePageByKeyRequest { PageKey = pageKey });
if (response == null || response.Id <= 0) return null;
var dto = new SitePageDto
{
Id = response.Id,
PageKey = response.PageKey,
Title = response.Title,
MetaDescription = response.MetaDescription,
HeroTitle = response.HeroTitle,
HeroSubtitle = response.HeroSubtitle,
HeroImagePath = response.HeroImagePath,
IsActive = response.IsActive
};
foreach (var s in response.Sections.OrderBy(s => s.SortOrder))
{
dto.Sections.Add(new SitePageSectionDto
{
Id = s.Id,
SectionKey = s.SectionKey,
Title = s.Title,
Subtitle = s.Subtitle,
HtmlContent = s.HtmlContent,
IconName = s.IconName,
ImagePath = s.ImagePath,
ImageThumbnailPath = s.ImageThumbnailPath,
SortOrder = s.SortOrder,
IsActive = s.IsActive,
ExtraData = s.ExtraData
});
}
return dto;
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"SitePageService.GetByKeyAsync error: {ex.Message}");
#endif
return null;
}
}
}
public class SitePageDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public string? HeroImagePath { get; set; }
public bool IsActive { get; set; }
public List<SitePageSectionDto> Sections { get; set; } = new();
public SitePageSectionDto? GetSection(string sectionKey) =>
Sections.FirstOrDefault(s => s.SectionKey == sectionKey && s.IsActive);
public List<SitePageSectionDto> GetSections(string prefix) =>
Sections.Where(s => s.SectionKey.StartsWith(prefix) && s.IsActive)
.OrderBy(s => s.SortOrder).ToList();
}
public class SitePageSectionDto
{
public long Id { get; set; }
public string SectionKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public string? HtmlContent { get; set; }
public string? IconName { get; set; }
public string? ImagePath { get; set; }
public string? ImageThumbnailPath { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public string? ExtraData { get; set; }
public T? GetExtraData<T>() where T : class
{
if (string.IsNullOrWhiteSpace(ExtraData)) return null;
try
{
return JsonSerializer.Deserialize<T>(ExtraData, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
return null;
}
}
}
@@ -3,4 +3,16 @@
public static class UrlUtility
{
public static string DownloadUrl { get; set; } = string.Empty; // initialize to avoid null
/// <summary>
/// ساخت URL کامل تصویر — اگر مسیر data-URI یا http باشد همان را برمی‌گرداند
/// </summary>
public static string GetImageUrl(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
if (path.StartsWith("data:") || path.StartsWith("http"))
return path;
return DownloadUrl + path;
}
}