Files
ECommerce.RCL/Components/CategoryTreeSelect.razor
T

288 lines
12 KiB
Plaintext

@using Commerce.Contracts.DTOs
@inject CategoryAPIConsumer CategoryService
<div class="relative w-full">
<button type="button"
class="select select-bordered w-full flex items-center justify-between h-auto py-3 bg-base-100 hover:bg-base-200 transition-colors"
@onclick="ToggleDropdown">
<span class="truncate">@(SelectedCategory?.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty) ??
Placeholder)</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-50 shrink-0" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
@if (_isOpen)
{
<!-- Backdrop to close dropdown -->
<div class="fixed inset-0 z-40 bg-transparent" @onclick="ToggleDropdown"></div>
<div
class="absolute z-50 mt-2 w-full lg:min-w-[400px] bg-base-100 shadow-2xl rounded-2xl border border-base-300 p-4 animate-in fade-in zoom-in duration-200 origin-top">
<!-- Tabs Navigation (Breadcrumbs style) -->
<div class="flex flex-wrap items-center gap-1 mb-4 bg-base-200/50 p-2 rounded-xl">
<button type="button"
class="btn btn-ghost btn-xs normal-case @(_activeLevel == 0 ? "text-primary font-bold bg-primary/10" : "text-base-content/60")"
@onclick="() => SetLevel(0)">
Root
</button>
@for (int i = 0; i < _path.Count; i++)
{
var index = i;
var step = _path[i];
<div class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-base-content/30" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
<button type="button"
class="btn btn-ghost btn-xs normal-case @(_activeLevel == index + 1 ? "text-primary font-bold bg-primary/10" : "text-base-content/60")"
@onclick="() => SetLevel(index + 1)">
@step.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
</button>
</div>
}
</div>
<!-- Categories List -->
<div class="max-h-72 overflow-y-auto custom-scrollbar pr-1">
@if (_loading)
{
<div class="flex flex-col items-center justify-center p-8 gap-3">
<span class="loading loading-spinner loading-md text-primary"></span>
<span class="text-sm text-gray-500">Loading categories...</span>
</div>
}
else if (!string.IsNullOrEmpty(_error))
{
<div class="alert alert-error shadow-sm text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>@_error</span>
<button type="button" class="btn btn-xs btn-ghost" @onclick="LoadTree">Retry</button>
</div>
}
else if (_activeCategories == null || !_activeCategories.Any())
{
<div class="py-8 text-center text-gray-500 italic">No categories found in this level</div>
}
else
{
<div class="grid grid-cols-1 gap-1">
@foreach (var cat in _activeCategories)
{
<div
class="group flex items-center justify-between p-2 rounded-xl hover:bg-base-200 transition-all border border-transparent @(cat.Id == Value ? "bg-primary/10 border-primary/20" : "")">
<div class="flex-1 flex items-center gap-3 cursor-pointer py-1" @onclick="() => SelectValue(cat)">
<div
class="w-1.5 h-6 rounded-full @(cat.Id == Value ? "bg-primary" : "bg-transparent") transition-colors">
</div>
<div class="flex flex-col">
<span
class="font-medium @(cat.Id == Value ? "text-primary font-bold" : "text-base-content")">@cat.Translations?.FirstOrDefault()?.Info?.Name
?? string.Empty)</span>
@if (cat.Id == Value)
{
<span class="text-[10px] text-primary uppercase font-bold tracking-wider">Selected</span>
}
</div>
</div>
@if (cat.ChildCategories?.Any() == true)
{
<button type="button"
class="btn btn-ghost btn-circle btn-sm hover:bg-primary/20 text-primary transition-colors"
@onclick="() => DrillDown(cat)" title="View subcategories">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
</button>
}
</div>
}
</div>
}
</div>
<!-- Footer -->
<div class="mt-4 pt-4 border-t border-base-200 flex justify-between items-center text-xs text-gray-400">
<span>Total: @(_tree.Count) roots</span>
<div class="flex gap-2">
<button type="button" class="btn btn-ghost btn-xs text-error" @onclick="ClearSelection">Clear</button>
<button type="button" class="btn btn-primary btn-xs" @onclick="ToggleDropdown">Done</button>
</div>
</div>
</div>
}
</div>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e5e7eb;
border-radius: 10px;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #374151;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
@code {
[Parameter] public Guid? Value { get; set; }
[Parameter] public EventCallback<Guid?> ValueChanged { get; set; }
[Parameter] public string Placeholder { get; set; } = "Select Category";
[Parameter] public List<Guid>? DisallowedIds { get; set; }
[Parameter] public bool ShowBundles { get; set; } = true;
private List<CategoryVM> _tree = new();
private List<CategoryVM> _path = new();
private int _activeLevel = 0;
private bool _isOpen = false;
private bool _loading = true;
private string? _error;
private List<CategoryVM> _activeCategories => GetCategoriesForLevel(_activeLevel);
private CategoryVM? SelectedCategory;
protected override async Task OnInitializedAsync()
{
await LoadTree();
}
protected override async Task OnParametersSetAsync()
{
await UpdateSelectedCategory();
}
private async Task LoadTree()
{
_loading = true;
_error = null;
try
{
_tree = await CategoryService.FetchCategoryTree();
await UpdateSelectedCategory();
}
catch (Exception ex)
{
_error = "Failed to load categories hierarchy.";
Console.WriteLine($"Error loading category tree: {ex.Message}");
}
finally
{
_loading = false;
}
}
private async Task UpdateSelectedCategory()
{
if (Value.HasValue)
{
SelectedCategory = FindInCategoryTree(_tree, Value.Value);
}
else
{
SelectedCategory = null;
}
}
private CategoryVM? FindInCategoryTree(List<CategoryVM> categories, Guid id)
{
foreach (var cat in categories)
{
if (cat.Id == id) return cat;
if (cat.ChildCategories != null)
{
var found = FindInCategoryTree(cat.ChildCategories.ToList(), id);
if (found != null) return found;
}
}
return null;
}
private List<CategoryVM> GetCategoriesForLevel(int level)
{
var cats = level == 0 ? _tree : _path[level - 1].ChildCategories?.ToList();
if (cats != null)
{
if (DisallowedIds != null)
{
cats = cats.Where(x => !DisallowedIds.Contains(x.Id ?? Guid.Empty)).ToList();
}
if (!ShowBundles)
{
cats = cats.Where(x => !x.IsBundle).ToList();
}
}
return cats ?? new List<CategoryVM>();
}
private void ToggleDropdown() => _isOpen = !_isOpen;
private void SetLevel(int level)
{
_activeLevel = level;
if (level <= _path.Count)
{
_path = _path.Take(level).ToList();
}
}
private async Task SelectValue(CategoryVM category)
{
Value = category.Id;
await ValueChanged.InvokeAsync(Value);
SelectedCategory = category;
StateHasChanged();
}
private void DrillDown(CategoryVM category)
{
if (category.ChildCategories?.Any() == true)
{
_path.Add(category);
_activeLevel = _path.Count;
}
}
private async Task ClearSelection()
{
Value = null;
await ValueChanged.InvokeAsync(Value);
SelectedCategory = null;
_path.Clear();
_activeLevel = 0;
}
}