merge: kub-stage → production (discount autocomplete + smart AddStockDialog + site pages admin)
Build and Deploy to Production / build-and-deploy (push) Successful in 8m9s

This commit is contained in:
masoodafar-web
2026-02-18 01:01:29 +03:30
23 changed files with 1872 additions and 17 deletions
+18
View File
@@ -25,6 +25,24 @@
- **تغییر:** `Foursat.CMSMicroservice.Protobuf``0.0.179`
- **فایل:** `BackOffice.csproj`
### ۱۵.۴ — فیکس K8S_SERVER در prod-deploy.yml ✅
- **مشکل:** `K8S_SERVER` هنوز `194.5.195.53` (staging) بود
- **رفع:** تغییر به `45.149.79.127` (production)
- **فیکس اضافی:** `kubectl rollout restart``kubectl set image` (تا ایمیج SHA-tagged واقعاً set بشه)
### ۱۵.۵ — ساخت appsettings.Production.json ✅
- **مشکل:** فایل `appsettings.Production.json` وجود نداشت → `GwUrl` fallback به `localhost`
- **رفع:** ساخت `wwwroot/appsettings.Production.json` با `GwUrl=https://cms.kbs1.ir`
### ۱۵.۶ — فیکس nginx image path در Dockerfile (production branch) ✅
- **مشکل:** Dockerfile روی برنچ production از `library/nginx:alpine` استفاده می‌کرد — وجود نداشت
- **ارور:** `manifest for 194.5.195.53:32082/library/nginx:alpine not found`
- **رفع:** تغییر به `194.5.195.53:32082/nginx:alpine`
- **کامیت:** `743403e` (مستقیم روی برنچ production)
---
## 🏷 فاز ۱۴ — فیکس Docker Build + Deployment (بهمن ۱۴۰۴)
+1 -1
View File
@@ -138,7 +138,7 @@
<!-- CMS Protobuf NuGet package (used for both local dev and Docker builds) -->
<ItemGroup>
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.178" />
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.180" />
</ItemGroup>
<!-- ============================================================ -->
@@ -27,6 +27,7 @@ using CMSMicroservice.Protobuf.Protos.BlogPost;
using CMSMicroservice.Protobuf.Protos.BlogCategory;
using CMSMicroservice.Protobuf.Protos.BlogPostImage;
using CMSMicroservice.Protobuf.Protos.SitePage;
using CMSMicroservice.Protobuf.Protos.SitePageSettings;
using CMSMicroservice.Protobuf.Protos.UserContract;
using CMSMicroservice.Protobuf.Protos.UserWallet;
using CMSMicroservice.Protobuf.Protos.UserWalletChangeLog;
@@ -91,6 +92,7 @@ public static class ConfigureServices
services.AddScoped<IBlogCategoryService, BlogCategoryService>();
services.AddScoped<IBlogPostImageService, BlogPostImageService>();
services.AddScoped<ISitePageService, SitePageService>();
services.AddScoped<ISitePageSettingsService, SitePageSettingsService>();
return services;
}
@@ -158,6 +160,7 @@ public static class ConfigureServices
services.AddTransient(sp => new BlogCategoryContract.BlogCategoryContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new BlogPostImageContract.BlogPostImageContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new SitePageContract.SitePageContractClient(sp.GetRequiredService<CallInvoker>()));
services.AddTransient(sp => new SitePageSettingsContract.SitePageSettingsContractClient(sp.GetRequiredService<CallInvoker>()));
// User Contracts Service
services.AddTransient(sp => new UserContractContract.UserContractContractClient(sp.GetRequiredService<CallInvoker>()));
@@ -0,0 +1,14 @@
@using CMSMicroservice.Protobuf.Protos.DiscountProduct
<MudAutocomplete T="DiscountProductDto"
Label="@Label"
Value="@_item"
DebounceInterval="700"
ValueChanged="@((e) => OnSelected(e))"
ToStringFunc="@(e => e == null ? null : e.Title)"
SearchFunc="@Search"
Variant="Variant.Outlined"
Clearable="true"
ShowProgressIndicator="true"
CoerceText="false"
OnClearButtonClick="()=> OnSelected(null)" />
@@ -0,0 +1,83 @@
using CMSMicroservice.Protobuf.Protos.DiscountProduct;
using Microsoft.AspNetCore.Components;
namespace BackOffice.Pages.AutoComplete;
public partial class DiscountProductsAutoComplete
{
[Inject] public DiscountProductContract.DiscountProductContractClient DiscountProductService { get; set; } = default!;
[Parameter] public long? Value { get; set; }
[Parameter] public string? Label { get; set; } = "محصول تخفیفی";
[Parameter] public EventCallback<long?> ValueChanged { get; set; }
private List<DiscountProductDto> _items = new();
private DiscountProductDto? _item;
protected override async void OnParametersSet()
{
await base.OnParametersSetAsync();
if (Value.HasValue && Value.Value > 0 && (_item == null || _item.Id != Value.Value))
{
try
{
var getAll = await DiscountProductService.GetDiscountProductsAsync(new GetDiscountProductsRequest
{
PageNumber = 1,
PageSize = 50
});
if (getAll?.Models != null)
{
_item = getAll.Models.FirstOrDefault(x => x.Id == Value.Value);
StateHasChanged();
}
}
catch
{
// Silently handle
}
}
}
private async Task OnSelected(DiscountProductDto? model)
{
if (model == null)
{
_item = null;
await ValueChanged.InvokeAsync(null);
}
else
{
_item = model;
await ValueChanged.InvokeAsync(model.Id);
}
}
private async Task<IEnumerable<DiscountProductDto>> Search(string value, CancellationToken token)
{
var request = new GetDiscountProductsRequest
{
PageNumber = 1,
PageSize = 9
};
if (!string.IsNullOrWhiteSpace(value))
{
request.SearchQuery = value;
}
try
{
var getAll = await DiscountProductService.GetDiscountProductsAsync(request);
_items = getAll?.Models?.ToList() ?? new List<DiscountProductDto>();
}
catch
{
_items = new List<DiscountProductDto>();
}
return _items;
}
}
@@ -0,0 +1,66 @@
@using BackOffice.Pages.Content.Components.SettingsModels
<MudExpansionPanels MultiExpansion="true" Elevation="0">
@* ── Vision ── *@
<MudExpansionPanel Text="چشم‌انداز" Icon="@Icons.Material.Filled.Visibility" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.VisionTitle"
Label="عنوان"
Placeholder="چشم‌انداز"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.VisionText"
Label="متن چشم‌انداز"
Variant="Variant.Outlined"
Lines="3" />
<MudTextField @bind-Value="Model.VisionIcon"
Label="نام آیکون"
Placeholder="Visibility"
Variant="Variant.Outlined"
HelperText="نام آیکون از مجموعه Material Icons" />
</MudStack>
</MudExpansionPanel>
@* ── Mission ── *@
<MudExpansionPanel Text="مأموریت" Icon="@Icons.Material.Filled.TrackChanges" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.MissionTitle"
Label="عنوان"
Placeholder="مأموریت"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.MissionText"
Label="متن مأموریت"
Variant="Variant.Outlined"
Lines="3" />
<MudTextField @bind-Value="Model.MissionIcon"
Label="نام آیکون"
Placeholder="TrackChanges"
Variant="Variant.Outlined"
HelperText="نام آیکون از مجموعه Material Icons" />
</MudStack>
</MudExpansionPanel>
@* ── Section Titles ── *@
<MudExpansionPanel Text="عناوین بخش‌ها" Icon="@Icons.Material.Filled.Title">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.ValuesTitle"
Label="عنوان بخش ارزش‌ها"
Placeholder="ارزش‌های ما"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.TeamTitle"
Label="عنوان بخش تیم"
Placeholder="تیم ما"
Variant="Variant.Outlined" />
</MudStack>
</MudExpansionPanel>
@* ── Hint ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mt-2">
آیتم‌های ارزش‌ها و اعضای تیم از بخش «مدیریت تصاویر» قابل ویرایش هستند.
</MudAlert>
</MudExpansionPanels>
@code {
[Parameter] public AboutSettingsModel Model { get; set; } = new();
}
@@ -0,0 +1,68 @@
@using BackOffice.Pages.Content.Components.SettingsModels
<MudExpansionPanels MultiExpansion="true" Elevation="0">
@* ── Contact Info ── *@
<MudExpansionPanel Text="اطلاعات تماس" Icon="@Icons.Material.Filled.ContactPhone" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.Address"
Label="آدرس"
Variant="Variant.Outlined"
Lines="2"
Placeholder="کرج مهرویلا میدان مادر..." />
<MudGrid Spacing="2">
<MudItem xs="12" md="6">
<MudTextField @bind-Value="Model.Phone"
Label="شماره تلفن"
Placeholder="026-34233563"
Variant="Variant.Outlined" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="Model.Email"
Label="ایمیل"
Placeholder="info@kbs1.co"
Variant="Variant.Outlined" />
</MudItem>
</MudGrid>
<MudTextField @bind-Value="Model.WorkingHours"
Label="ساعات کاری"
Placeholder="شنبه تا پنج‌شنبه — ۹ صبح تا ۶ عصر"
Variant="Variant.Outlined" />
</MudStack>
</MudExpansionPanel>
@* ── Social Media ── *@
<MudExpansionPanel Text="شبکه‌های اجتماعی" Icon="@Icons.Material.Filled.Share" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.TelegramUrl"
Label="تلگرام"
Placeholder="https://t.me/kbs1"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Custom.Brands.Telegram" />
<MudTextField @bind-Value="Model.InstagramUrl"
Label="اینستاگرام"
Placeholder="https://instagram.com/kbs1"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Custom.Brands.Instagram" />
<MudTextField @bind-Value="Model.LinkedinUrl"
Label="لینکدین"
Placeholder="https://linkedin.com/company/kbs1"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Custom.Brands.LinkedIn" />
<MudTextField @bind-Value="Model.WhatsappUrl"
Label="واتساپ"
Placeholder="https://wa.me/989123456789"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Custom.Brands.WhatsApp" />
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
@code {
[Parameter] public ContactSettingsModel Model { get; set; } = new();
}
@@ -0,0 +1,332 @@
@using BackOffice.Pages.Content.Components.SettingsModels
<MudExpansionPanels MultiExpansion="true" Elevation="0">
@* ── Hero Buttons ── *@
<MudExpansionPanel Text="دکمه‌های هیرو" Icon="@Icons.Material.Filled.TouchApp" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.HeroButtonPrimaryText"
Label="متن دکمه اصلی"
Placeholder="شروع رایگان"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.HeroButtonSecondaryText"
Label="متن دکمه ثانویه"
Placeholder="آخرین اخبار"
Variant="Variant.Outlined" />
</MudStack>
</MudExpansionPanel>
@* ── Trust Badges ── *@
<MudExpansionPanel Text="@($"نشان‌های اعتماد ({Model.TrustBadges.Count})")" Icon="@Icons.Material.Filled.Verified">
<MudStack Spacing="2" Class="pa-2">
@for (var i = 0; i < Model.TrustBadges.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary" Style="min-width:20px;">@(index + 1)</MudText>
<MudTextField @bind-Value="Model.TrustBadges[index].IconName"
Label="نام آیکون"
Placeholder="Timer"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudTextField @bind-Value="Model.TrustBadges[index].Text"
Label="متن"
Placeholder="ثبت‌نام زیر ۲ دقیقه"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:2;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.TrustBadges.RemoveAt(index)" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.TrustBadges.Add(new TrustBadgeModel())">
افزودن نشان
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── Steps ── *@
<MudExpansionPanel Text="@($"مراحل ({Model.Steps.Count})")" Icon="@Icons.Material.Filled.FormatListNumbered">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.StepsTitle"
Label="عنوان بخش مراحل"
Placeholder="سه گام تا شروع"
Variant="Variant.Outlined" />
@for (var i = 0; i < Model.Steps.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudAvatar Size="Size.Small" Color="Color.Primary">
<MudText Typo="Typo.caption" Style="color:#fff;">@(index + 1)</MudText>
</MudAvatar>
<MudTextField @bind-Value="Model.Steps[index].Title"
Label="عنوان مرحله"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.Steps.RemoveAt(index)" />
</MudStack>
<MudTextField @bind-Value="Model.Steps[index].Description"
Label="توضیحات"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.Steps.Add(new StepModel())">
افزودن مرحله
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── Features ── *@
<MudExpansionPanel Text="@($"ویژگی‌ها ({Model.Features.Count})")" Icon="@Icons.Material.Filled.Star">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.FeaturesTitle"
Label="عنوان بخش ویژگی‌ها"
Placeholder="چرا کارا بازار سلامت؟"
Variant="Variant.Outlined" />
@for (var i = 0; i < Model.Features.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary" Style="min-width:20px;">@(index + 1)</MudText>
<MudTextField @bind-Value="Model.Features[index].IconName"
Label="نام آیکون"
Placeholder="VerifiedUser"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudTextField @bind-Value="Model.Features[index].Title"
Label="عنوان"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:2;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.Features.RemoveAt(index)" />
</MudStack>
<MudTextField @bind-Value="Model.Features[index].Description"
Label="توضیحات"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.Features.Add(new FeatureModel())">
افزودن ویژگی
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── Stats ── *@
<MudExpansionPanel Text="@($"آمار ({Model.Stats.Count})")" Icon="@Icons.Material.Filled.BarChart">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.StatsTitle"
Label="عنوان بخش آمار"
Placeholder="آمار پلتفرم"
Variant="Variant.Outlined" />
@for (var i = 0; i < Model.Stats.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary" Style="min-width:20px;">@(index + 1)</MudText>
<MudTextField @bind-Value="Model.Stats[index].Label"
Label="عنوان"
Placeholder="رشد میانگین تیم"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:2;" />
<MudNumericField @bind-Value="Model.Stats[index].Value"
Label="مقدار"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudTextField @bind-Value="Model.Stats[index].Suffix"
Label="پسوند"
Placeholder="%+"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudSelect @bind-Value="Model.Stats[index].Color"
Label="رنگ"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;">
<MudSelectItem Value="@("Primary")">آبی</MudSelectItem>
<MudSelectItem Value="@("Secondary")">بنفش</MudSelectItem>
<MudSelectItem Value="@("Success")">سبز</MudSelectItem>
<MudSelectItem Value="@("Warning")">زرد</MudSelectItem>
<MudSelectItem Value="@("Error")">قرمز</MudSelectItem>
<MudSelectItem Value="@("Info")">آبی روشن</MudSelectItem>
</MudSelect>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.Stats.RemoveAt(index)" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.Stats.Add(new StatModel())">
افزودن آمار
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── Testimonials ── *@
<MudExpansionPanel Text="@($"نظرات مشتریان ({Model.Testimonials.Count})")" Icon="@Icons.Material.Filled.FormatQuote">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.TestimonialsTitle"
Label="عنوان بخش نظرات"
Placeholder="اعتماد مشتریان"
Variant="Variant.Outlined" />
@for (var i = 0; i < Model.Testimonials.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary" Style="min-width:20px;">@(index + 1)</MudText>
<MudTextField @bind-Value="Model.Testimonials[index].Name"
Label="نام"
Placeholder="شرکت سینا نت"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudTextField @bind-Value="Model.Testimonials[index].Role"
Label="سمت"
Placeholder="مدیر عملیات"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.Testimonials.RemoveAt(index)" />
</MudStack>
<MudTextField @bind-Value="Model.Testimonials[index].Quote"
Label="متن نظر"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.Testimonials.Add(new TestimonialModel())">
افزودن نظر
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── FAQs ── *@
<MudExpansionPanel Text="@($"سوالات متداول ({Model.Faqs.Count})")" Icon="@Icons.Material.Filled.QuestionAnswer">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.FaqTitle"
Label="عنوان بخش سوالات"
Placeholder="سوالات متداول"
Variant="Variant.Outlined" />
@for (var i = 0; i < Model.Faqs.Count; i++)
{
var index = i;
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="border:1px solid var(--mud-palette-lines-default);">
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary" Style="min-width:20px;">@(index + 1)</MudText>
<MudTextField @bind-Value="Model.Faqs[index].Question"
Label="سوال"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Style="flex:1;" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="() => Model.Faqs.RemoveAt(index)" />
</MudStack>
<MudTextField @bind-Value="Model.Faqs[index].Answer"
Label="پاسخ"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="2" />
</MudStack>
</MudPaper>
}
<MudButton StartIcon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Variant="Variant.Text"
OnClick="() => Model.Faqs.Add(new FaqModel())">
افزودن سوال
</MudButton>
</MudStack>
</MudExpansionPanel>
@* ── CTA Section ── *@
<MudExpansionPanel Text="بنر فراخوان (CTA)" Icon="@Icons.Material.Filled.Campaign">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.CtaTitle"
Label="عنوان CTA"
Placeholder="آماده شروع هستید؟"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.CtaDescription"
Label="توضیحات CTA"
Placeholder="همین الان ثبت‌نام کنید..."
Variant="Variant.Outlined"
Lines="2" />
<MudTextField @bind-Value="Model.CtaButtonText"
Label="متن دکمه CTA"
Placeholder="شروع رایگان"
Variant="Variant.Outlined" />
</MudStack>
</MudExpansionPanel>
@* ── Blog Section ── *@
<MudExpansionPanel Text="بخش بلاگ" Icon="@Icons.Material.Filled.Article">
<MudStack Spacing="2" Class="pa-2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="_featuredBlogEnabled" Color="Color.Primary" />
<MudText Typo="Typo.body2">نمایش آخرین مطالب بلاگ</MudText>
</MudStack>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
@code {
[Parameter] public LandingSettingsModel Model { get; set; } = new();
private bool _featuredBlogEnabled
{
get => Model.FeaturedBlogEnabled ?? true;
set => Model.FeaturedBlogEnabled = value;
}
}
@@ -0,0 +1,32 @@
@using BackOffice.Pages.Content.Components.SettingsModels
<MudExpansionPanels MultiExpansion="true" Elevation="0">
@* ── Page Settings ── *@
<MudExpansionPanel Text="تنظیمات صفحه" Icon="@Icons.Material.Filled.Settings" IsInitiallyExpanded="true">
<MudStack Spacing="2" Class="pa-2">
<MudTextField @bind-Value="Model.PageDescription"
Label="توضیحات صفحه"
Variant="Variant.Outlined"
Lines="3"
Placeholder="مجوزها و نمادهای اعتماد الکترونیکی ما..." />
<MudSelect @bind-Value="Model.DisplayStyle"
Label="نوع نمایش"
Variant="Variant.Outlined">
<MudSelectItem Value="@("grid")">گرید (Grid)</MudSelectItem>
<MudSelectItem Value="@("list")">لیست (List)</MudSelectItem>
<MudSelectItem Value="@("carousel")">اسلایدر (Carousel)</MudSelectItem>
</MudSelect>
</MudStack>
</MudExpansionPanel>
@* ── Hint ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mt-2">
تصاویر مجوزها از بخش «مدیریت تصاویر» قابل افزودن و ویرایش هستند.
</MudAlert>
</MudExpansionPanels>
@code {
[Parameter] public LicensesSettingsModel Model { get; set; } = new();
}
@@ -0,0 +1,100 @@
@using BackOffice.Services.Content
@using Microsoft.AspNetCore.Components.Forms
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudSelect T="string" @bind-Value="Model.ImageGroup"
Label="گروه تصویر"
Required="true"
Variant="Variant.Outlined">
@foreach (var group in GetAvailableGroups())
{
<MudSelectItem T="string" Value="@group.Key">@group.Value</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="Model.Title"
Label="عنوان"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.Subtitle"
Label="زیرعنوان"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.Description"
Label="توضیحات"
Variant="Variant.Outlined"
Lines="3" />
<MudTextField @bind-Value="Model.LinkUrl"
Label="لینک"
Variant="Variant.Outlined"
Placeholder="https://..." />
<MudTextField @bind-Value="Model.IconName"
Label="نام آیکون"
Variant="Variant.Outlined"
HelperText="نام آیکون Material Design — مثلاً: Verified, Star, Group" />
<MudNumericField @bind-Value="Model.SortOrder"
Label="ترتیب نمایش"
Variant="Variant.Outlined"
Min="0" />
<MudText Typo="Typo.subtitle2">تصویر</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="200" Height="150" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(ExistingImagePath))
{
<Image Src="@ExistingImagePath" Width="200" Height="150" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:200px;height:150px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync"
Disabled="@(!_isValid || _loading)">
@(IsNew ? "افزودن" : "ذخیره تغییرات")
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,112 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class PageImageEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageSettingsService PageSettingsService { get; set; } = default!;
[Parameter] public SavePageImageDto Model { get; set; } = new();
[Parameter] public string PageKey { get; set; } = string.Empty;
[Parameter] public bool IsNew { get; set; }
[Parameter] public string? ExistingImagePath { get; set; }
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
else if (IsNew && _imageBuffer == null)
{
Snackbar.Add("لطفاً یک تصویر انتخاب کنید", Severity.Warning);
_loading = false;
return;
}
try
{
var result = await PageSettingsService.SaveImageAsync(Model);
if (result.Success)
{
DialogInstance.Close(DialogResult.Ok(true));
}
else
{
Snackbar.Add("خطا در ذخیره تصویر", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private Dictionary<string, string> GetAvailableGroups() => PageKey switch
{
"about" => new Dictionary<string, string>
{
{ "team", "اعضای تیم" },
{ "values", "ارزش‌ها" }
},
"licenses" => new Dictionary<string, string>
{
{ "licenses", "مجوزها" }
},
"landing" => new Dictionary<string, string>
{
{ "values", "ارزش‌ها" }
},
_ => new Dictionary<string, string>
{
{ "values", "ارزش‌ها" },
{ "team", "اعضای تیم" },
{ "licenses", "مجوزها" }
}
};
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,88 @@
@using BackOffice.Services.Content
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle1">تصاویر صفحه «@PageTitle»</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="AddImage">
افزودن تصویر
</MudButton>
</MudStack>
@if (!Images.Any())
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="my-4">
تصویری ثبت نشده است.
</MudAlert>
}
else
{
<MudDataGrid T="PageImageItemDto"
Items="Images"
Hover="true"
Dense="true"
Elevation="0">
<Columns>
<TemplateColumn Title="تصویر" Sortable="false">
<CellTemplate>
@if (!string.IsNullOrWhiteSpace(context.Item.ThumbnailPath))
{
<Image Src="@context.Item.ThumbnailPath" Width="48" Height="48" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrWhiteSpace(context.Item.ImagePath))
{
<Image Src="@context.Item.ImagePath" Width="48" Height="48" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.ImageNotSupported" Size="Size.Medium" />
}
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.ImageGroup" Title="گروه" />
<PropertyColumn Property="x => x.Title" Title="عنوان" />
<PropertyColumn Property="x => x.SortOrder" Title="ترتیب" />
<PropertyColumn Property="x => x.IsActive" Title="وضعیت">
<CellTemplate>
<MudChip T="string"
Color="@(context.Item.IsActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(context.Item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CellTemplate>
</PropertyColumn>
<TemplateColumn Title="عملیات" Sortable="false">
<CellTemplate>
<MudStack Row="true" Spacing="1">
<MudTooltip Text="ویرایش">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
OnClick="@(() => EditImage(context.Item))" />
</MudTooltip>
<MudTooltip Text="حذف">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeleteImage(context.Item))" />
</MudTooltip>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
</MudDataGrid>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Close">
بستن
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,133 @@
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class PageImagesDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageSettingsService PageSettingsService { get; set; } = default!;
[Parameter] public long PageId { get; set; }
[Parameter] public string PageKey { get; set; } = string.Empty;
[Parameter] public string PageTitle { get; set; } = string.Empty;
[Parameter] public List<PageImageItemDto> Images { get; set; } = new();
private bool _hasChanges;
private async Task AddImage()
{
var dto = new SavePageImageDto
{
SitePageSettingsId = PageId,
ImageGroup = GetDefaultImageGroup(),
IsActive = true,
SortOrder = Images.Count + 1
};
var parameters = new DialogParameters<PageImageEditDialog>
{
{ nameof(PageImageEditDialog.Model), dto },
{ nameof(PageImageEditDialog.PageKey), PageKey },
{ nameof(PageImageEditDialog.IsNew), true }
};
var dialog = await DialogService.ShowAsync<PageImageEditDialog>(
"افزودن تصویر", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result is { Canceled: false })
{
Snackbar.Add("تصویر با موفقیت اضافه شد", Severity.Success);
_hasChanges = true;
await ReloadImages();
}
}
private async Task EditImage(PageImageItemDto item)
{
var dto = new SavePageImageDto
{
Id = item.Id,
SitePageSettingsId = PageId,
ImageGroup = item.ImageGroup,
Title = item.Title,
Subtitle = item.Subtitle,
Description = item.Description,
LinkUrl = item.LinkUrl,
IconName = item.IconName,
SortOrder = item.SortOrder,
IsActive = item.IsActive
};
var parameters = new DialogParameters<PageImageEditDialog>
{
{ nameof(PageImageEditDialog.Model), dto },
{ nameof(PageImageEditDialog.PageKey), PageKey },
{ nameof(PageImageEditDialog.IsNew), false },
{ nameof(PageImageEditDialog.ExistingImagePath), item.ImagePath }
};
var dialog = await DialogService.ShowAsync<PageImageEditDialog>(
"ویرایش تصویر", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result is { Canceled: false })
{
Snackbar.Add("تصویر با موفقیت ویرایش شد", Severity.Success);
_hasChanges = true;
await ReloadImages();
}
}
private async Task DeleteImage(PageImageItemDto item)
{
var confirm = await DialogService.ShowMessageBox(
"تایید حذف",
$"آیا از حذف تصویر «{item.Title ?? item.ImageGroup}» اطمینان دارید؟",
yesText: "بله، حذف شود",
cancelText: "انصراف");
if (confirm != true) return;
try
{
await PageSettingsService.DeleteImageAsync(item.Id);
Snackbar.Add("تصویر با موفقیت حذف شد", Severity.Success);
_hasChanges = true;
await ReloadImages();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در حذف تصویر: {ex.Message}", Severity.Error);
}
}
private async Task ReloadImages()
{
var details = await PageSettingsService.GetByKeyAsync(PageKey);
if (details != null)
{
Images = details.Images;
StateHasChanged();
}
}
private string GetDefaultImageGroup() => PageKey switch
{
"about" => "team",
"licenses" => "licenses",
_ => "values"
};
private void Close()
{
if (_hasChanges)
DialogInstance.Close(DialogResult.Ok(true));
else
DialogInstance.Cancel();
}
}
@@ -0,0 +1,111 @@
@using BackOffice.Services.Content
@using BackOffice.Pages.Content.Components.SettingsModels
@using Microsoft.AspNetCore.Components.Forms
@using BackOffice.Common.BaseComponents
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="@_isValid">
<MudStack Spacing="2">
<MudTextField @bind-Value="Model.Title"
Label="عنوان صفحه"
Required="true"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.MetaDescription"
Label="توضیحات متا (SEO)"
Variant="Variant.Outlined"
Lines="2" />
<MudTextField @bind-Value="Model.HeroTitle"
Label="عنوان هیرو"
Variant="Variant.Outlined" />
<MudTextField @bind-Value="Model.HeroSubtitle"
Label="زیرعنوان هیرو"
Variant="Variant.Outlined"
Lines="2" />
<MudText Typo="Typo.subtitle2">تصویر هیرو</MudText>
<MudStack Justify="Justify.Center" AlignItems="AlignItems.Center">
@if (!string.IsNullOrWhiteSpace(_imagePreview))
{
<Image Src="@_imagePreview" Width="280" Height="160" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else if (!string.IsNullOrEmpty(ExistingHeroImagePath))
{
<Image Src="@ExistingHeroImagePath" Width="280" Height="160" ObjectPosition="ObjectPosition.Center" ObjectFit="ObjectFit.Cover" />
}
else
{
<MudPaper Class="d-flex align-center justify-center" Style="width:280px;height:160px;">
<MudText Typo="Typo.caption">تصویری انتخاب نشده است</MudText>
</MudPaper>
}
<MudFileUpload T="IBrowserFile" Accept="image/*" FilesChanged="OnImageSelected">
<ActivatorContent>
<MudButton HtmlTag="label"
Variant="Variant.Filled"
Color="Color.Primary"
ButtonType="ButtonType.Button"
StartIcon="@Icons.Material.Filled.Image">
انتخاب تصویر هیرو
</MudButton>
</ActivatorContent>
<SelectedTemplate>
@if (context != null)
{
<MudText Class="mt-1" Typo="Typo.caption">@context.Name</MudText>
}
</SelectedTemplate>
</MudFileUpload>
</MudStack>
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle1" Class="mb-1">
<MudIcon Icon="@Icons.Material.Filled.Tune" Size="Size.Small" Class="ml-1" />
تنظیمات اختصاصی «@PageTitle»
</MudText>
@* ── Typed Settings Editors ── *@
@switch (Model.PageKey)
{
case "landing":
<LandingSettingsEditor Model="_landingSettings" />
break;
case "about":
<AboutSettingsEditor Model="_aboutSettings" />
break;
case "contact":
<ContactSettingsEditor Model="_contactSettings" />
break;
case "licenses":
<LicensesSettingsEditor Model="_licensesSettings" />
break;
default:
<MudTextField @bind-Value="Model.SettingsJson"
Label="تنظیمات JSON"
Variant="Variant.Outlined"
Lines="8"
HelperText="تنظیمات اختصاصی صفحه (JSON)" />
break;
}
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudSwitch T="bool" @bind-Value="Model.IsActive" Color="Color.Primary" />
<MudText Typo="Typo.body2">فعال</MudText>
</MudStack>
</MudStack>
</MudForm>
</DialogContent>
<DialogActions>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SubmitAsync" Disabled="@(!_isValid || _loading)">
ذخیره تغییرات
</MudButton>
<MudButton Color="Color.Default" Variant="Variant.Text" OnClick="Cancel">
انصراف
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,151 @@
using System.Text.Json;
using BackOffice.Pages.Content.Components.SettingsModels;
using BackOffice.Services.Content;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MudBlazor;
namespace BackOffice.Pages.Content.Components;
public partial class PageSettingsEditDialog
{
[CascadingParameter] IMudDialogInstance DialogInstance { get; set; } = default!;
[Inject] public ISitePageSettingsService PageSettingsService { get; set; } = default!;
[Parameter] public SavePageSettingsDto Model { get; set; } = new();
[Parameter] public string? ExistingHeroImagePath { get; set; }
[Parameter] public string PageTitle { get; set; } = string.Empty;
private MudForm? _form;
private bool _isValid = true;
private bool _loading;
private IBrowserFile? _imageFile;
private byte[]? _imageBuffer;
private string? _imagePreview;
private readonly long _maxAllowedSize = (1024 * 1024) * 5;
// ── Typed settings models ──
private LandingSettingsModel _landingSettings = new();
private AboutSettingsModel _aboutSettings = new();
private ContactSettingsModel _contactSettings = new();
private LicensesSettingsModel _licensesSettings = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
protected override void OnParametersSet()
{
DeserializeSettings();
}
/// <summary>
/// Parse existing SettingsJson into the typed model based on PageKey
/// </summary>
private void DeserializeSettings()
{
if (string.IsNullOrWhiteSpace(Model.SettingsJson)) return;
try
{
switch (Model.PageKey)
{
case "landing":
_landingSettings = JsonSerializer.Deserialize<LandingSettingsModel>(Model.SettingsJson, JsonOptions) ?? new();
break;
case "about":
_aboutSettings = JsonSerializer.Deserialize<AboutSettingsModel>(Model.SettingsJson, JsonOptions) ?? new();
break;
case "contact":
_contactSettings = JsonSerializer.Deserialize<ContactSettingsModel>(Model.SettingsJson, JsonOptions) ?? new();
break;
case "licenses":
_licensesSettings = JsonSerializer.Deserialize<LicensesSettingsModel>(Model.SettingsJson, JsonOptions) ?? new();
break;
}
}
catch
{
// If JSON is malformed, start with empty model
}
}
/// <summary>
/// Serialize the typed model back to JSON before saving
/// </summary>
private void SerializeSettings()
{
Model.SettingsJson = Model.PageKey switch
{
"landing" => JsonSerializer.Serialize(_landingSettings, JsonOptions),
"about" => JsonSerializer.Serialize(_aboutSettings, JsonOptions),
"contact" => JsonSerializer.Serialize(_contactSettings, JsonOptions),
"licenses" => JsonSerializer.Serialize(_licensesSettings, JsonOptions),
_ => Model.SettingsJson // keep raw JSON for unknown pages
};
}
private async Task OnImageSelected(IBrowserFile? file)
{
_imageFile = file;
if (file != null)
{
using var ms = new MemoryStream();
await file.OpenReadStream(_maxAllowedSize).CopyToAsync(ms);
_imageBuffer = ms.ToArray();
_imagePreview = $"data:{file.ContentType};base64," + Convert.ToBase64String(_imageBuffer);
StateHasChanged();
}
}
private async Task SubmitAsync()
{
if (_form != null)
{
await _form.Validate();
if (!_isValid) return;
}
_loading = true;
// Serialize typed model → JSON before saving
SerializeSettings();
if (_imageFile != null && _imageBuffer != null)
{
Model.ImageFile = _imageBuffer;
Model.ImageMime = _imageFile.ContentType;
Model.ImageFileName = Path.GetFileNameWithoutExtension(_imageFile.Name);
}
try
{
var result = await PageSettingsService.SaveSettingsAsync(Model);
if (result.Success)
{
DialogInstance.Close(DialogResult.Ok(true));
}
else
{
Snackbar.Add("خطا در ذخیره تنظیمات", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private void Cancel()
{
DialogInstance.Cancel();
}
}
@@ -0,0 +1,108 @@
namespace BackOffice.Pages.Content.Components.SettingsModels;
// ══════════════════════════════════════════════════════════════
// LANDING PAGE
// ══════════════════════════════════════════════════════════════
public class LandingSettingsModel
{
public string? HeroButtonPrimaryText { get; set; }
public string? HeroButtonSecondaryText { get; set; }
public List<TrustBadgeModel> TrustBadges { get; set; } = new();
public string? StepsTitle { get; set; }
public List<StepModel> Steps { get; set; } = new();
public string? FeaturesTitle { get; set; }
public List<FeatureModel> Features { get; set; } = new();
public string? StatsTitle { get; set; }
public List<StatModel> Stats { get; set; } = new();
public string? TestimonialsTitle { get; set; }
public List<TestimonialModel> Testimonials { get; set; } = new();
public string? FaqTitle { get; set; }
public List<FaqModel> Faqs { get; set; } = new();
public string? CtaTitle { get; set; }
public string? CtaDescription { get; set; }
public string? CtaButtonText { get; set; }
public bool? FeaturedBlogEnabled { get; set; }
}
public class TrustBadgeModel
{
public string? IconName { get; set; }
public string? Text { get; set; }
}
public class StepModel
{
public string? Title { get; set; }
public string? Description { get; set; }
}
public class FeatureModel
{
public string? IconName { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
}
public class StatModel
{
public string? Label { get; set; }
public double Value { get; set; }
public string? Suffix { get; set; }
public string? Color { get; set; }
}
public class TestimonialModel
{
public string? Quote { get; set; }
public string? Name { get; set; }
public string? Role { get; set; }
}
public class FaqModel
{
public string? Question { get; set; }
public string? Answer { get; set; }
}
// ══════════════════════════════════════════════════════════════
// ABOUT PAGE
// ══════════════════════════════════════════════════════════════
public class AboutSettingsModel
{
public string? VisionTitle { get; set; }
public string? VisionText { get; set; }
public string? VisionIcon { get; set; }
public string? MissionTitle { get; set; }
public string? MissionText { get; set; }
public string? MissionIcon { get; set; }
public string? ValuesTitle { get; set; }
public string? TeamTitle { get; set; }
}
// ══════════════════════════════════════════════════════════════
// CONTACT PAGE
// ══════════════════════════════════════════════════════════════
public class ContactSettingsModel
{
public string? Address { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? WorkingHours { get; set; }
public string? TelegramUrl { get; set; }
public string? InstagramUrl { get; set; }
public string? LinkedinUrl { get; set; }
public string? WhatsappUrl { get; set; }
}
// ══════════════════════════════════════════════════════════════
// LICENSES PAGE
// ══════════════════════════════════════════════════════════════
public class LicensesSettingsModel
{
public string? PageDescription { get; set; }
public string? DisplayStyle { get; set; }
}
@@ -0,0 +1,74 @@
@page "/content/page-settings"
@using BackOffice.Services.Content
@inject ISitePageSettingsService PageSettingsService
<div>
<MudStack Spacing="3">
<MudText Typo="Typo.h5">مدیریت تنظیمات صفحات</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">تنظیمات ۴ صفحه ثابت سایت: صفحه اصلی، درباره ما، تماس با ما و مجوزها</MudText>
@if (_loading)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudGrid>
@foreach (var item in _pages)
{
<MudItem xs="12" sm="6" md="3">
<MudCard Elevation="2" Class="pa-2">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@GetPageIcon(item.PageKey)" Size="Size.Large" Color="Color.Primary" />
<MudStack Spacing="0">
<MudText Typo="Typo.h6">@item.Title</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@item.PageKey</MudText>
</MudStack>
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
<MudChip T="string"
Color="@(item.IsActive ? Color.Success : Color.Error)"
Size="Size.Small">
@(item.IsActive ? "فعال" : "غیرفعال")
</MudChip>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="1">
<MudText Typo="Typo.body2">
<MudIcon Icon="@Icons.Material.Filled.Image" Size="Size.Small" Class="ml-1" />
@item.ImageCount تصویر
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
آخرین ویرایش: @item.LastModified.MiladiToJalaliWithTime()
</MudText>
</MudStack>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Edit"
OnClick="@(() => EditPageSettings(item))">
ویرایش
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Info"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Collections"
OnClick="@(() => ManageImages(item))">
تصاویر
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
}
</MudStack>
</div>
@@ -0,0 +1,119 @@
using BackOffice.Services.Content;
using BackOffice.Pages.Content.Components;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace BackOffice.Pages.Content;
public partial class PageSettingsManagementPage
{
private List<PageSettingsSummaryDto> _pages = new();
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
await LoadPagesAsync();
}
private async Task LoadPagesAsync()
{
_loading = true;
try
{
_pages = await PageSettingsService.GetAllAsync();
}
catch (Exception ex)
{
Snackbar.Add($"خطا در دریافت صفحات: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task EditPageSettings(PageSettingsSummaryDto summary)
{
var details = await PageSettingsService.GetByKeyAsync(summary.PageKey);
if (details == null)
{
Snackbar.Add("خطا در دریافت تنظیمات صفحه", Severity.Error);
return;
}
var dto = new SavePageSettingsDto
{
PageKey = details.PageKey,
Title = details.Title,
MetaDescription = details.MetaDescription,
HeroTitle = details.HeroTitle,
HeroSubtitle = details.HeroSubtitle,
IsActive = details.IsActive,
SettingsJson = details.SettingsJson
};
var parameters = new DialogParameters<PageSettingsEditDialog>
{
{ nameof(PageSettingsEditDialog.Model), dto },
{ nameof(PageSettingsEditDialog.ExistingHeroImagePath), details.HeroImagePath },
{ nameof(PageSettingsEditDialog.PageTitle), GetPageDisplayName(summary.PageKey) }
};
var dialog = await DialogService.ShowAsync<PageSettingsEditDialog>(
$"ویرایش تنظیمات «{GetPageDisplayName(summary.PageKey)}»", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result is { Canceled: false })
{
Snackbar.Add("تنظیمات صفحه با موفقیت ذخیره شد", Severity.Success);
await LoadPagesAsync();
}
}
private async Task ManageImages(PageSettingsSummaryDto summary)
{
var details = await PageSettingsService.GetByKeyAsync(summary.PageKey);
if (details == null)
{
Snackbar.Add("خطا در دریافت اطلاعات صفحه", Severity.Error);
return;
}
var parameters = new DialogParameters<PageImagesDialog>
{
{ nameof(PageImagesDialog.PageId), details.Id },
{ nameof(PageImagesDialog.PageKey), details.PageKey },
{ nameof(PageImagesDialog.PageTitle), GetPageDisplayName(summary.PageKey) },
{ nameof(PageImagesDialog.Images), details.Images }
};
var dialog = await DialogService.ShowAsync<PageImagesDialog>(
$"مدیریت تصاویر «{GetPageDisplayName(summary.PageKey)}»", parameters,
new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Large, FullWidth = true });
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadPagesAsync();
}
}
private static string GetPageIcon(string pageKey) => pageKey switch
{
"landing" => Icons.Material.Filled.Home,
"about" => Icons.Material.Filled.Info,
"contact" => Icons.Material.Filled.ContactMail,
"licenses" => Icons.Material.Filled.VerifiedUser,
_ => Icons.Material.Filled.Article
};
private static string GetPageDisplayName(string pageKey) => pageKey switch
{
"landing" => "صفحه اصلی",
"about" => "درباره ما",
"contact" => "تماس با ما",
"licenses" => "مجوزها",
_ => pageKey
};
}
@@ -1,6 +1,7 @@
@using CMSMicroservice.Protobuf.Protos.Inventory
@using Google.Protobuf.WellKnownTypes
@using InventoryProductType = CMSMicroservice.Protobuf.Protos.Inventory.ProductType
@using BackOffice.Pages.AutoComplete
<MudDialog>
<DialogContent>
@@ -19,12 +20,16 @@
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudNumericField T="long" @bind-Value="_productId"
Label="شناسه محصول"
Variant="Variant.Outlined"
Required="true"
RequiredError="شناسه محصول الزامی است"
Min="1" />
@if (_productType == InventoryProductType.RegularProduct)
{
<ProductsAutoComplete @bind-Value="_productId"
Label="انتخاب محصول عادی" />
}
else
{
<DiscountProductsAutoComplete @bind-Value="_discountProductId"
Label="انتخاب محصول تخفیفی" />
}
</MudItem>
}
else
@@ -103,7 +108,8 @@
private bool _isSubmitting;
private InventoryProductType _productType = InventoryProductType.RegularProduct;
private long _productId;
private long? _productId;
private long? _discountProductId;
private long? _warehouseId;
private int _quantity = 1;
private string _referenceNumber = string.Empty;
@@ -111,9 +117,12 @@
protected override void OnInitialized()
{
if (ProductIdParam.HasValue)
if (ProductIdParam.HasValue && ProductTypeParam.HasValue)
{
_productId = ProductIdParam.Value;
if (ProductTypeParam.Value == InventoryProductType.DiscountProduct)
_discountProductId = ProductIdParam.Value;
else
_productId = ProductIdParam.Value;
}
if (ProductTypeParam.HasValue)
{
@@ -144,9 +153,19 @@
_isSubmitting = true;
try
{
var selectedProductId = ProductIdParam
?? (_productType == InventoryProductType.DiscountProduct ? _discountProductId : _productId);
if (!selectedProductId.HasValue || selectedProductId.Value <= 0)
{
Snackbar.Add("لطفاً یک محصول انتخاب کنید", Severity.Warning);
_isSubmitting = false;
return;
}
var request = new AddStockRequest
{
ProductId = ProductIdParam ?? _productId,
ProductId = selectedProductId.Value,
ProductType = ProductTypeParam ?? _productType,
Quantity = _quantity,
ReferenceNumber = _referenceNumber ?? string.Empty,
@@ -0,0 +1,96 @@
namespace BackOffice.Services.Content;
public interface ISitePageSettingsService
{
Task<List<PageSettingsSummaryDto>> GetAllAsync();
Task<PageSettingsDetailsDto?> GetByKeyAsync(string pageKey);
Task<SaveResultDto> SaveSettingsAsync(SavePageSettingsDto dto);
Task<SaveResultDto> SaveImageAsync(SavePageImageDto dto);
Task DeleteImageAsync(long id);
}
// ── Summary DTO (لیست صفحات) ──
public class PageSettingsSummaryDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int ImageCount { get; set; }
public DateTime LastModified { get; set; }
}
// ── Details DTO (تنظیمات کامل + تصاویر) ──
public class PageSettingsDetailsDto
{
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 string? SettingsJson { get; set; }
public List<PageImageItemDto> Images { get; set; } = new();
}
// ── Image Item DTO ──
public class PageImageItemDto
{
public long Id { get; set; }
public string ImageGroup { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string? ThumbnailPath { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
// ── Save Settings DTO ──
public class SavePageSettingsDto
{
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 bool IsActive { get; set; } = true;
public string? SettingsJson { get; set; }
// Hero image upload
public byte[]? ImageFile { get; set; }
public string? ImageMime { get; set; }
public string? ImageFileName { get; set; }
}
// ── Save Image DTO ──
public class SavePageImageDto
{
public long Id { get; set; } // 0 = جدید
public long SitePageSettingsId { get; set; }
public string ImageGroup { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
// Image upload
public byte[]? ImageFile { get; set; }
public string? ImageMime { get; set; }
public string? ImageFileName { get; set; }
}
// ── Save Result ──
public class SaveResultDto
{
public long Id { get; set; }
public bool Success { get; set; }
}
@@ -0,0 +1,131 @@
using CMSMicroservice.Protobuf.Protos.SitePageSettings;
namespace BackOffice.Services.Content;
public class SitePageSettingsService : ISitePageSettingsService
{
private readonly SitePageSettingsContract.SitePageSettingsContractClient _client;
public SitePageSettingsService(SitePageSettingsContract.SitePageSettingsContractClient client)
{
_client = client;
}
public async Task<List<PageSettingsSummaryDto>> GetAllAsync()
{
var response = await _client.GetAllPageSettingsAsync(new GetAllPageSettingsRequest());
return response.Pages.Select(m => new PageSettingsSummaryDto
{
Id = m.Id,
PageKey = m.PageKey,
Title = m.Title,
IsActive = m.IsActive,
ImageCount = m.ImageCount,
LastModified = m.LastModified?.ToDateTime() ?? DateTime.MinValue
}).ToList();
}
public async Task<PageSettingsDetailsDto?> GetByKeyAsync(string pageKey)
{
var response = await _client.GetPageSettingsAsync(
new GetPageSettingsRequest { PageKey = pageKey });
if (response == null || response.Id <= 0) return null;
var dto = new PageSettingsDetailsDto
{
Id = response.Id,
PageKey = response.PageKey,
Title = response.Title,
MetaDescription = response.MetaDescription,
HeroTitle = response.HeroTitle,
HeroSubtitle = response.HeroSubtitle,
HeroImagePath = response.HeroImagePath,
IsActive = response.IsActive,
SettingsJson = response.SettingsJson
};
foreach (var img in response.Images)
{
dto.Images.Add(new PageImageItemDto
{
Id = img.Id,
ImageGroup = img.ImageGroup,
Title = img.Title,
Subtitle = img.Subtitle,
Description = img.Description,
ImagePath = img.ImagePath,
ThumbnailPath = img.ThumbnailPath,
LinkUrl = img.LinkUrl,
IconName = img.IconName,
SortOrder = img.SortOrder,
IsActive = img.IsActive
});
}
return dto;
}
public async Task<SaveResultDto> SaveSettingsAsync(SavePageSettingsDto dto)
{
var request = new SavePageSettingsRequest
{
PageKey = dto.PageKey,
Title = dto.Title,
MetaDescription = dto.MetaDescription ?? string.Empty,
HeroTitle = dto.HeroTitle ?? string.Empty,
HeroSubtitle = dto.HeroSubtitle ?? string.Empty,
IsActive = dto.IsActive,
SettingsJson = dto.SettingsJson ?? string.Empty
};
if (dto.ImageFile != null && !string.IsNullOrEmpty(dto.ImageMime) && !string.IsNullOrEmpty(dto.ImageFileName))
{
request.HeroImageFile = new PageSettingsImageFile
{
File = Google.Protobuf.ByteString.CopyFrom(dto.ImageFile),
Mime = dto.ImageMime,
FileName = dto.ImageFileName
};
}
var response = await _client.SavePageSettingsAsync(request);
return new SaveResultDto { Id = response.Id, Success = response.Success };
}
public async Task<SaveResultDto> SaveImageAsync(SavePageImageDto dto)
{
var request = new SavePageImageRequest
{
Id = dto.Id,
SitePageSettingsId = dto.SitePageSettingsId,
ImageGroup = dto.ImageGroup,
Title = dto.Title ?? string.Empty,
Subtitle = dto.Subtitle ?? string.Empty,
Description = dto.Description ?? string.Empty,
LinkUrl = dto.LinkUrl ?? string.Empty,
IconName = dto.IconName ?? string.Empty,
SortOrder = dto.SortOrder,
IsActive = dto.IsActive
};
if (dto.ImageFile != null && !string.IsNullOrEmpty(dto.ImageMime) && !string.IsNullOrEmpty(dto.ImageFileName))
{
request.ImageFile = new PageSettingsImageFile
{
File = Google.Protobuf.ByteString.CopyFrom(dto.ImageFile),
Mime = dto.ImageMime,
FileName = dto.ImageFileName
};
}
var response = await _client.SavePageImageAsync(request);
return new SaveResultDto { Id = response.Id, Success = response.Success };
}
public async Task DeleteImageAsync(long id)
{
await _client.DeletePageImageAsync(new DeletePageImageRequest { Id = id });
}
}
+2 -2
View File
@@ -231,9 +231,9 @@
@if (CanManageSitePages)
{
<MudNavLink Match="NavLinkMatch.Prefix"
Href="/content/pages"
Href="/content/page-settings"
Icon="@Icons.Material.Filled.WebAsset">
صفحات سایت
تنظیمات صفحات
</MudNavLink>
}
+1 -4
View File
@@ -1,8 +1,5 @@
{
"GwUrl": "http://localhost:32847",
// "GwUrl": "https://localhost:32846",
// "GwUrl": "https://backoffice-bff.se.kbs1.ir", // OLD - via BFF
// "GwUrl": "https://cms.se.kbs1.ir", // NEW - direct to CMS
"GwUrl": "http://localhost:32847",
"Authentication": {
"Authority": "https://ids.afrino.co/",
"ClientId": "client_backoffice_spa"