Initial commit - ECommerce.RCL
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
@using Commerce.Contracts.DTOs
|
||||
|
||||
<div class="category-tree-node mb-2">
|
||||
<!-- Category Card -->
|
||||
<div
|
||||
class="flex items-center gap-3 bg-base-100 hover:bg-base-200 border border-base-300 rounded-xl p-2 transition-all shadow-sm group flex-shrink-0 w-80">
|
||||
<!-- Square Image -->
|
||||
<div class="w-10 h-10 rounded-lg bg-base-300 flex-shrink-0 overflow-hidden shadow-inner">
|
||||
@if (Category.Photos?.Any() == true)
|
||||
{
|
||||
<img src="@Category.Photos[0]" alt="@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)" class="w-full h-full object-cover" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center justify-center h-full opacity-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Name and Badge -->
|
||||
<div class="flex-grow flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-sm text-base-content tracking-tight">@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</span>
|
||||
@if (Category.IsBundle)
|
||||
{
|
||||
<span class="text-[9px] uppercase font-black text-primary tracking-widest leading-none">Bundle</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1">
|
||||
@if (IsAdmin)
|
||||
{
|
||||
<button type="button" @onclick="() => OnEdit.InvokeAsync(Category)"
|
||||
class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/shop?categoryId=@Category.Id"
|
||||
class="btn btn-primary btn-xs rounded-lg group-hover:shadow-md transition-all">
|
||||
Visit
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (Category.ChildCategories?.Any() == true)
|
||||
{
|
||||
<button type="button" @onclick="ToggleExpand"
|
||||
class="btn btn-ghost btn-xs btn-circle transition-transform duration-300 @(_expanded ? "rotate-90" : "")">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-6 h-6"></div> @* Spacer to align cards *@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recursive Children -->
|
||||
@if (_expanded && Category.ChildCategories?.Any() == true)
|
||||
{
|
||||
<div class="ml-10 mt-2 border-l-2 border-base-300 pl-4 animate-in slide-in-from-left-2 duration-200">
|
||||
@foreach (var child in Category.ChildCategories)
|
||||
{
|
||||
<CategoryTreeItem Category="child" OnEdit="OnEdit" IsAdmin="IsAdmin" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public CategoryVM Category { get; set; } = new();
|
||||
[Parameter] public EventCallback<CategoryVM> OnEdit { get; set; }
|
||||
[Parameter] public bool IsAdmin { get; set; } = true;
|
||||
|
||||
private bool _expanded = false;
|
||||
|
||||
private void ToggleExpand() => _expanded = !_expanded;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
@page "/manage-category/{id?}"
|
||||
@layout AdminLayout
|
||||
@using Generic.Media.Components
|
||||
@using Commerce.Contracts.DTOs
|
||||
@inject CategoryAPIConsumer categoryAPIConsumer
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<div class="max-w-7xl mx-auto mt-10 px-4 pb-32 min-h-screen">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-3xl font-extrabold text-base-content tracking-tight">
|
||||
@(string.IsNullOrEmpty(Id) ? "Create Category" : "Edit Category")
|
||||
</h3>
|
||||
|
||||
<!-- Global Language Switcher -->
|
||||
<div class="flex gap-1 bg-base-200 p-1.5 rounded-2xl border border-base-300 shadow-sm">
|
||||
@foreach (var lang in _languages)
|
||||
{
|
||||
<button type="button" @onclick="() => _activeLang = lang.Key"
|
||||
class="btn btn-sm border-none transition-all duration-300 rounded-xl @(_activeLang == lang.Key ? "btn-primary shadow-md" : "btn-ghost opacity-60")">
|
||||
@lang.Value
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="HandleSubmit">
|
||||
<FluentValidationValidator />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
<!-- SIDEBAR: Configuration -->
|
||||
<div class="lg:col-span-1 space-y-8">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 !overflow-visible">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-base-200">
|
||||
<div class="p-2 bg-primary/10 rounded-lg text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="font-bold text-lg">Configuration</h4>
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Parent Category</span></label>
|
||||
<CategoryTreeSelect @bind-value="category.ParentCategoryId"
|
||||
DisallowedIds="@(category.Id.HasValue ? new List<Guid> { category.Id.Value } : null)"
|
||||
Placeholder="Root Category" />
|
||||
<ValidationMessage For="@(() => category.ParentCategoryId)"
|
||||
class="text-error text-xs mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Bundle Toggle -->
|
||||
<div class="form-control p-4 bg-base-200/50 rounded-2xl border border-base-300">
|
||||
<label class="label cursor-pointer justify-between">
|
||||
<span class="label-text font-bold">Bundle Category?</span>
|
||||
<InputCheckbox @bind-value="category.IsBundle" class="toggle toggle-primary" />
|
||||
</label>
|
||||
<p class="text-[10px] text-base-content/50 mt-1 uppercase tracking-widest font-bold">Groups
|
||||
multiple products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- MAIN: Localized Content -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<div class="card bg-base-100 shadow-2xl border border-base-200 !overflow-visible">
|
||||
<div class="p-8 space-y-10">
|
||||
<!-- Category Name -->
|
||||
<div class="form-control">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-xl font-black tracking-tight flex items-center gap-2">
|
||||
Category Name
|
||||
<div class="badge badge-primary badge-sm">@(_languages[_activeLang])</div>
|
||||
</h4>
|
||||
</div>
|
||||
<MultiLangInput @bind="_names" ActiveCulture="@_activeLang" HideTabs="true"
|
||||
Placeholder="Enter category name in the selected language..." />
|
||||
<ValidationMessage For="@(() => _names)" class="text-error text-sm mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-control">
|
||||
<h4 class="text-xl font-black tracking-tight mb-4 flex items-center gap-2">
|
||||
Description
|
||||
<div class="badge badge-secondary badge-sm">@(_languages[_activeLang])</div>
|
||||
</h4>
|
||||
<MultiLangInput @bind="_descriptions" ActiveCulture="@_activeLang"
|
||||
HideTabs="true" IsTextArea="true"
|
||||
Placeholder="Summarize what this category contains..." />
|
||||
<ValidationMessage For="@(() => _descriptions)" class="text-error text-sm mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Localized Specifications -->
|
||||
<div class="form-control pt-6 border-t border-base-200">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-xl font-black tracking-tight flex items-center gap-2">
|
||||
Category Specifications
|
||||
<div class="badge badge-accent badge-sm">@(_languages[_activeLang])</div>
|
||||
</h4>
|
||||
<button type="button" @onclick="AddTechnicalDetail"
|
||||
class="btn btn-sm btn-primary rounded-xl gap-2 shadow-lg shadow-primary/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Spec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if (!_technicalDetails.ContainsKey(_activeLang) ||
|
||||
!_technicalDetails[_activeLang].Any())
|
||||
{
|
||||
<div
|
||||
class="border-2 border-dashed border-base-200 rounded-3xl p-10 text-center opacity-30">
|
||||
<p class="text-sm font-bold uppercase tracking-widest">No specifications for
|
||||
@(_languages[_activeLang])</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in _technicalDetails[_activeLang])
|
||||
{
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 bg-base-200/30 p-4 rounded-2xl border border-base-300 relative group transition-all hover:bg-base-200/50">
|
||||
<div class="form-control">
|
||||
<label class="label p-0 mb-1">
|
||||
<span
|
||||
class="text-[9px] uppercase font-black text-primary/60">Attribute</span>
|
||||
</label>
|
||||
<InputText @bind-value="item.Key"
|
||||
class="input input-bordered input-sm rounded-xl focus:input-primary"
|
||||
Placeholder="e.g. Series" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label p-0 mb-1">
|
||||
<span class="text-[9px] uppercase font-black text-accent/60">Value</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<InputText @bind-value="item.Value"
|
||||
class="input input-bordered input-sm rounded-xl focus:input-primary w-full"
|
||||
Placeholder="e.g. Pro" />
|
||||
</div>
|
||||
<button type="button" @onclick="() => RemoveTechnicalDetail(item)"
|
||||
class="btn btn-ghost btn-circle btn-sm text-error self-end">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 !overflow-visible">
|
||||
<div class="p-8">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<div class="p-2 bg-secondary/10 rounded-lg text-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="font-bold text-xl">Category Media</h4>
|
||||
</div>
|
||||
<Media @ref="mediaComponent" InitialUrls="@(category.Photos?.ToList() ?? new List<string>())"
|
||||
SingleUploadMode="false" MaxItems="10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-4 p-8 bg-base-300/30 rounded-3xl border border-base-300 shadow-inner">
|
||||
<button type="button" @onclick="ResetForm" class="btn btn-ghost px-8 rounded-2xl">Reset</button>
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-wide rounded-2xl shadow-xl shadow-primary/20 gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
Save Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
|
||||
CategoryVM category = new();
|
||||
private EditContext? _editContext;
|
||||
Media? mediaComponent;
|
||||
Dictionary<string, string> _names = new();
|
||||
Dictionary<string, string> _descriptions = new();
|
||||
Dictionary<string, List<TechnicalDetailVM>> _technicalDetails = new();
|
||||
|
||||
private string _activeLang = "en";
|
||||
private readonly Dictionary<string, string> _languages = new()
|
||||
{
|
||||
{ "en", "English" },
|
||||
{ "ar", "العربية" },
|
||||
{ "es", "Español" },
|
||||
{ "de", "Deutsch" }
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Id))
|
||||
{
|
||||
category = await categoryAPIConsumer.GetCategory(Id);
|
||||
}
|
||||
|
||||
// Map from Translations to local dictionaries
|
||||
foreach(var t in category.Translations ?? new())
|
||||
{
|
||||
_names[t.Language] = t.Info.Name;
|
||||
_descriptions[t.Language] = t.Info.Description;
|
||||
_technicalDetails[t.Language] = t.Info.TechnicalDetails?.ToList() ?? new();
|
||||
}
|
||||
|
||||
// Ensure supported cultures exist in dictionaries
|
||||
foreach (var lang in _languages.Keys)
|
||||
{
|
||||
if (!_names.ContainsKey(lang)) _names[lang] = "";
|
||||
if (!_descriptions.ContainsKey(lang)) _descriptions[lang] = "";
|
||||
if (!_technicalDetails.ContainsKey(lang)) _technicalDetails[lang] = new();
|
||||
}
|
||||
|
||||
category.Photos ??= Array.Empty<string>();
|
||||
|
||||
SyncTranslations();
|
||||
_editContext = new EditContext(category);
|
||||
}
|
||||
|
||||
void SyncTranslations()
|
||||
{
|
||||
category.Translations = _languages.Keys.Select(lang => new CategoryTranslationVM
|
||||
{
|
||||
Language = lang,
|
||||
Info = new CategoryInfoVM
|
||||
{
|
||||
Name = _names.GetValueOrDefault(lang, ""),
|
||||
Description = _descriptions.GetValueOrDefault(lang, ""),
|
||||
TechnicalDetails = _technicalDetails.GetValueOrDefault(lang, new())
|
||||
}
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
async Task HandleSubmit()
|
||||
{
|
||||
SyncTranslations();
|
||||
if (_editContext != null && _editContext.Validate())
|
||||
{
|
||||
await OnSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
void AddTechnicalDetail()
|
||||
{
|
||||
if (!_technicalDetails.ContainsKey(_activeLang))
|
||||
{
|
||||
_technicalDetails[_activeLang] = new List<TechnicalDetailVM>();
|
||||
}
|
||||
_technicalDetails[_activeLang].Add(new TechnicalDetailVM());
|
||||
}
|
||||
|
||||
void RemoveTechnicalDetail(TechnicalDetailVM item)
|
||||
{
|
||||
if (_technicalDetails.ContainsKey(_activeLang))
|
||||
{
|
||||
_technicalDetails[_activeLang].Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnSubmit()
|
||||
{
|
||||
Console.WriteLine("OnSubmit started");
|
||||
try
|
||||
{
|
||||
if (mediaComponent != null)
|
||||
{
|
||||
Console.WriteLine("Starting ProcessUploadsAsync");
|
||||
var uploadResult = await mediaComponent.ProcessUploadsAsync();
|
||||
Console.WriteLine("ProcessUploadsAsync finished successfully");
|
||||
category.Photos = uploadResult.FinalUrls.ToArray();
|
||||
}
|
||||
|
||||
SyncTranslations();
|
||||
|
||||
if (string.IsNullOrEmpty(Id))
|
||||
{
|
||||
Console.WriteLine("Creating category...");
|
||||
await categoryAPIConsumer.CreateCategory(category);
|
||||
ToastService.ShowToast("Category created successfully!", ToastLevel.Success);
|
||||
ResetForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Updating category...");
|
||||
await categoryAPIConsumer.UpdateCategory(category);
|
||||
ToastService.ShowToast("Category updated successfully!", ToastLevel.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Caught exception in OnSubmit: {ex.Message}");
|
||||
ToastService.ShowToast($"Error: {ex.Message}", ToastLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
void ResetForm()
|
||||
{
|
||||
category = new();
|
||||
_names.Clear();
|
||||
_descriptions.Clear();
|
||||
_technicalDetails.Clear();
|
||||
foreach (var lang in _languages.Keys)
|
||||
{
|
||||
_names[lang] = "";
|
||||
_descriptions[lang] = "";
|
||||
_technicalDetails[lang] = new();
|
||||
}
|
||||
SyncTranslations();
|
||||
_editContext = new EditContext(category);
|
||||
if (mediaComponent != null)
|
||||
{
|
||||
mediaComponent.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
@page "/admin/categories"
|
||||
@layout AdminLayout
|
||||
@using Generic
|
||||
|
||||
@inject CategoryAPIConsumer categoryAPIConsumer
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JS
|
||||
@inject NavigationManager nav
|
||||
|
||||
<div class="flex flex-col h-full w-full gap-5 p-6">
|
||||
|
||||
<div
|
||||
class="flex flex-row justify-between items-center bg-base-100 p-4 rounded-2xl border border-base-300 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-extrabold tracking-tight text-base-content">Categories</h1>
|
||||
<div class="join border border-base-300">
|
||||
<button class="join-item btn btn-sm @(_viewType == ViewType.Table ? "btn-primary" : "btn-ghost")"
|
||||
@onclick="() => SetView(ViewType.Table)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Table
|
||||
</button>
|
||||
<button class="join-item btn btn-sm @(_viewType == ViewType.Tree ? "btn-primary" : "btn-ghost")"
|
||||
@onclick="() => SetView(ViewType.Tree)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
Tree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-primary shadow-lg" href="/manage-category">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label"><span class="label-text">Search Categories</span></label>
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Search by name..." class="input input-bordered w-full pr-10"
|
||||
@bind="categoryFilter.Name" @bind:event="oninput" @onkeyup="HandleKeyUp" />
|
||||
<button type="button" class="absolute right-0 top-0 h-full px-3 flex items-center justify-center"
|
||||
@onclick="LoadCategories">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-50" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label"><span class="label-text">Is Bundle</span></label>
|
||||
<select class="select select-bordered w-full"
|
||||
value="@(categoryFilter.IsBundle?.ToString().ToLower() ?? "")" @onchange="OnIsBundleChanged">
|
||||
<option value="">All</option>
|
||||
<option value="true">Bundle</option>
|
||||
<option value="false">Category</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-ghost" @onclick="ClearFilters">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_viewType == ViewType.Table)
|
||||
{
|
||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">Pic</th>
|
||||
<th>Name</th>
|
||||
<th class="hidden md:table-cell">Description</th>
|
||||
<th>Type</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_loading)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
else if (categories == null || !categories.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-10 opacity-50">No categories found.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-12 h-12 bg-base-200">
|
||||
@if (category.Photos != null && category.Photos.Any())
|
||||
{
|
||||
<img src="@category.Photos.First()" alt="Category image"
|
||||
class="object-fill h-full w-full" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center justify-center h-full w-full opacity-30">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-medium text-primary">@category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</td>
|
||||
<td class="hidden md:table-cell max-w-xs truncate">@category.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</td>
|
||||
<td>
|
||||
@if (category.IsBundle)
|
||||
{
|
||||
<div class="badge badge-primary badge-sm">Bundle</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="badge badge-ghost badge-sm">Category</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-right flex justify-end gap-2">
|
||||
<a class="btn btn-ghost btn-xs btn-primary" href="@($"/manage-category/{category.Id}")">
|
||||
Edit
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-xs btn-error" @onclick="@(() => Remove(category))">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<PaginationComp PaginationModel="@pagination" OnSearch="HandlePageChange" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto pb-4 custom-scrollbar">
|
||||
<div class="inline-block min-w-max space-y-4">
|
||||
@foreach (var root in _categoryTree)
|
||||
{
|
||||
<CategoryTreeItem Category="root" OnEdit="NavigateToEdit" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
CategoryFilter categoryFilter = new();
|
||||
Pagination pagination = new() { PageSize = 10, CurrentPage = 1 };
|
||||
bool _loading = false;
|
||||
|
||||
enum ViewType { Table, Tree }
|
||||
ViewType _viewType = ViewType.Table;
|
||||
|
||||
List<CategoryVM> categories = new();
|
||||
List<CategoryVM> _categoryTree = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
if (_viewType == ViewType.Table) await LoadCategories();
|
||||
else await LoadTree();
|
||||
}
|
||||
|
||||
async Task SetView(ViewType type)
|
||||
{
|
||||
_viewType = type;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
async Task LoadCategories()
|
||||
{
|
||||
_loading = true;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await categoryAPIConsumer.FetchCategory(categoryFilter, pagination);
|
||||
categories = result.Item1 ?? new List<CategoryVM>();
|
||||
pagination.ItemsCount = result.Item2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading categories: {ex.Message}");
|
||||
categories = new List<CategoryVM>();
|
||||
ToastService.ShowToast("Failed to load categories", ToastLevel.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadTree()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_categoryTree = await categoryAPIConsumer.FetchCategoryTree();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading tree: {ex.Message}");
|
||||
ToastService.ShowToast("Failed to load category tree", ToastLevel.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
void NavigateToEdit(CategoryVM category)
|
||||
{
|
||||
nav.NavigateTo($"/manage-category/{category.Id}");
|
||||
}
|
||||
|
||||
async Task HandlePageChange(int page)
|
||||
{
|
||||
pagination.CurrentPage = page;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
async Task HandleKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
pagination.CurrentPage = 1;
|
||||
await LoadCategories();
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnIsBundleChanged(ChangeEventArgs e)
|
||||
{
|
||||
var val = e.Value?.ToString();
|
||||
if (string.IsNullOrEmpty(val)) categoryFilter.IsBundle = null;
|
||||
else categoryFilter.IsBundle = bool.Parse(val);
|
||||
|
||||
pagination.CurrentPage = 1;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
async Task ClearFilters()
|
||||
{
|
||||
categoryFilter = new();
|
||||
pagination.CurrentPage = 1;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
async Task Remove(CategoryVM category)
|
||||
{
|
||||
if (category.Id == null) return;
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Are you sure you want to delete '{category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty}'?");
|
||||
|
||||
if (confirmed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var isSuccess = await categoryAPIConsumer.DeleteCategory(category.Id.Value.ToString());
|
||||
if (isSuccess)
|
||||
{
|
||||
ToastService.ShowToast("Category deleted successfully", ToastLevel.Success);
|
||||
await LoadCategories(); // Reload to refresh list and pagination
|
||||
}
|
||||
else
|
||||
{
|
||||
ToastService.ShowToast("Failed to delete category", ToastLevel.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToastService.ShowToast($"Error: {ex.Message}", ToastLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user