Initial commit: ECommerce.Module
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Snackbar.Add("Category created successfully!", Severity.Success);
|
||||
ResetForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Updating category...");
|
||||
await categoryAPIConsumer.UpdateCategory(category);
|
||||
Snackbar.Add("Category updated successfully!", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Caught exception in OnSubmit: {ex.Message}");
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.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>();
|
||||
Snackbar.Add("Failed to load categories", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadTree()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_categoryTree = await categoryAPIConsumer.FetchCategoryTree();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading tree: {ex.Message}");
|
||||
Snackbar.Add("Failed to load category tree", Severity.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)
|
||||
{
|
||||
Snackbar.Add("Category deleted successfully", Severity.Success);
|
||||
await LoadCategories(); // Reload to refresh list and pagination
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to delete category", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@page "/"
|
||||
@inject ProductAPIConsumer productService
|
||||
@inject CategoryAPIConsumer categoryService
|
||||
|
||||
|
||||
<!-- Carousel Section -->
|
||||
<div class="h-screen w-full">
|
||||
@if (CarouselData != null && CarouselData.Any())
|
||||
{
|
||||
<Carousal Data="CarouselData"></Carousal>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Random Products Section -->
|
||||
|
||||
<section class="flex flex-col container px-6 py-20 mx-auto gap-20 dark:bg-gray-900">
|
||||
|
||||
<h1 class=" font-semibold text-center capitalize dark:text-white text-white text-6xl">@Loc["Production_Lines"]
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 flex-wrap overflow-x-scroll">
|
||||
@foreach (var bundle in RandomBundles)
|
||||
{
|
||||
<BundleComp Bundle="bundle"></BundleComp>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row w-full mx-auto ">
|
||||
<a href="/shop?IsBundle=true" class="btn btn-primary mx-auto">@Loc["See_More"]</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<section class=" flex flex-col container px-6 py-20 mx-auto gap-20 dark:bg-gray-900 ">
|
||||
|
||||
<h1 class=" font-semibold text-center capitalize dark:text-white text-white text-6xl">@Loc["Our_Categories"]
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3 xl:gap-12 lg:grid-cols-4">
|
||||
@foreach (var category in RandomCategories.Take(4))
|
||||
{
|
||||
<CategoryComp Category="category"></CategoryComp>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row w-full mx-auto ">
|
||||
<a href="/shop/categories" class="btn btn-primary mx-auto">@Loc["See_More"]</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<section class="flex flex-col gap-1">
|
||||
<h2 class="text-center text-5xl text-white ">@Loc["Our_Products"]</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-between gap-8 flex-wrap p-7 md:p-14 lg:p-20">
|
||||
@foreach (var product in RandomProducts.Take(3))
|
||||
{
|
||||
<ProductComp4 Product="product"></ProductComp4>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row w-full text-center ">
|
||||
<a href="/shop" class="btn btn-primary mx-auto">@Loc["See_More"]</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
List<ProductVM> RandomProducts = new();
|
||||
List<ProductVM> RandomBundles = new();
|
||||
List<CategoryVM> RandomCategories = new();
|
||||
List<(string Text, string ImageUrl)> CarouselData = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Populate Carousel with Data
|
||||
CarouselData = new List<(string Text, string ImageUrl)>
|
||||
{
|
||||
(Loc["Welcome_to_Our_Shop"], "https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp"),
|
||||
(Loc["Best_Quality_Products"], "https://img.daisyui.com/images/stock/photo-1559181567-c3190ca9959b.webp"),
|
||||
(Loc["Amazing_Deals"], "https://img.daisyui.com/images/stock/photo-1601004890684-d8cbf643f5f2.webp")
|
||||
};
|
||||
|
||||
RandomProducts = (await productService.FetchProduct(new ProductFilter { Random = true }, new Pagination()
|
||||
{
|
||||
PageSize = 3,
|
||||
CurrentPage = 1
|
||||
})).Item1;
|
||||
|
||||
// Fetch real bundles
|
||||
var bundleResult = (await productService.FetchProduct(new ProductFilter { IsBundle = true, Random = true }, new
|
||||
Pagination
|
||||
{
|
||||
PageSize = 4,
|
||||
CurrentPage = 1
|
||||
})).Item1;
|
||||
RandomBundles = bundleResult;
|
||||
|
||||
RandomCategories = (await categoryService.FetchCategory(new CategoryFilter { Random = true }, new Pagination()
|
||||
{
|
||||
CurrentPage = 1,
|
||||
PageSize = 4
|
||||
})).Item1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
@page "/manage-product/{id?}"
|
||||
@layout AdminLayout
|
||||
@using Generic
|
||||
@using Commerce.Contracts.DTOs
|
||||
@inject ProductAPIConsumer productAPIConsumer
|
||||
@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) ? "Add New Product" : "Edit Product")
|
||||
</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: Generic Properties -->
|
||||
<div class="lg:col-span-1 space-y-8">
|
||||
<!-- Configuration Card -->
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="font-bold text-lg">Product Settings</h4>
|
||||
</div>
|
||||
|
||||
<!-- Code Name -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Code Name</span></label>
|
||||
<InputText @bind-value="product.CodeName"
|
||||
class="input input-bordered w-full focus:input-primary transition-all"
|
||||
placeholder="PROD-XXX" />
|
||||
<ValidationMessage For="@(() => product.CodeName)" class="text-error text-xs mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Price & Discount -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Price ($)</span></label>
|
||||
<InputNumber @bind-value="product.Price"
|
||||
class="input input-bordered w-full focus:input-primary transition-all" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Discount (%)</span></label>
|
||||
<InputNumber @bind-value="product.Discount"
|
||||
class="input input-bordered w-full focus:input-primary transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Category</span></label>
|
||||
<CategoryTreeSelect @bind-value="product.CategoryId" Placeholder="Select Category" />
|
||||
</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 Product?</span>
|
||||
<InputCheckbox @bind-value="product.IsBundle" class="toggle toggle-primary" />
|
||||
</label>
|
||||
<p class="text-[10px] text-base-content/50 mt-1 uppercase tracking-widest font-bold">Enable
|
||||
to compose from other products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- MAIN: Localized Content -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- Localized Content Card -->
|
||||
<div class="card bg-base-100 shadow-2xl border border-base-200 !overflow-visible">
|
||||
<div class="p-8 space-y-10">
|
||||
<!-- Product 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">
|
||||
Product Name
|
||||
<div class="badge badge-primary badge-sm">@(_languages[_activeLang])</div>
|
||||
</h4>
|
||||
</div>
|
||||
<MultiLangInput @bind="_names" ActiveCulture="@_activeLang" HideTabs="true"
|
||||
Placeholder="Enter product 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="Write a compelling product description..." />
|
||||
<ValidationMessage For="@(() => _descriptions)" class="text-error text-sm mt-1" />
|
||||
</div>
|
||||
|
||||
<!-- Technical Details / 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">
|
||||
Product 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. Weight" />
|
||||
</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. 1.2kg" />
|
||||
</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">Product Media</h4>
|
||||
</div>
|
||||
<Generic.Media.Components.Media @ref="mediaComponent" InitialUrls="@(product.Photos?.ToList() ?? new List<string>())"
|
||||
SingleUploadMode="false" MaxItems="10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bundle Composition Section (Conditional) -->
|
||||
@if (product.IsBundle)
|
||||
{
|
||||
<div class="card bg-base-100 shadow-xl border border-primary/20 !overflow-visible">
|
||||
<div class="bg-primary/5 p-6 border-b border-primary/10">
|
||||
<h4 class="text-xl font-bold text-primary flex items-center gap-3">
|
||||
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
Bundle Composition
|
||||
</h4>
|
||||
</div>
|
||||
<div class="p-8 space-y-8">
|
||||
<!-- Selected Products -->
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm font-bold text-base-content/40 uppercase tracking-widest">Included
|
||||
Products (@(product.ChildProducts?.Count ?? 0))</p>
|
||||
@if (product.ChildProducts == null || !product.ChildProducts.Any())
|
||||
{
|
||||
<div
|
||||
class="border-2 border-dashed border-base-300 rounded-3xl p-12 text-center text-base-content/40 flex flex-col items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 opacity-20" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<span class="italic font-medium">No products added. Search and add below.</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@foreach (var item in product.ChildProducts)
|
||||
{
|
||||
<div
|
||||
class="flex items-center justify-between bg-base-100 p-4 rounded-2xl shadow-sm border border-base-200 group hover:border-primary/50 hover:shadow-lg transition-all">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-base-200 flex-shrink-0 overflow-hidden ring-1 ring-base-300">
|
||||
@if (item.Photos?.Any() == true)
|
||||
{
|
||||
<img src="@item.Photos[0]" class="w-full h-full object-cover" />
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="font-bold text-sm leading-tight">@item.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</span>
|
||||
<span
|
||||
class="text-xs font-bold text-primary">@item.Price?.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" @onclick="() => RemoveFromBundle(item)"
|
||||
class="btn btn-ghost btn-circle btn-sm text-error opacity-0 group-hover:opacity-100 transition-all">
|
||||
<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>
|
||||
|
||||
<!-- Finder -->
|
||||
<div class="space-y-4 pt-6 border-t border-base-200">
|
||||
<p class="text-sm font-bold text-base-content/40 uppercase tracking-widest">Find & Add
|
||||
Products</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input type="text" @bind="searchFilter.Name" @bind:event="oninput"
|
||||
@onkeyup="HandleSearchKeyUp" placeholder="Search by name..."
|
||||
class="input input-bordered w-full pr-12 rounded-2xl focus:input-primary transition-all" />
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
|
||||
@if (_isSearching)
|
||||
{
|
||||
<span class="loading loading-spinner loading-xs text-primary"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-20" 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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:w-80">
|
||||
<CategoryTreeSelect @bind-value="searchFilter.CategoryId"
|
||||
@bind-value:after="SearchProducts" ShowBundles="false"
|
||||
Placeholder="All Categories" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_searchResults != null && _searchResults.Any())
|
||||
{
|
||||
<div
|
||||
class="bg-base-200/50 rounded-3xl border border-base-300 p-2 max-h-72 overflow-y-auto custom-grid-scrollbar shadow-inner mt-4">
|
||||
@foreach (var res in _searchResults)
|
||||
{
|
||||
<div class="flex items-center justify-between p-3 rounded-2xl hover:bg-base-100 transition-all cursor-pointer group"
|
||||
@onclick="() => AddToBundle(res)">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-base-300 flex-shrink-0 border border-base-300">
|
||||
@if (res.Photos?.Any() == true)
|
||||
{
|
||||
<img src="@res.Photos[0]" class="w-full h-full object-cover rounded-xl" />
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm font-bold leading-none mb-1">@res.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</span>
|
||||
<span class="text-xs opacity-50">@res.Price?.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-circle btn-sm btn-ghost group-hover:bg-primary group-hover:text-primary-content transition-all">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</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 Product Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
|
||||
ProductVM product = new();
|
||||
private EditContext? _editContext;
|
||||
Media? mediaComponent;
|
||||
|
||||
// Search state
|
||||
ProductFilter searchFilter = new() { IsBundle = false };
|
||||
Dictionary<string, string> _names = new();
|
||||
Dictionary<string, string> _descriptions = new();
|
||||
Dictionary<string, List<TechnicalDetailVM>> _technicalDetails = new();
|
||||
|
||||
List<ProductVM> _searchResults = new();
|
||||
bool _isSearching = false;
|
||||
|
||||
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))
|
||||
{
|
||||
product = await productAPIConsumer.GetProduct(Id);
|
||||
}
|
||||
|
||||
// Map from Translations to local dictionaries
|
||||
foreach (var t in product.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();
|
||||
}
|
||||
|
||||
product.Photos ??= Array.Empty<string>();
|
||||
product.ChildProducts ??= new List<ProductVM>();
|
||||
|
||||
SyncTranslations();
|
||||
_editContext = new EditContext(product);
|
||||
}
|
||||
|
||||
void SyncTranslations()
|
||||
{
|
||||
product.Translations = _languages.Keys.Select(lang => new ProductTranslationVM
|
||||
{
|
||||
Language = lang,
|
||||
Info = new ProductInfoVM
|
||||
{
|
||||
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 HandleSearchKeyUp()
|
||||
{
|
||||
await SearchProducts();
|
||||
}
|
||||
|
||||
async Task SearchProducts()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchFilter.Name) && !searchFilter.CategoryId.HasValue)
|
||||
{
|
||||
_searchResults.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
_isSearching = true;
|
||||
try
|
||||
{
|
||||
var (results, _) = await productAPIConsumer.FetchProduct(searchFilter, new Pagination { PageSize = 10, CurrentPage = 1 });
|
||||
// Filter out the current product itself to avoid circular bundles if it already exists
|
||||
_searchResults = results.Where(x => x.Id != product.Id).ToList();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
void AddToBundle(ProductVM item)
|
||||
{
|
||||
if (product.ChildProducts == null) product.ChildProducts = new List<ProductVM>();
|
||||
if (!product.ChildProducts.Any(x => x.Id == item.Id))
|
||||
{
|
||||
product.ChildProducts.Add(item);
|
||||
_searchResults.Clear();
|
||||
searchFilter.Name = "";
|
||||
}
|
||||
}
|
||||
|
||||
void RemoveFromBundle(ProductVM item)
|
||||
{
|
||||
if (product.ChildProducts != null)
|
||||
{
|
||||
product.ChildProducts.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");
|
||||
product.Photos = uploadResult.FinalUrls.ToArray();
|
||||
}
|
||||
|
||||
SyncTranslations();
|
||||
|
||||
if (string.IsNullOrEmpty(Id))
|
||||
{
|
||||
Console.WriteLine("Creating product...");
|
||||
await productAPIConsumer.CreateProduct(product);
|
||||
Snackbar.Add("Product added successfully!", Severity.Success);
|
||||
ResetForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Updating product...");
|
||||
await productAPIConsumer.UpdateProduct(product);
|
||||
Snackbar.Add("Product updated successfully!", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Caught exception in OnSubmit: {ex.Message}");
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
void ResetForm()
|
||||
{
|
||||
product = new();
|
||||
_names.Clear();
|
||||
_descriptions.Clear();
|
||||
_technicalDetails.Clear();
|
||||
foreach (var lang in _languages.Keys)
|
||||
{
|
||||
_names[lang] = "";
|
||||
_descriptions[lang] = "";
|
||||
_technicalDetails[lang] = new();
|
||||
}
|
||||
SyncTranslations();
|
||||
_editContext = new EditContext(product);
|
||||
if (mediaComponent != null)
|
||||
{
|
||||
mediaComponent.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
@page "/admin/products"
|
||||
@layout AdminLayout
|
||||
@using Generic
|
||||
|
||||
@inject ProductAPIConsumer productAPIConsumer
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="flex flex-col h-full w-full gap-5 p-6">
|
||||
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Manage Products</h1>
|
||||
<a class="btn btn-primary" href="/manage-product">Add Product</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 Products</span></label>
|
||||
<div class="relative">
|
||||
<input type="text" placeholder="Search by name..." class="input input-bordered w-full pr-10"
|
||||
@bind="productFilter.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="LoadProducts">
|
||||
<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>
|
||||
|
||||
<button class="btn btn-ghost" @onclick="ClearFilters">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>Price</th>
|
||||
<th class="hidden md:table-cell">Description</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 (products == null || !products.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-10 opacity-50">No products found.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var product in products)
|
||||
{
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="avatar">
|
||||
<div class="mask mask-squircle w-12 h-12 bg-base-200">
|
||||
@if (product.Photos != null && product.Photos.Any())
|
||||
{
|
||||
<img src="@product.Photos.First()" alt="Product 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">@product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</td>
|
||||
<td>@product.Price?.ToString("C")</td>
|
||||
<td class="hidden md:table-cell max-w-xs truncate">@product.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</td>
|
||||
<td class="text-right flex justify-end gap-2">
|
||||
<a class="btn btn-ghost btn-xs btn-primary" href="@($"/manage-product/{product.Id}")">
|
||||
Edit
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-xs btn-error" @onclick="@(() => Remove(product))">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<PaginationComp PaginationModel="@pagination" OnSearch="HandlePageChange" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
ProductFilter productFilter = new();
|
||||
Pagination pagination = new() { PageSize = 10, CurrentPage = 1 };
|
||||
bool _loading = false;
|
||||
|
||||
List<ProductVM> products = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadProducts();
|
||||
}
|
||||
|
||||
async Task LoadProducts()
|
||||
{
|
||||
_loading = true;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await productAPIConsumer.FetchProduct(productFilter, pagination);
|
||||
products = result.Item1 ?? new List<ProductVM>();
|
||||
pagination.ItemsCount = result.Item2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error loading products: {ex.Message}");
|
||||
products = new List<ProductVM>();
|
||||
Snackbar.Add("Failed to load products", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task HandlePageChange(int page)
|
||||
{
|
||||
pagination.CurrentPage = page;
|
||||
await LoadProducts();
|
||||
}
|
||||
|
||||
async Task HandleKeyUp(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
pagination.CurrentPage = 1;
|
||||
await LoadProducts();
|
||||
}
|
||||
}
|
||||
|
||||
async Task ClearFilters()
|
||||
{
|
||||
productFilter = new();
|
||||
pagination.CurrentPage = 1;
|
||||
await LoadProducts();
|
||||
}
|
||||
|
||||
async Task Remove(ProductVM product)
|
||||
{
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Are you sure you want to delete '{product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty}'?");
|
||||
|
||||
if (confirmed)
|
||||
{
|
||||
if (product.Id == null) return;
|
||||
try
|
||||
{
|
||||
var isSuccess = await productAPIConsumer.DeleteProduct(product.Id.Value.ToString());
|
||||
if (isSuccess)
|
||||
{
|
||||
Snackbar.Add("Product deleted successfully", Severity.Success);
|
||||
await LoadProducts(); // Reload to refresh list and pagination
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to delete product", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
<div class="flex items-end overflow-hidden bg-cover rounded-lg h-96"
|
||||
style="background-image:url('@(Bundle.Photos?.Any() == true ? Bundle.Photos[0] : "https://via.placeholder.com/400x300?text=No+Image")')">
|
||||
<div class="w-full px-8 py-4 overflow-hidden rounded-b-lg backdrop-blur-sm bg-white/60 dark:bg-gray-800/60">
|
||||
<h2 class="mt-4 text-xl font-semibold text-gray-800 capitalize dark:text-white">@Bundle.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
<p class="mt-2 text-lg tracking-wider text-blue-500 uppercase dark:text-blue-400 ">@Bundle.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</p>
|
||||
</div>
|
||||
</div>
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public ProductVM Bundle { get; set; }
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@inject NavigationManager nav
|
||||
@using ECommerceModule.Services
|
||||
@inject ShopStateService ShopStateService
|
||||
|
||||
<button @onclick="NavTo"
|
||||
class="relative grid h-[35rem] max-w-lg flex-col items-end justify-center overflow-hidden rounded-lg bg-white">
|
||||
<div style="background-image: url('@Category.Photos[0]');"
|
||||
class="absolute inset-0 m-0 h-full w-full overflow-hidden rounded-none bg-transparent bg-cover bg-center">
|
||||
<div class="to-bg-black-10 absolute inset-0 h-full w-full bg-gradient-to-t from-black/80 via-black/50"></div>
|
||||
</div>
|
||||
<div class="relative text-center p-6 px-6 py-14 md:px-12">
|
||||
<h2 class="mb-6 text-3xl font-medium text-white">
|
||||
@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public CategoryVM Category { get; set; }
|
||||
|
||||
async Task NavTo()
|
||||
{
|
||||
await ShopStateService.SetCategoryId(Category.Id);
|
||||
if (Category.IsBundle)
|
||||
nav.NavigateTo("/bundles");
|
||||
else
|
||||
nav.NavigateTo("/products");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@*
|
||||
|
||||
|
||||
<div class="flex items-end overflow-hidden bg-cover rounded-lg h-96"
|
||||
style="background-image:url('@Category.PhotoPath')">
|
||||
<div class="w-full px-8 py-4 overflow-hidden rounded-b-lg backdrop-blur-sm bg-white/60 dark:bg-gray-800/60">
|
||||
<h2 class="mt-4 text-xl font-semibold text-gray-800 capitalize dark:text-white">@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
<p class="mt-2 text-lg tracking-wider text-blue-500 uppercase dark:text-blue-400 ">@Category.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</p>
|
||||
</div>
|
||||
</div>
|
||||
@code{
|
||||
|
||||
[Parameter]
|
||||
public Category Category {get; set;}
|
||||
|
||||
|
||||
href="@(Category.IsBundle ? $"bundles?category={Category.Id}&pagesize=12¤tpage=1" :
|
||||
$"products?category={Category.Id}&pagesize=12¤tpage=1")"
|
||||
|
||||
} *@
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="w-80 h-20">
|
||||
<div class="flex flex-col md:flex-row flex-wrap md:flex-nowrap bg-white shadow-md rounded-2xl p-2">
|
||||
<img src="@Category.Photos[0]" alt="Just a flower" class=" w-20 object-cover mx-auto h-20 rounded-xl">
|
||||
<div class="flex flex-col justify-center w-full px-2 py-1">
|
||||
<div class="flex justify-between items-center ">
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-xs md:text-sm font-medium">@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
</div>
|
||||
@* <svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-0 w-0 md:w-5 md:h-5 @favoriteClass cursor-pointer" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
||||
</svg> *@
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row mx-auto md:mx-0 gap-2 md text-sm text-gray-400">
|
||||
<div class="flex items-center mr-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-500 mr-1" viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z">
|
||||
</path>
|
||||
</svg>
|
||||
<p class="font-normal text-sm md:text-md">@Loc["4.5"]</p>
|
||||
</div>
|
||||
@* <div class="flex items-center mx-auto md:mx-0 font-medium text-gray-900 ">
|
||||
$@ProductModel.Price
|
||||
</div> *@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public CategoryVM Category { get; set; }
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@* @inject IStringLocalizer<App> Loc *@
|
||||
|
||||
|
||||
<div class="flex items-end overflow-hidden bg-cover rounded-lg h-96"
|
||||
style="background-image:url('@Category.Photos[0]')">
|
||||
<div class="w-full px-8 py-4 overflow-hidden rounded-b-lg backdrop-blur-sm bg-white/60 dark:bg-gray-800/60">
|
||||
<h2 class="mt-4 text-xl font-semibold text-gray-800 capitalize dark:text-white">@Category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
<p class="mt-2 text-lg tracking-wider text-blue-500 uppercase dark:text-blue-400 ">@Loc["Ui_kit"]</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public CategoryVM Category { get; set; }
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@using ECommerceModule.Services
|
||||
@inject ShopStateService ShopStateService
|
||||
|
||||
<div class="">
|
||||
<div class="flex flex-col md:flex-row flex-wrap md:flex-nowrap bg-white shadow-md rounded-2xl p-2">
|
||||
<img
|
||||
src="@(ProductModel.Photos?.Any() == true ? ProductModel.Photos[0] : "https://via.placeholder.com/400x300?text=No+Image")"
|
||||
alt="@ProductModel.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)" class="w-20 object-cover mx-auto h-20 rounded-xl">
|
||||
<div class="flex flex-col justify-center w-full px-2 py-1">
|
||||
<div class="flex justify-between items-center ">
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-xs md:text-sm font-medium">@ProductModel.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
</div>
|
||||
<svg @onclick="ToggleFavorite" xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-0 w-0 md:w-5 md:h-5 @favoriteClass cursor-pointer" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row mx-auto md:mx-0 gap-2 md text-sm text-gray-400">
|
||||
<div class="flex items-center mr-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-500 mr-1" viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z">
|
||||
</path>
|
||||
</svg>
|
||||
<p class="font-normal text-sm md:text-md">@Loc["4.5"]</p>
|
||||
</div>
|
||||
@* <div class="flex items-center mx-auto md:mx-0 font-medium text-gray-900 ">
|
||||
$@ProductModel.Price
|
||||
</div> *@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
[Parameter]
|
||||
public ProductVM ProductModel { get; set; }
|
||||
string favoriteClass = "text-white";
|
||||
|
||||
protected async override Task OnInitializedAsync()
|
||||
{
|
||||
await ShopStateService.InitializeAsync();
|
||||
UpdateFavoriteState();
|
||||
ShopStateService.OnChange += StateChanged;
|
||||
}
|
||||
|
||||
void StateChanged()
|
||||
{
|
||||
UpdateFavoriteState();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void UpdateFavoriteState()
|
||||
{
|
||||
if (ShopStateService.IsFavorite(ProductModel.Id.ToString()))
|
||||
favoriteClass = "text-pink-500 fill-current";
|
||||
else
|
||||
favoriteClass = "text-white";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ShopStateService.OnChange -= StateChanged;
|
||||
}
|
||||
|
||||
async Task ToggleFavorite()
|
||||
{
|
||||
await ShopStateService.ToggleFavorite(ProductModel.Id.ToString());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<div
|
||||
class="group flex flex-col h-full bg-white border border-gray-200 shadow-sm rounded-xl dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70">
|
||||
<div class="h-52 flex flex-col justify-center items-center bg-amber-500 rounded-t-xl">
|
||||
<svg class="size-28" width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="56" rx="10" fill="white" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M23.7326 11.968C21.9637 11.9693 20.5321 13.4049 20.5334 15.1738C20.5321 16.9427 21.965 18.3782 23.7339 18.3795H26.9345V15.1751C26.9358 13.4062 25.5029 11.9706 23.7326 11.968C23.7339 11.968 23.7339 11.968 23.7326 11.968M23.7326 20.5184H15.2005C13.4316 20.5197 11.9987 21.9553 12 23.7242C11.9974 25.4931 13.4303 26.9286 15.1992 26.9312H23.7326C25.5016 26.9299 26.9345 25.4944 26.9332 23.7255C26.9345 21.9553 25.5016 20.5197 23.7326 20.5184V20.5184Z"
|
||||
fill="#36C5F0" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M44.0001 23.7242C44.0014 21.9553 42.5684 20.5197 40.7995 20.5184C39.0306 20.5197 37.5977 21.9553 37.599 23.7242V26.9312H40.7995C42.5684 26.9299 44.0014 25.4944 44.0001 23.7242ZM35.4666 23.7242V15.1738C35.4679 13.4062 34.0363 11.9706 32.2674 11.968C30.4985 11.9693 29.0656 13.4049 29.0669 15.1738V23.7242C29.0643 25.4931 30.4972 26.9286 32.2661 26.9312C34.035 26.9299 35.4679 25.4944 35.4666 23.7242Z"
|
||||
fill="#2EB67D" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M32.2661 44.0322C34.035 44.0309 35.4679 42.5953 35.4666 40.8264C35.4679 39.0575 34.035 37.622 32.2661 37.6207H29.0656V40.8264C29.0642 42.594 30.4972 44.0295 32.2661 44.0322ZM32.2661 35.4804H40.7995C42.5684 35.4791 44.0013 34.0436 44 32.2747C44.0026 30.5058 42.5697 29.0702 40.8008 29.0676H32.2674C30.4985 29.0689 29.0656 30.5045 29.0669 32.2734C29.0656 34.0436 30.4972 35.4791 32.2661 35.4804V35.4804Z"
|
||||
fill="#ECB22E" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 32.2746C11.9987 34.0435 13.4316 35.479 15.2005 35.4804C16.9694 35.479 18.4024 34.0435 18.401 32.2746V29.0688H15.2005C13.4316 29.0702 11.9987 30.5057 12 32.2746ZM20.5334 32.2746V40.825C20.5308 42.5939 21.9637 44.0295 23.7326 44.0321C25.5016 44.0308 26.9345 42.5952 26.9332 40.8263V32.2772C26.9358 30.5083 25.5029 29.0728 23.7339 29.0702C21.9637 29.0702 20.5321 30.5057 20.5334 32.2746C20.5334 32.2759 20.5334 32.2746 20.5334 32.2746Z"
|
||||
fill="#E01E5A" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="p-4 md:p-6">
|
||||
<span class="block mb-1 text-xs font-semibold uppercase text-amber-500">@Loc["Slack_API"]</span>
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-neutral-300 dark:hover:text-white">@Loc["Slack"]</h3>
|
||||
<p class="mt-3 text-gray-500 dark:text-neutral-500">@Loc["Email_collaboration_and"]</p>
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto flex border-t border-gray-200 divide-x divide-gray-200 dark:border-neutral-700 dark:divide-neutral-700">
|
||||
<a class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-es-xl bg-white text-gray-800 shadow-sm hover:bg-gray-50 focus:outline-none focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-800 dark:focus:bg-neutral-800"
|
||||
href="#">@Loc["View_sample"]</a>
|
||||
<a class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-ee-xl bg-white text-gray-800 shadow-sm hover:bg-gray-50 focus:outline-none focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-white dark:hover:bg-neutral-800 dark:focus:bg-neutral-800"
|
||||
href="#">@Loc["View_API"]</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public ProductVM Product { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@if (Product == null) return;
|
||||
<div
|
||||
class="group relative flex flex-col h-full bg-white border @(Product.IsBundle ? "border-primary/40 shadow-xl shadow-primary/5 scale-[1.02]" : "border-gray-200") shadow-sm rounded-xl dark:bg-neutral-900 dark:border-neutral-700 dark:shadow-neutral-700/70 transition-all duration-300 hover:shadow-2xl">
|
||||
|
||||
<div class="relative flex items-end overflow-hidden bg-cover rounded-t-xl h-52"
|
||||
style="background-image:url('@(Product.Photos?.Any() == true ? Product.Photos[0] : "https://via.placeholder.com/400x300?text=No+Image")')">
|
||||
|
||||
@if (Product.IsBundle)
|
||||
{
|
||||
<div
|
||||
class="absolute top-3 right-3 px-3 py-1.5 rounded-xl backdrop-blur-md bg-primary/20 border border-primary/30 shadow-lg animate-pulse">
|
||||
<span class="text-[10px] font-black tracking-widest text-primary uppercase flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
Bundle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-3 left-3 px-2 py-1 rounded-lg backdrop-blur-md bg-black/40 border border-white/10">
|
||||
<span class="text-[9px] font-bold text-white/90 uppercase tracking-tighter">Value Pack</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-[10px] font-black uppercase text-primary/60 tracking-wider">
|
||||
@Product.CategoryName
|
||||
</span>
|
||||
@if (Product.IsBundle && Product.ChildProducts?.Count > 0)
|
||||
{
|
||||
<div class="badge badge-ghost badge-sm font-bold opacity-50">@(Product.ChildProducts.Count) Items</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-800 dark:text-neutral-300 group-hover:text-primary transition-colors">
|
||||
@Product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
|
||||
</h3>
|
||||
|
||||
<p class="mt-3 text-sm text-gray-500 dark:text-neutral-500 line-clamp-2 leading-relaxed">
|
||||
@Product.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-2xl font-black text-gray-900 dark:text-white">@Product.Price?.ToString("C")</span>
|
||||
@if (Product.Discount > 0)
|
||||
{
|
||||
<span class="text-xs font-bold text-error line-through opacity-50">@((Product.Price * (1 + Product.Discount /
|
||||
100.0))?.ToString("C"))</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-auto flex border-t border-gray-200 divide-x divide-gray-200 dark:border-neutral-700 dark:divide-neutral-700 bg-gray-50/50 dark:bg-neutral-800/50 rounded-b-xl">
|
||||
<a class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold bg-transparent text-gray-800 hover:bg-white dark:text-white dark:hover:bg-neutral-800 transition-all rounded-es-xl"
|
||||
href="/product/@Product.Id">
|
||||
@Loc["View"]
|
||||
</a>
|
||||
<a class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold bg-transparent text-gray-800 hover:bg-white dark:text-white dark:hover:bg-neutral-800 transition-all rounded-ee-xl"
|
||||
href="#">
|
||||
@Loc["Contact_Us"]
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public ProductVM? Product { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
<div
|
||||
class="group relative flex flex-col h-full bg-base-100 border border-base-300 shadow-lg rounded-2xl overflow-hidden hover:shadow-2xl transition-all duration-500 transform hover:-translate-y-1">
|
||||
|
||||
@* Image Section with Hover Zoom *@
|
||||
<div class="relative h-64 overflow-hidden bg-base-200">
|
||||
@if (Product.Photos != null && Product.Photos.Any())
|
||||
{
|
||||
<img src="@Product.Photos[0]"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
alt="@Product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex items-center justify-center h-full bg-base-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 opacity-20" 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>
|
||||
}
|
||||
|
||||
@* Badges Layer (Glassmorphism) *@
|
||||
<div class="absolute top-4 left-4 flex flex-col gap-2">
|
||||
@if (Product.IsBundle)
|
||||
{
|
||||
<span class="badge badge-accent font-bold py-3 glass px-3 shadow-sm border-none uppercase text-[10px]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
@Loc["Bundle"]
|
||||
</span>
|
||||
}
|
||||
@if (Product.Discount > 0)
|
||||
{
|
||||
<span class="badge badge-error font-bold py-3 glass px-3 shadow-sm border-none uppercase text-[10px]">
|
||||
@Loc["Sale"]
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Content Section *@
|
||||
<div class="p-5 flex flex-col flex-grow">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest text-primary opacity-70">
|
||||
@Product.CategoryName
|
||||
</span>
|
||||
<span class="text-[10px] font-medium text-base-content opacity-40">
|
||||
#@Product.CodeName
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-base-content mb-2 line-clamp-1 group-hover:text-primary transition-colors">
|
||||
@Product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-base-content opacity-60 line-clamp-2 mb-4">
|
||||
@Product.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)
|
||||
</p>
|
||||
|
||||
@* Price Section *@
|
||||
<div class="mt-auto pt-4 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
@if (Product.Discount > 0)
|
||||
{
|
||||
<span class="text-xs line-through opacity-40 font-medium">$@Product.Price</span>
|
||||
<span class="text-2xl font-black text-primary">$@GetDiscountedPrice()</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-2xl font-black text-base-content">$@Product.Price</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<a href="/product/@Product.Id"
|
||||
class="btn btn-primary btn-circle btn-sm shadow-md transition-all duration-300 transform group-hover:scale-125">
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public ProductVM Product { get; set; } = new();
|
||||
|
||||
private double GetDiscountedPrice()
|
||||
{
|
||||
if (Product.Price == null) return 0;
|
||||
var discount = Product.Discount ?? 0;
|
||||
return Math.Round(Product.Price.Value * (1 - discount / 100.0), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
@page "/product/{Id:guid}"
|
||||
@inject ProductAPIConsumer ProductService
|
||||
|
||||
<div class="container mx-auto px-4 py-8 md:py-16">
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh] gap-4">
|
||||
<div class="loading loading-spinner loading-lg text-primary"></div>
|
||||
<p class="text-gray-500 animate-pulse">@Loc["Loading_Product"]...</p>
|
||||
</div>
|
||||
}
|
||||
else if (_product == null)
|
||||
{
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh] gap-6 text-center">
|
||||
<div class="bg-red-50 p-6 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-red-500" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9.172 9.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">@Loc["Product_Not_Found"]</h2>
|
||||
<a href="/shop" class="btn btn-primary">@Loc["Back_to_Shop"]</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||
<!-- Left: Image Viewer -->
|
||||
<div class="w-full">
|
||||
<Generic.ImageViewer.ImageViewer Width="5" Height="4" ThumbSize="100"
|
||||
MediaUrls="@(_product.Photos ?? Array.Empty<string>())" Class="sticky top-24" />
|
||||
</div>
|
||||
|
||||
<!-- Right: Product Info -->
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
@if (!string.IsNullOrEmpty(_product.CategoryName))
|
||||
{
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-bold uppercase tracking-widest rounded-full mb-4">
|
||||
@_product.CategoryName
|
||||
</span>
|
||||
}
|
||||
<h1 class="text-4xl md:text-5xl font-black text-gray-900 leading-tight mb-4">
|
||||
@(_product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h1>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-3xl font-bold text-primary">
|
||||
@(_product.Price * (1 - _product.Discount / 100)) $
|
||||
</span>
|
||||
@if (_product.Discount > 0)
|
||||
{
|
||||
<span class="text-xl text-gray-400 line-through">@_product.Price $</span>
|
||||
<span class="bg-green-500 text-white text-xs font-bold px-2 py-1 rounded">
|
||||
-@_product.Discount%
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs tabs-bordered mb-4">
|
||||
<button class="tab tab-lg @(_activeTab == "description" ? "tab-active font-bold text-primary" : "")"
|
||||
@onclick='() => _activeTab = "description"'>
|
||||
@Loc["Description"]
|
||||
</button>
|
||||
<button class="tab tab-lg @(_activeTab == "technical" ? "tab-active font-bold text-primary" : "")"
|
||||
@onclick='() => _activeTab = "technical"'>
|
||||
@Loc["Technical_Details"]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[200px]">
|
||||
@if (_activeTab == "description")
|
||||
{
|
||||
<div class="prose prose-lg text-gray-600 max-w-none animate-in fade-in duration-300">
|
||||
<p>@(_product.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</p>
|
||||
</div>
|
||||
}
|
||||
else if (_activeTab == "technical")
|
||||
{
|
||||
<!-- Technical Details -->
|
||||
@if (_product.Translations?.FirstOrDefault()?.Info?.TechnicalDetails != null &&
|
||||
_product.Translations?.FirstOrDefault()?.Info?.TechnicalDetails?.Any() == true)
|
||||
{
|
||||
<div class="bg-gray-50 rounded-2xl p-6 md:p-8 animate-in fade-in duration-300">
|
||||
<h3 class="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@Loc["Technical_Specifications"]
|
||||
</h3>
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
|
||||
@foreach (var detail in _product.Translations?.FirstOrDefault()?.Info?.TechnicalDetails ?? new())
|
||||
{
|
||||
<div class="flex flex-col border-b border-gray-200 pb-2">
|
||||
<dt class="text-sm font-semibold text-gray-500">@detail.Key</dt>
|
||||
<dd class="text-base text-gray-900 font-medium">@detail.Value</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-gray-500 italic">@Loc["No_Technical_Details_Available"]</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex flex-wrap gap-4">
|
||||
<button class="btn btn-primary btn-lg flex-1 md:flex-none md:min-w-[200px] shadow-xl shadow-primary/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
@Loc["Add_to_Cart"]
|
||||
</button>
|
||||
<button class="btn btn-outline btn-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
@Loc["Favorite"]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_product.IsBundle && _product.ChildProducts?.Any() == true)
|
||||
{
|
||||
<div class="mt-20 pt-16 border-t border-gray-100 animate-in slide-in-from-bottom-10 duration-700">
|
||||
<div class="flex flex-col gap-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-3xl font-black text-gray-900 tracking-tight flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl text-primary">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
@Loc["Included_in_this_Bundle"]
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-widest">@Loc["Consisting_of"]:</span>
|
||||
<div class="badge badge-md badge-primary font-bold opacity-80 shadow-lg shadow-primary/10">
|
||||
@(_product.ChildProducts.Count) @Loc["Items"]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
@foreach (var child in _product.ChildProducts)
|
||||
{
|
||||
<div
|
||||
class="group flex flex-col bg-white border border-gray-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl hover:border-primary/20 transition-all duration-500 hover:-translate-y-2">
|
||||
<div class="h-48 overflow-hidden bg-gray-50 relative">
|
||||
@if (child.Photos?.Length > 0)
|
||||
{
|
||||
<img src="@child.Photos[0]"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
alt="@(child.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
||||
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 class="p-6 flex flex-col gap-4 flex-1">
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
class="font-bold text-lg text-gray-900 group-hover:text-primary transition-colors truncate">
|
||||
@(child.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 line-clamp-2 leading-relaxed mt-2">
|
||||
@(child.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</p>
|
||||
</div>
|
||||
|
||||
<a href="/product/@child.Id"
|
||||
class="btn btn-primary btn-sm rounded-xl w-full gap-2 shadow-lg shadow-primary/10">
|
||||
@Loc["View_Details"]
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid Id { get; set; }
|
||||
|
||||
private ProductVM? _product;
|
||||
private bool _loading = true;
|
||||
private string _activeTab = "description";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_product = await ProductService.GetProduct(Id.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error fetching product: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
@page "/shop"
|
||||
@inject ProductAPIConsumer productService
|
||||
@inject CategoryAPIConsumer categoryService
|
||||
@inject NavigationManager nav
|
||||
@using Common
|
||||
|
||||
|
||||
<div class="flex flex-col gap-8 pb-20">
|
||||
<!-- Hero Section -->
|
||||
<div class="relative w-full h-80 rounded-2xl overflow-hidden shadow-2xl">
|
||||
<div class="absolute inset-0 bg-cover bg-center"
|
||||
style="background-image: url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp);">
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div class="relative z-10 h-full flex flex-col justify-center items-center text-center text-white p-6">
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold mb-4 tracking-tight">Discover Premium Quality</h1>
|
||||
<p class="text-lg md:text-xl text-gray-200 max-w-2xl">Explore our curated collection of top-tier products
|
||||
and exclusive bundles designed to elevate your lifestyle.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<EditForm Model="productFilter" OnValidSubmit="() => OnSearchProduct(1)"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 border border-gray-100 dark:border-gray-700">
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 items-end">
|
||||
<!-- Search -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Search</label>
|
||||
<div class="relative">
|
||||
<InputText @bind-Value="productFilter.Name" placeholder="Search products..."
|
||||
class="w-full pl-4 pr-10 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
<button type="submit"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-400 hover:text-blue-500">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Category</label>
|
||||
<CategoryTreeSelect @bind-Value="productFilter.CategoryId" ShowBundles="false"
|
||||
Placeholder="All Categories" />
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
||||
<InputSelect @bind-Value="productFilter.IsBundle"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
<option value="">All Types</option>
|
||||
<option value="true">Bundles Only</option>
|
||||
<option value="false">Products Only</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Max Price</label>
|
||||
<InputNumber @bind-Value="productFilter.Price" placeholder="Max Price"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<div class="lg:col-span-1">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Min Discount %</label>
|
||||
<InputNumber @bind-Value="productFilter.Discount" placeholder="Min Discount"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="lg:col-span-1">
|
||||
<button type="submit"
|
||||
class="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-md transition-colors duration-200">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 w-full h-full gap-5 p-5 md:p-12 lg:p-20">
|
||||
@foreach (var product in products)
|
||||
{
|
||||
<ProductComp4 Product="product" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<PaginationComp OnSearch="OnSearchProduct" PaginationModel="pagination"></PaginationComp>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
public Pagination pagination { get; set; } = new() { CurrentPage = 1, PageSize = 15 };
|
||||
|
||||
public ProductFilter productFilter { get; set; } = new ProductFilter();
|
||||
|
||||
List<ProductVM> products = new List<ProductVM>();
|
||||
|
||||
|
||||
[Parameter, SupplyParameterFromQuery]
|
||||
public string? CategoryId { get; set; }
|
||||
|
||||
[Parameter, SupplyParameterFromQuery]
|
||||
public bool? IsBundle { get; set; }
|
||||
|
||||
protected async override Task OnParametersSetAsync()
|
||||
{
|
||||
bool changed = false;
|
||||
if (!string.IsNullOrEmpty(CategoryId) && Guid.TryParse(CategoryId, out var categoryGuid))
|
||||
{
|
||||
productFilter.CategoryId = categoryGuid;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (IsBundle.HasValue)
|
||||
{
|
||||
productFilter.IsBundle = IsBundle.Value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
await OnSearchProduct();
|
||||
}
|
||||
}
|
||||
|
||||
protected async override Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(CategoryId))
|
||||
{
|
||||
await OnSearchProduct();
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnSearchProduct(int pageNumber = 1)
|
||||
{
|
||||
pagination.CurrentPage = pageNumber;
|
||||
|
||||
var result = await productService.FetchProduct(productFilter, pagination);
|
||||
products = result.Item1;
|
||||
pagination.ItemsCount = result.Item2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@page "/bundle/{productid}"
|
||||
@inject ProductAPIConsumer ProductService
|
||||
@inject NavigationManager nav
|
||||
|
||||
|
||||
<div class="text-gray-700 dark:text-gray-200 body-font overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div class="container px-5 py-12 mx-auto">
|
||||
<div class="lg:w-4/5 mx-auto flex flex-wrap">
|
||||
<div class="lg:w-1/2 w-full lg:pr-10 lg:py-6 mb-6 lg:mb-0">
|
||||
<h2 class="text-sm title-font text-gray-500 tracking-widest uppercase">@Loc["Bundle_Deal"]</h2>
|
||||
<h1 class="text-gray-900 dark:text-white text-3xl title-font font-medium mb-1">@(Product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h1>
|
||||
@if (!string.IsNullOrEmpty(Product.CodeName))
|
||||
{
|
||||
<h2 class="text-xs text-gray-500 tracking-widest font-medium mb-4">MODEL: @Product.CodeName</h2>
|
||||
}
|
||||
|
||||
<p class="leading-relaxed mb-6">@(Product.Translations?.FirstOrDefault()?.Info?.Description ?? string.Empty)</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">@Loc["Included_in_Bundle"]:
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
@if (Product.ChildProducts != null)
|
||||
{
|
||||
@foreach (var item in Product.ChildProducts)
|
||||
{
|
||||
<li class="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
@(item.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex flex-col">
|
||||
@if (Product.Discount > 0 && Product.Price.HasValue)
|
||||
{
|
||||
<span class="title-font font-medium text-2xl text-red-600 dark:text-red-400">
|
||||
$@((Product.Price.Value - (Product.Price.Value * (Product.Discount.Value /
|
||||
100))).ToString("N2"))
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-500 line-through">
|
||||
$@Product.Price.Value.ToString("N2")
|
||||
</span>
|
||||
<span class="text-xs font-bold text-white bg-red-500 px-2 py-1 rounded">
|
||||
-@Product.Discount.Value.ToString("0")%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="title-font font-medium text-2xl text-gray-900 dark:text-white">
|
||||
$@(Product.Price.HasValue? Product.Price.Value.ToString("N2") : "N/A")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="flex ml-auto text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded">@Loc["Buy_Bundle"]</button>
|
||||
<button
|
||||
class="rounded-full w-10 h-10 bg-gray-200 dark:bg-gray-700 p-0 border-0 inline-flex items-center justify-center text-gray-500 ml-4">
|
||||
<svg fill="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img alt="@(Product.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)" class="lg:w-1/2 w-full lg:h-auto h-64 object-cover object-center rounded shadow-lg"
|
||||
src="@(Product.Photos != null && Product.Photos.Any() ? Product.Photos.First() : "https://dummyimage.com/400x400")">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string ProductId { get; set; }
|
||||
|
||||
ProductVM Product = new();
|
||||
protected async override Task OnInitializedAsync()
|
||||
{
|
||||
Product = await ProductService.GetProduct(ProductId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page "/shop/categories"
|
||||
@inject CategoryAPIConsumer categoryService
|
||||
|
||||
|
||||
<div class="hero h-1/3"
|
||||
style="background-image: url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp);">
|
||||
<div class="hero-overlay bg-opacity-60"></div>
|
||||
<div class="hero-content text-white text-center pt-20">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-5xl font-bold ">@Loc["Our_Categories"]</h1>
|
||||
<p class="mb-5">@Loc["Provident_cupiditate_voluptatem"]</p>
|
||||
<button class="btn btn-primary">@Loc["Get_Started"]</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="max-w-7xl mx-auto p-10">
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<span class="loading loading-infinity loading-lg text-primary"></span>
|
||||
<span class="text-sm font-bold opacity-50">Loading Categories...</span>
|
||||
</div>
|
||||
}
|
||||
else if (categories == null || !categories.Any())
|
||||
{
|
||||
<div class="flex flex-col items-center justify-center py-20 opacity-40 italic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span>No categories found.</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto pb-4 custom-scrollbar">
|
||||
<div class="inline-block min-w-max space-y-4">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<CategoryTreeItem Category="category" IsAdmin="false" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
List<CategoryVM> categories = new List<CategoryVM>();
|
||||
bool _loading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
categories = await categoryService.FetchCategoryTree();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@page "/shop/category/{id}"
|
||||
|
||||
@inject ProductAPIConsumer productService
|
||||
@inject CategoryAPIConsumer categoryService
|
||||
@inject NavigationManager nav
|
||||
|
||||
<div class="hero h-1/3"
|
||||
style="background-image: url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp);">
|
||||
<div class="hero-overlay bg-opacity-60"></div>
|
||||
<div class="hero-content text-neutral-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-5xl font-bold">@Loc["Hello_there"]</h1>
|
||||
<p class="mb-5">@Loc["Provident_cupiditate_voluptatem"]</p>
|
||||
<button class="btn btn-primary">@Loc["Get_Started"]</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<h2>Child Categories for @(category.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</h2>
|
||||
|
||||
<div class="p-6 md:p-14 lg:p-20 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
|
||||
@foreach (var cat in categories)
|
||||
{
|
||||
<CategoryComp Category="cat"></CategoryComp>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string Id { get; set; }
|
||||
|
||||
CategoryVM category = new();
|
||||
List<CategoryVM> categories = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Id))
|
||||
{
|
||||
category = await categoryService.GetCategory(Id);
|
||||
// Assuming GetCategory response doesn't include ChildCategories populated depth?
|
||||
// If ChildCategories exists on VM, use it.
|
||||
// checking CategoryVM: yes it has ChildCategories.
|
||||
if (category.ChildCategories != null)
|
||||
{
|
||||
categories = category.ChildCategories.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@* @page "/productss"
|
||||
@inject ProductAPIConsumer productService
|
||||
@inject CategoryAPIConsumer categoryService
|
||||
|
||||
|
||||
<div class="flex flex-col gap-5 ">
|
||||
<div class="hero h-1/3"
|
||||
style="background-image: url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp);">
|
||||
<div class="hero-overlay bg-opacity-60"></div>
|
||||
<div class="hero-content text-white text-center py-20">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-5xl font-bold ">@Loc["Hello_there"]</h1>
|
||||
<p class="mb-5">@Loc["Provident_cupiditate_voluptatem"]</p>
|
||||
<button class="btn btn-primary">@Loc["Get_Started"]</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<EditForm Model="productFilter" OnValidSubmit="() => OnSearchProduct(1)"
|
||||
class="max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<!-- Search Bar -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<InputText @bind-Value="productFilter.Name" type="text" placeholder="Search..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500">@Loc["Search"]</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<!-- Category Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">@Loc["Category"]</label>
|
||||
<InputSelect @bind-Value="productFilter.CategoryId"
|
||||
class="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">@Loc["All_Categories"]</option>
|
||||
|
||||
@foreach (var item in categories.Where(x => x.IsBundle == false).ToList())
|
||||
{
|
||||
<option value="@item.Id">@(item.Translations?.FirstOrDefault()?.Info?.Name ?? string.Empty)</option>
|
||||
}
|
||||
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
<!-- Sort By Filter -->
|
||||
<!-- Rating Filter -->
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 w-full h-full gap-5 p-5 md:p-12 lg:p-20">
|
||||
@foreach (var product in products)
|
||||
{
|
||||
<ProductComp4 Product="product" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<PaginationComp OnSearch="OnSearchProduct" PaginationModel="pagination"></PaginationComp>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
public Pagination pagination { get; set; } = new() { CurrentPage = 1, PageSize = 15 };
|
||||
|
||||
public ProductFilter productFilter { get; set; } = new ProductFilter();
|
||||
|
||||
|
||||
List<ProductVM> products = new List<ProductVM>();
|
||||
|
||||
List<CategoryVM> categories = new List<CategoryVM>();
|
||||
|
||||
|
||||
SortModel sortModel = new SortModel();
|
||||
|
||||
|
||||
|
||||
|
||||
protected async override Task OnInitializedAsync()
|
||||
{
|
||||
var categoryResult = await categoryService.FetchCategory(new CategoryFilter(), new Pagination
|
||||
{
|
||||
PageSize = 100,
|
||||
CurrentPage = 1
|
||||
});
|
||||
categories = categoryResult.Item1;
|
||||
|
||||
await OnSearchProduct();
|
||||
}
|
||||
|
||||
async Task OnSearchProduct(int pageNumber = 1)
|
||||
{
|
||||
pagination.CurrentPage = pageNumber;
|
||||
var result = await productService.FetchProduct(productFilter, pagination);
|
||||
products = result.Item1;
|
||||
pagination.ItemsCount = result.Item2;
|
||||
}
|
||||
|
||||
} *@
|
||||
@@ -0,0 +1,85 @@
|
||||
@using System.Globalization
|
||||
|
||||
<div class="space-y-2">
|
||||
@if (!HideTabs)
|
||||
{
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
@foreach (var culture in SupportedCultures)
|
||||
{
|
||||
<button type="button" @onclick="() => SelectCulture(culture.Key)"
|
||||
class="btn btn-sm @(ActiveCulture == culture.Key ? "btn-primary" : "btn-ghost")">
|
||||
@culture.Value
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="relative">
|
||||
@if (IsTextArea)
|
||||
{
|
||||
<textarea @bind="CurrentValue" @bind:event="oninput" class="textarea textarea-bordered w-full h-24"
|
||||
placeholder="@Placeholder"></textarea>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="text" @bind="CurrentValue" @bind:event="oninput" class="input input-bordered w-full"
|
||||
placeholder="@Placeholder" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Value != null && Value.ContainsKey(ActiveCulture))
|
||||
{
|
||||
<div class="text-xs opacity-50 text-right">
|
||||
Chars: @Value[ActiveCulture].Length
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Dictionary<string, string> Value { get; set; } = new();
|
||||
[Parameter] public EventCallback<Dictionary<string, string>> ValueChanged { get; set; }
|
||||
[Parameter] public string Placeholder { get; set; } = "";
|
||||
[Parameter] public bool IsTextArea { get; set; } = false;
|
||||
|
||||
[Parameter] public string ActiveCulture { get; set; } = "en";
|
||||
[Parameter] public EventCallback<string> ActiveCultureChanged { get; set; }
|
||||
[Parameter] public bool HideTabs { get; set; } = false;
|
||||
|
||||
private static readonly Dictionary<string, string> SupportedCultures = new()
|
||||
{
|
||||
{ "en", "English" },
|
||||
{ "ar", "العربية" },
|
||||
{ "es", "Español" },
|
||||
{ "de", "Deutsch" }
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ActiveCulture))
|
||||
{
|
||||
// Default to current culture if supported, otherwise en
|
||||
var current = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||
if (SupportedCultures.ContainsKey(current))
|
||||
{
|
||||
ActiveCulture = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string CurrentValue
|
||||
{
|
||||
get => Value != null && Value.TryGetValue(ActiveCulture, out var v) ? v : "";
|
||||
set
|
||||
{
|
||||
if (Value == null) Value = new();
|
||||
Value[ActiveCulture] = value;
|
||||
ValueChanged.InvokeAsync(Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectCulture(string culture)
|
||||
{
|
||||
ActiveCulture = culture;
|
||||
await ActiveCultureChanged.InvokeAsync(culture);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user