+ @if (activeGroup() === 'general') {
+ @if (breadcrumb.length > 0) {
+
-
-
-
-
-
-
-
+
+ @for (locale of locales; track locale) {
+
+ }
+ }
+
+ @if (activeGroup() === 'media') {
+
+
+
+
+
+
+ }
+
+ @if (activeGroup() === 'seo') {
+
{{ 'adminProducts.seoExplain' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ category.seo.metaTitle || category.title }}
+
yourstore.com/category/{{ category.slug }}
+
{{ category.seo.metaDescription || category.description }}
+
+ }
+
+ @if (activeGroup() === 'visibility') {
+
+
{{ 'adminCategories.visibilityExplain' | translate }}
+ }
+
+ @if (activeGroup() === 'navigation') {
+
+
{{ breadcrumb.length > 0 ? breadcrumb.join(' / ') : ('adminCategories.navRoot' | translate) }}
+
+
+ @if (children.length === 0) {
+
{{ 'adminCategories.navNoChildren' | translate }}
+ } @else {
+
+ @for (child of children; track child.id) { - {{ child.icon }} {{ child.title }}
}
+
+ }
+
+
+
{{ (category.visible ? 'adminCategories.navAppearsVisible' : 'adminCategories.navAppearsHidden') | translate }}
+ }
+
+ @if (activeGroup() === 'attributes') {
+
+
+
+
+
+
+ }
+
+ @if (activeGroup() === 'advanced') {
+
+ }
diff --git a/src/app/features/admin/categories/components/admin-category-form.component.scss b/src/app/features/admin/categories/components/admin-category-form.component.scss
index e37a2a0..1705828 100644
--- a/src/app/features/admin/categories/components/admin-category-form.component.scss
+++ b/src/app/features/admin/categories/components/admin-category-form.component.scss
@@ -12,3 +12,33 @@ input[type='checkbox'] { width: auto; }
.image-field .preview { width: 64px; height: 64px; object-fit: cover; border-radius: 10px; border: 1px solid var(--border-color, #d3dad9); }
.actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 900px) { .grid.two { grid-template-columns: 1fr; } }
+
+.form-card__header { display: grid; gap: 8px; }
+
+.form-card__tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-subtle, #e7ece9); }
+.form-card__tab {
+ all: unset;
+ cursor: pointer;
+ padding: 10px 14px;
+ border-radius: 8px 8px 0 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-secondary, #5f6e6a);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ &:focus-visible { outline: 2px solid var(--brand-primary, #1e8a6e); outline-offset: 2px; }
+}
+.form-card__tab--active { color: var(--brand-primary, #1e8a6e); background: var(--surface-muted, #eef2f0); }
+.form-card__panel { min-height: 200px; display: grid; gap: 14px; }
+.form-card__subheading { margin: 8px 0 0; font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary, #5f6e6a); }
+.form-card__explain { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
+.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: 400; }
+
+.nav-children { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.85rem; }
+
+.seo-preview { border: 1px solid var(--border-subtle, #e7ece9); border-radius: 10px; padding: 12px 14px; display: grid; gap: 2px; }
+.seo-preview__title { margin: 0; color: #1a0dab; font-size: 1rem; }
+.seo-preview__url { margin: 0; color: #006621; font-size: 0.8rem; }
+.seo-preview__description { margin: 0; color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
diff --git a/src/app/features/admin/categories/components/admin-category-form.component.ts b/src/app/features/admin/categories/components/admin-category-form.component.ts
index 9a33f42..f1ad1fa 100644
--- a/src/app/features/admin/categories/components/admin-category-form.component.ts
+++ b/src/app/features/admin/categories/components/admin-category-form.component.ts
@@ -1,17 +1,36 @@
-import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { AdminCategory } from '../models/admin-category.model';
+import { DatePipe } from '@angular/common';
+import { AdminCategory, AdminCategoryAttribute } from '../models/admin-category.model';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { InputComponent } from '../../../../shared/ui/input/input.component';
import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component';
-import { MediaPickerComponent } from '../../../../shared/media/media-picker/media-picker.component';
-import { MediaAsset } from '../../../../core/media/models/media-asset.model';
+import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
+import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
+import { KeyValueEditorComponent } from '../../../../shared/ui/key-value-editor/key-value-editor.component';
+import { ImageFieldComponent } from '../../../../shared/ui/image-field/image-field.component';
+import { AdminCategoryHealth } from '../facade/admin-categories.facade';
+import { CategoryHealthWidgetComponent, CategoryHealthItem } from './category-health-widget/category-health-widget.component';
+
+export type AdminCategoryEditorGroup = 'general' | 'media' | 'seo' | 'visibility' | 'navigation' | 'attributes' | 'advanced';
@Component({
selector: 'app-admin-category-form',
standalone: true,
- imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent],
+ imports: [
+ FormsModule,
+ DatePipe,
+ TranslatePipe,
+ ButtonComponent,
+ InputComponent,
+ FormFieldComponent,
+ ToggleComponent,
+ BadgeComponent,
+ KeyValueEditorComponent,
+ ImageFieldComponent,
+ CategoryHealthWidgetComponent,
+ ],
templateUrl: './admin-category-form.component.html',
styleUrls: ['./admin-category-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -20,15 +39,40 @@ export class AdminCategoryFormComponent {
@Input({ required: true }) category!: AdminCategory;
@Input() parentOptions: AdminCategory[] = [];
@Input() breadcrumb: string[] = [];
+ @Input() children: AdminCategory[] = [];
@Input() slugTaken = false;
@Input() locales: string[] = ['en'];
@Input() mode: 'create' | 'edit' = 'create';
+ @Input() health!: AdminCategoryHealth;
@Output() categoryChange = new EventEmitter
>();
@Output() saveDraft = new EventEmitter();
@Output() publish = new EventEmitter();
- protected mediaPickerOpen = false;
+ readonly groups: { id: AdminCategoryEditorGroup; labelKey: string }[] = [
+ { id: 'general', labelKey: 'adminCategories.groupGeneral' },
+ { id: 'media', labelKey: 'adminCategories.groupMedia' },
+ { id: 'seo', labelKey: 'adminCategories.groupSeo' },
+ { id: 'visibility', labelKey: 'adminCategories.groupVisibility' },
+ { id: 'navigation', labelKey: 'adminCategories.groupNavigation' },
+ { id: 'attributes', labelKey: 'adminCategories.groupAttributes' },
+ { id: 'advanced', labelKey: 'adminCategories.groupAdvanced' },
+ ];
+
+ readonly activeGroup = signal('general');
+
+ setGroup(group: AdminCategoryEditorGroup): void {
+ this.activeGroup.set(group);
+ }
+
+ readonly healthItems = computed(() => [
+ { labelKey: 'adminCategories.healthImage', done: this.health.hasImage },
+ { labelKey: 'adminCategories.healthSeo', done: this.health.hasSeo },
+ { labelKey: 'adminCategories.healthDescription', done: this.health.hasDescription },
+ { labelKey: 'adminCategories.healthParent', done: this.health.hasValidParent },
+ { labelKey: 'adminCategories.healthVisibility', done: this.health.isVisible },
+ { labelKey: 'adminCategories.healthProducts', done: this.health.hasProducts },
+ ]);
updateField(key: K, value: AdminCategory[K]): void {
this.categoryChange.emit({ [key]: value } as Partial);
@@ -43,12 +87,17 @@ export class AdminCategoryFormComponent {
});
}
- openMediaPicker(): void {
- this.mediaPickerOpen = true;
+ setImage(url: string): void {
+ this.updateField('imageUrl', url);
}
- onImagePicked(asset: MediaAsset): void {
- this.updateField('imageUrl', asset.url);
- this.mediaPickerOpen = false;
+ readonly createAttributeRow = (): AdminCategoryAttribute => ({ key: '', value: '' });
+
+ updateAttributes(rows: AdminCategoryAttribute[]): void {
+ this.categoryChange.emit({ attributes: rows });
+ }
+
+ updateAttributeField(index: number, field: 'key' | 'value', value: string): void {
+ this.updateAttributes(this.category.attributes.map((row, i) => i === index ? { ...row, [field]: value } : row));
}
}
diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html
new file mode 100644
index 0000000..22e2487
--- /dev/null
+++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+ {{ 'adminCategories.recommendedNext' | translate }}
+ {{ stats().recommendation.labelKey | translate }}
+ @if (stats().recommendation.categoryId) {
+ {{ 'adminProducts.openAction' | translate }}
+ }
+
+
diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss
new file mode 100644
index 0000000..f50a1a6
--- /dev/null
+++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.scss
@@ -0,0 +1,41 @@
+.categories-dashboard {
+ display: grid;
+ gap: 16px;
+ margin-bottom: 8px;
+}
+
+.categories-dashboard__metrics {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+}
+
+@media (max-width: 1100px) {
+ .categories-dashboard__metrics {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .categories-dashboard__metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.categories-dashboard__recommend {
+ display: grid;
+ gap: 8px;
+ align-content: start;
+
+ h4 {
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 0;
+ color: var(--text-secondary, #5f6e6a);
+ font-size: 0.9rem;
+ }
+}
diff --git a/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts
new file mode 100644
index 0000000..310da34
--- /dev/null
+++ b/src/app/features/admin/categories/components/categories-dashboard/categories-dashboard.component.ts
@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+import { TranslateService } from '../../../../../i18n/translate.service';
+import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
+import { CardComponent } from '../../../../../shared/ui/card/card.component';
+import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
+import { AdminCategoriesDashboardStats } from '../../facade/admin-categories.facade';
+import { inject } from '@angular/core';
+
+@Component({
+ selector: 'app-categories-dashboard',
+ standalone: true,
+ imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent],
+ templateUrl: './categories-dashboard.component.html',
+ styleUrl: './categories-dashboard.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CategoriesDashboardComponent {
+ private readonly translate = inject(TranslateService);
+
+ readonly stats = input.required();
+
+ @Output() openCategory = new EventEmitter();
+
+ readonly lastModifiedValue = computed(() => {
+ const iso = this.stats().lastModifiedAt;
+ return iso ? new Date(iso).toLocaleDateString() : this.translate.t('adminCategories.lastModifiedNever');
+ });
+
+ goRecommended(): void {
+ const id = this.stats().recommendation.categoryId;
+ if (id) {
+ this.openCategory.emit(id);
+ }
+ }
+}
diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html
new file mode 100644
index 0000000..b6a5bdc
--- /dev/null
+++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.html
@@ -0,0 +1,16 @@
+
+
+
{{ completionPercent() }}%
+ @if (!compact()) {
+
+ @for (item of items(); track item.labelKey) {
+ -
+ {{ item.done ? '✓' : '○' }}
+ {{ item.labelKey | translate }}
+
+ }
+
+ }
+
diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss
new file mode 100644
index 0000000..36ec762
--- /dev/null
+++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.scss
@@ -0,0 +1,47 @@
+.category-health {
+ display: grid;
+ gap: 6px;
+ align-items: center;
+}
+
+.category-health__bar {
+ height: 6px;
+ border-radius: 999px;
+ background: var(--surface-muted, #eef2f0);
+ overflow: hidden;
+}
+
+.category-health__bar-fill {
+ height: 100%;
+ background: var(--brand-primary, #1e8a6e);
+}
+
+.category-health__percent {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--text-secondary, #5f6e6a);
+}
+
+.category-health__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 4px;
+ font-size: 0.78rem;
+ color: var(--text-secondary, #5f6e6a);
+
+ li {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+}
+
+.category-health__list-item--done {
+ color: var(--text-primary, #1e3c38);
+}
+
+.category-health--compact {
+ grid-template-columns: 1fr auto;
+}
diff --git a/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts
new file mode 100644
index 0000000..090a326
--- /dev/null
+++ b/src/app/features/admin/categories/components/category-health-widget/category-health-widget.component.ts
@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+
+export interface CategoryHealthItem {
+ labelKey: string;
+ done: boolean;
+}
+
+/** Reusable checklist + completion meter, fed real per-item booleans from AdminCategoriesFacade.health() - never invents its own data. */
+@Component({
+ selector: 'app-category-health-widget',
+ standalone: true,
+ imports: [TranslatePipe],
+ templateUrl: './category-health-widget.component.html',
+ styleUrl: './category-health-widget.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CategoryHealthWidgetComponent {
+ readonly items = input.required();
+ readonly completionPercent = input.required();
+ readonly compact = input(false);
+}
diff --git a/src/app/features/admin/categories/facade/admin-categories.facade.ts b/src/app/features/admin/categories/facade/admin-categories.facade.ts
index b9a96ba..2fd31a0 100644
--- a/src/app/features/admin/categories/facade/admin-categories.facade.ts
+++ b/src/app/features/admin/categories/facade/admin-categories.facade.ts
@@ -8,6 +8,40 @@ import { ProjectEditorFacade } from '../../../project-editor/facade/project-edit
const DRAFT_KEY_PREFIX = 'admin-category-draft:';
const NEW_CATEGORY_DRAFT_KEY = `${DRAFT_KEY_PREFIX}new`;
+const VIEW_MODE_KEY = 'admin-categories:view-mode';
+const DENSITY_KEY = 'admin-categories:density';
+const COLUMNS_KEY = 'admin-categories:visible-columns';
+const EXPANDED_KEY = 'admin-categories:expanded';
+
+export type AdminCategoriesViewMode = 'tree' | 'table' | 'cards';
+export type AdminCategoriesDensity = 'comfortable' | 'compact';
+
+export const ALL_CATEGORY_COLUMNS = ['slug', 'items', 'status', 'visibility', 'updated'] as const;
+export type AdminCategoryColumn = typeof ALL_CATEGORY_COLUMNS[number];
+
+export interface AdminCategoryHealth {
+ hasImage: boolean;
+ hasSeo: boolean;
+ hasDescription: boolean;
+ hasValidParent: boolean;
+ isVisible: boolean;
+ hasProducts: boolean;
+ completionPercent: number;
+}
+
+export interface AdminCategoriesDashboardStats {
+ total: number;
+ visible: number;
+ hidden: number;
+ empty: number;
+ rootCount: number;
+ subCount: number;
+ missingImages: number;
+ missingSeo: number;
+ lastModifiedAt: string | null;
+ completionPercent: number;
+ recommendation: { labelKey: string; categoryId?: string };
+}
@Injectable({ providedIn: 'root' })
export class AdminCategoriesFacade {
@@ -18,6 +52,76 @@ export class AdminCategoriesFacade {
readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']);
+ readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminCategoriesViewMode) || 'tree');
+ readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminCategoriesDensity) || 'comfortable');
+ readonly visibleColumns = signal(this.localStorage.getJSON(COLUMNS_KEY) ?? [...ALL_CATEGORY_COLUMNS]);
+ readonly expandedIds = signal>(new Set(this.localStorage.getJSON(EXPANDED_KEY) ?? []));
+ readonly selectedIds = signal([]);
+
+ readonly hasSelection = computed(() => this.selectedIds().length > 0);
+
+ setViewMode(mode: AdminCategoriesViewMode): void {
+ this.viewMode.set(mode);
+ this.localStorage.setItem(VIEW_MODE_KEY, mode);
+ }
+
+ setDensity(density: AdminCategoriesDensity): void {
+ this.density.set(density);
+ this.localStorage.setItem(DENSITY_KEY, density);
+ }
+
+ setColumnVisible(column: AdminCategoryColumn, visible: boolean): void {
+ const next = visible ? [...new Set([...this.visibleColumns(), column])] : this.visibleColumns().filter(c => c !== column);
+ this.visibleColumns.set(next);
+ this.localStorage.setJSON(COLUMNS_KEY, next);
+ }
+
+ isExpanded(id: string): boolean {
+ return this.expandedIds().has(id);
+ }
+
+ toggleExpanded(id: string): void {
+ const next = new Set(this.expandedIds());
+ next.has(id) ? next.delete(id) : next.add(id);
+ this.expandedIds.set(next);
+ this.localStorage.setJSON(EXPANDED_KEY, [...next]);
+ }
+
+ /** Expands every ancestor of `id` (not `id` itself) so a deep node becomes reachable - used by search and "jump to parent". */
+ expandAncestorsOf(id: string): void {
+ const next = new Set(this.expandedIds());
+ let current = this.categories().find(category => category.id === id);
+ while (current?.parentId) {
+ next.add(current.parentId);
+ current = this.categories().find(category => category.id === current!.parentId);
+ }
+ this.expandedIds.set(next);
+ this.localStorage.setJSON(EXPANDED_KEY, [...next]);
+ }
+
+ expandAll(): void {
+ const next = new Set(this.categories().map(category => category.id));
+ this.expandedIds.set(next);
+ this.localStorage.setJSON(EXPANDED_KEY, [...next]);
+ }
+
+ collapseAll(): void {
+ this.expandedIds.set(new Set());
+ this.localStorage.setJSON(EXPANDED_KEY, []);
+ }
+
+ toggleSelection(id: string, checked: boolean): void {
+ this.selectedIds.update(current => checked ? [...new Set([...current, id])] : current.filter(item => item !== id));
+ }
+
+ toggleAll(checked: boolean): void {
+ this.selectedIds.set(checked ? this.categories().map(category => category.id) : []);
+ }
+
+ clearSelection(): void {
+ this.selectedIds.set([]);
+ }
+
ensureLocalesLoaded(): void {
if (!this.projectEditor.bootstrap()) {
this.projectEditor.loadBootstrap();
@@ -60,9 +164,16 @@ export class AdminCategoriesFacade {
return trail;
}
+ /**
+ * Always fetches the full unfiltered catalog (search/visibility/deleted always 'all'
+ * at the gateway) so parent/child chains never go missing when a filter would have
+ * excluded an ancestor - the tree previously broke this way. All filtering for both
+ * the tree and the flat table/cards views happens client-side in filteredCategories()
+ * and visibleTreeRows() below.
+ */
loadList(): void {
this.loading.set(true);
- this.gateway.loadCategories(this.filters()).pipe(take(1)).subscribe({
+ this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => {
this.categories.set(categories);
this.loading.set(false);
@@ -76,7 +187,77 @@ export class AdminCategoriesFacade {
updateFilters(patch: Partial): void {
this.filters.update(current => ({ ...current, ...patch }));
- this.loadList();
+ }
+
+ private matchesSearch(category: AdminCategory, query: string): boolean {
+ return !query || category.title.toLowerCase().includes(query.trim().toLowerCase());
+ }
+
+ /** Flat view (table/cards) - applies search/visibility/includeDeleted client-side. */
+ readonly filteredCategories = computed(() => {
+ const { search, visibility, includeDeleted } = this.filters();
+ return this.categories()
+ .filter(category => includeDeleted || !category.deletedAt)
+ .filter(category => visibility === 'all' || (visibility === 'visible' ? category.visible : !category.visible))
+ .filter(category => this.matchesSearch(category, search))
+ .sort((a, b) => a.order - b.order);
+ });
+
+ /**
+ * Tree rows, respecting expand/collapse state - and while searching, branches with no
+ * matching descendant are hidden and matching branches are force-expanded (search always
+ * wins over manually-collapsed state), reverting to the remembered expand state once the
+ * search is cleared.
+ */
+ readonly visibleTreeRows = computed<{ category: AdminCategory; depth: number; hasChildren: boolean }[]>(() => {
+ const { search, visibility, includeDeleted } = this.filters();
+ const visible = (category: AdminCategory) => (includeDeleted || !category.deletedAt) && (visibility === 'all' || (visibility === 'visible' ? category.visible : !category.visible));
+ const query = search.trim();
+
+ const keepSet = query ? this.computeSearchKeepSet(query, visible) : null;
+ const expanded = this.expandedIds();
+
+ const build = (parentId: string | null, depth: number): { category: AdminCategory; depth: number; hasChildren: boolean }[] => {
+ const siblings = (parentId === null ? this.categories().filter(c => !c.parentId) : this.categories().filter(c => c.parentId === parentId))
+ .filter(visible)
+ .filter(category => !keepSet || keepSet.has(category.id))
+ .sort((a, b) => a.order - b.order);
+
+ return siblings.flatMap(category => {
+ const children = this.categories().filter(c => c.parentId === category.id).filter(visible);
+ const hasChildren = children.length > 0;
+ const isOpen = query ? true : expanded.has(category.id);
+ const row = { category, depth, hasChildren };
+ const nested = hasChildren && isOpen ? build(category.id, depth + 1) : [];
+ return [row, ...nested];
+ });
+ };
+
+ return build(null, 0);
+ });
+
+ private computeSearchKeepSet(query: string, visible: (category: AdminCategory) => boolean): Set {
+ const all = this.categories().filter(visible);
+ const matches = all.filter(category => this.matchesSearch(category, query));
+ const keep = new Set();
+
+ const addAncestors = (id: string) => {
+ let current = all.find(category => category.id === id);
+ while (current) {
+ keep.add(current.id);
+ current = current.parentId ? all.find(category => category.id === current!.parentId) : undefined;
+ }
+ };
+ const addDescendants = (id: string) => {
+ keep.add(id);
+ all.filter(category => category.parentId === id).forEach(child => addDescendants(child.id));
+ };
+
+ matches.forEach(match => {
+ addAncestors(match.id);
+ addDescendants(match.id);
+ });
+ return keep;
}
startCreate(): void {
@@ -161,18 +342,18 @@ export class AdminCategoriesFacade {
}
deleteOne(id: string): void {
- this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
+ this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
}
restoreOne(id: string): void {
- this.gateway.restoreCategory(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
+ this.gateway.restoreCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
}
setVisible(id: string, visible: boolean): void {
const category = this.categories().find(item => item.id === id);
if (!category) return;
this.gateway.updateCategory({ ...category, visible, updatedAt: new Date().toISOString() }).pipe(take(1))
- .subscribe({ next: () => this.loadList() });
+ .subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
}
reorder(id: string, targetId: string): void {
@@ -197,4 +378,137 @@ export class AdminCategoriesFacade {
updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe());
this.loadList();
}
+
+ applyBulkVisibility(visible: boolean): void {
+ const selected = new Set(this.selectedIds());
+ const updates = this.categories().filter(category => selected.has(category.id)).map(category => ({ ...category, visible, updatedAt: new Date().toISOString() }));
+ updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ applyBulkDelete(): void {
+ const ids = [...this.selectedIds()].filter(id => this.canDelete(id));
+ ids.forEach(id => this.gateway.deleteCategory(id).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ applyBulkAssignParent(parentId: string | null): void {
+ const selected = new Set(this.selectedIds());
+ // A category can never become its own ancestor - drop any selected id that is the target parent itself.
+ const updates = this.categories()
+ .filter(category => selected.has(category.id) && category.id !== parentId)
+ .map(category => ({ ...category, parentId, updatedAt: new Date().toISOString() }));
+ updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ applyBulkAssignImage(imageUrl: string): void {
+ const selected = new Set(this.selectedIds());
+ const updates = this.categories().filter(category => selected.has(category.id)).map(category => ({ ...category, imageUrl, updatedAt: new Date().toISOString() }));
+ updates.forEach(category => this.gateway.updateCategory(category).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ /** Duplicates via the existing createCategory call (no dedicated duplicate endpoint exists) - a real create, not a fabricated action. */
+ applyBulkDuplicate(): void {
+ const selected = new Set(this.selectedIds());
+ const sources = this.categories().filter(category => selected.has(category.id));
+ sources.forEach(source => {
+ const now = new Date().toISOString();
+ const duplicated: AdminCategory = {
+ ...source,
+ id: `category-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ slug: `${source.slug}-copy-${Date.now()}`,
+ title: `${source.title} Copy`,
+ status: 'draft',
+ createdAt: now,
+ updatedAt: now,
+ };
+ this.gateway.createCategory(duplicated).pipe(take(1)).subscribe();
+ });
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ /** Client-side CSV of the currently selected rows - no backend export endpoint exists. */
+ exportSelectedAsCsv(): void {
+ const selected = new Set(this.selectedIds());
+ const rows = this.categories().filter(category => selected.has(category.id));
+ const header = ['id', 'title', 'slug', 'parentId', 'visible', 'status', 'itemsCount'];
+ const lines = rows.map(category => [category.id, category.title, category.slug, category.parentId ?? '', category.visible, category.status, category.itemsCount]
+ .map(value => `"${String(value).replace(/"/g, '""')}"`).join(','));
+ const csv = [header.join(','), ...lines].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'categories-export.csv';
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+
+ health(category: AdminCategory): AdminCategoryHealth {
+ const hasImage = category.imageUrl.trim().length > 0;
+ const hasSeo = !!(category.seo.metaTitle.trim() && category.seo.metaDescription.trim());
+ const hasDescription = category.description.trim().length > 0;
+ const hasValidParent = category.parentId === null || this.categories().some(other => other.id === category.parentId);
+ const isVisible = category.visible;
+ const hasProducts = category.itemsCount > 0;
+ const checks = [hasImage, hasSeo, hasDescription, hasValidParent, isVisible, hasProducts];
+ const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100);
+ return { hasImage, hasSeo, hasDescription, hasValidParent, isVisible, hasProducts, completionPercent };
+ }
+
+ readonly dashboardStats = signal(null);
+
+ /** Dashboard stats reflect the whole catalog, not the current filtered view. */
+ loadDashboardStats(): void {
+ this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false }).pipe(take(1))
+ .subscribe({ next: categories => this.dashboardStats.set(this.computeDashboardStats(categories)) });
+ }
+
+ private computeDashboardStats(categories: AdminCategory[]): AdminCategoriesDashboardStats {
+ const withHealth = categories.map(category => ({ category, health: this.health(category) }));
+ const missingImages = withHealth.filter(entry => !entry.health.hasImage);
+ const missingSeo = withHealth.filter(entry => !entry.health.hasSeo);
+ const empty = categories.filter(category => category.itemsCount === 0);
+ const lastModifiedAt = categories.reduce((latest, category) => !latest || category.updatedAt > latest ? category.updatedAt : latest, null);
+ const avgCompletion = withHealth.length > 0
+ ? Math.round(withHealth.reduce((sum, entry) => sum + entry.health.completionPercent, 0) / withHealth.length)
+ : 0;
+
+ let recommendation: AdminCategoriesDashboardStats['recommendation'];
+ if (missingImages[0]) {
+ recommendation = { labelKey: 'adminCategories.recommendAddImage', categoryId: missingImages[0].category.id };
+ } else if (missingSeo[0]) {
+ recommendation = { labelKey: 'adminCategories.recommendAddSeo', categoryId: missingSeo[0].category.id };
+ } else if (empty[0]) {
+ recommendation = { labelKey: 'adminCategories.recommendFillEmpty', categoryId: empty[0].id };
+ } else {
+ recommendation = { labelKey: 'adminCategories.recommendNone' };
+ }
+
+ return {
+ total: categories.length,
+ visible: categories.filter(category => category.visible).length,
+ hidden: categories.filter(category => !category.visible).length,
+ empty: empty.length,
+ rootCount: categories.filter(category => !category.parentId).length,
+ subCount: categories.filter(category => !!category.parentId).length,
+ missingImages: missingImages.length,
+ missingSeo: missingSeo.length,
+ lastModifiedAt,
+ completionPercent: avgCompletion,
+ recommendation,
+ };
+ }
}
diff --git a/src/app/features/admin/categories/models/admin-category.model.ts b/src/app/features/admin/categories/models/admin-category.model.ts
index b88e65c..c655a4c 100644
--- a/src/app/features/admin/categories/models/admin-category.model.ts
+++ b/src/app/features/admin/categories/models/admin-category.model.ts
@@ -13,6 +13,11 @@ export interface AdminCategorySeo {
keywords: string;
}
+export interface AdminCategoryAttribute {
+ key: string;
+ value: string;
+}
+
export interface AdminCategory {
id: string;
parentId: string | null;
@@ -21,12 +26,14 @@ export interface AdminCategory {
description: string;
icon: string;
imageUrl: string;
+ imageAlt: string;
order: number;
visible: boolean;
status: AdminCategoryStatus;
itemsCount: number;
translations: Record;
seo: AdminCategorySeo;
+ attributes: AdminCategoryAttribute[];
deletedAt: string | null;
createdAt: string;
updatedAt: string;
diff --git a/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts
index 22a6abd..e6bf355 100644
--- a/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts
+++ b/src/app/features/admin/categories/pages/admin-categories-list-page.component.ts
@@ -1,8 +1,7 @@
-import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
-import { AdminCategoriesListComponent, AdminCategoryRow } from '../components/admin-categories-list.component';
-import { AdminCategory } from '../models/admin-category.model';
+import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
import { LanguageService } from '../../../../services/language.service';
import { TranslateService } from '../../../../i18n/translate.service';
@@ -11,16 +10,41 @@ import { TranslateService } from '../../../../i18n/translate.service';
standalone: true,
imports: [AdminCategoriesListComponent],
template: ``,
+ (reorder)="facade.reorder($event.id, $event.targetId)"
+ (toggleExpand)="facade.toggleExpanded($event)"
+ (jumpToParent)="jumpToParent($event)"
+ (expandAll)="facade.expandAll()"
+ (collapseAll)="facade.collapseAll()"
+ (selectionChange)="facade.toggleSelection($event.id, $event.checked)"
+ (selectAll)="facade.toggleAll($event)"
+ (bulkVisibility)="facade.applyBulkVisibility($event)"
+ (bulkDelete)="facade.applyBulkDelete()"
+ (bulkDuplicate)="facade.applyBulkDuplicate()"
+ (bulkAssignParent)="facade.applyBulkAssignParent($event)"
+ (bulkAssignImage)="facade.applyBulkAssignImage($event)"
+ (bulkExport)="facade.exportSelectedAsCsv()"
+ (viewModeChange)="facade.setViewMode($event)"
+ (densityChange)="facade.setDensity($event)"
+ (columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminCategoriesListPageComponent {
@@ -29,15 +53,26 @@ export class AdminCategoriesListPageComponent {
private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService);
- readonly rows = computed(() => this.buildRows(this.facade.rootCategories(), 0));
+ readonly healthFn = (category: Parameters[0]) => this.facade.health(category);
+ readonly isExpandedFn = (id: string) => this.facade.isExpanded(id);
+ readonly canDeleteFn = (id: string) => this.facade.canDelete(id);
constructor() {
this.facade.loadList();
+ this.facade.loadDashboardStats();
}
create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'categories', 'create']); }
edit(id: string): void { this.facade.loadForEdit(id); void this.router.navigate([this.lang(), 'backoffice', 'categories', id, 'edit']); }
+ jumpToParent(id: string): void {
+ const category = this.facade.categories().find(c => c.id === id);
+ if (category?.parentId) {
+ this.facade.expandAncestorsOf(id);
+ queueMicrotask(() => document.getElementById(`category-tree-row-${category.parentId}`)?.focus());
+ }
+ }
+
deleteOne(id: string): void {
if (!this.facade.canDelete(id)) {
window.alert(this.translate.t('adminCategories.deleteBlocked'));
@@ -48,13 +83,6 @@ export class AdminCategoriesListPageComponent {
}
}
- private buildRows(categories: AdminCategory[], depth: number): AdminCategoryRow[] {
- return categories.flatMap(category => [
- { category, depth },
- ...this.buildRows(this.facade.childrenOf(category.id), depth + 1)
- ]);
- }
-
private lang(): string {
return this.languageService.currentLanguage();
}
diff --git a/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts
index 778266c..1b6d935 100644
--- a/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts
+++ b/src/app/features/admin/categories/pages/admin-category-editor-page.component.ts
@@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service';
selector: 'app-admin-category-editor-page',
standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe],
- template: `@if (facade.draft(); as draft) {{{ title() | translate }}
} @else {{{ 'common.loading' | translate }}
}`,
+ template: `@if (facade.draft(); as draft) {{{ title() | translate }}
} @else {{{ 'common.loading' | translate }}
}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})
diff --git a/src/app/features/admin/categories/services/admin-categories-form.factory.ts b/src/app/features/admin/categories/services/admin-categories-form.factory.ts
index f72a110..0506a02 100644
--- a/src/app/features/admin/categories/services/admin-categories-form.factory.ts
+++ b/src/app/features/admin/categories/services/admin-categories-form.factory.ts
@@ -12,12 +12,14 @@ export class AdminCategoriesFormFactory {
description: '',
icon: '',
imageUrl: '',
+ imageAlt: '',
order: 0,
visible: true,
status: 'draft',
itemsCount: 0,
translations: { en: {}, ru: {}, hy: {} },
seo: { metaTitle: '', metaDescription: '', keywords: '' },
+ attributes: [],
deletedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
diff --git a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts
index f5661f0..95456f0 100644
--- a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts
+++ b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts
@@ -93,6 +93,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
description: category.description ?? '',
icon: category.icon ?? '',
imageUrl: category.imageUrl ?? '',
+ imageAlt: '',
order: 0,
visible: true,
status: 'published',
@@ -107,6 +108,7 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway {
metaDescription: category.description ?? '',
keywords: '',
},
+ attributes: [],
deletedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index 38feb4e..a7e1bb5 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -1221,6 +1221,84 @@ export const en: Translations = {
adminCategories: {
chooseImage: 'Choose image',
replaceImage: 'Replace image',
+ search: 'Search categories…',
+ showDeleted: 'Show deleted',
+ create: 'Create category',
+ edit: 'Edit category',
+ title: 'Title',
+ slug: 'URL slug',
+ slugTaken: 'This slug is already used by another category.',
+ parent: 'Parent category',
+ noParent: 'No parent (top level)',
+ icon: 'Icon',
+ image: 'Image',
+ imageHint: 'Shown in category listings and navigation.',
+ description: 'Description',
+ items: 'products',
+ status: { draft: 'Draft', published: 'Published' },
+ restore: 'Restore',
+ saveDraft: 'Save draft',
+ publish: 'Publish',
+ confirmDelete: 'Delete this category? This cannot be undone.',
+ confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
+ deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
+ emptyTitle: 'No categories yet',
+ emptyDescription: 'Create your first category to start organizing products.',
+ emptyGuide: 'Categories help customers browse and find products. Start with a few broad top-level categories (e.g. Clothing, Electronics), then add subcategories as your catalog grows — avoid nesting more than 2-3 levels deep.',
+ viewTree: 'Tree',
+ viewCards: 'Cards',
+ expandAll: 'Expand all',
+ collapseAll: 'Collapse all',
+ expandNode: 'Expand',
+ collapseNode: 'Collapse',
+ jumpToParent: 'Jump to parent',
+ deletedBadge: 'Deleted',
+ column_slug: 'Slug',
+ column_items: 'Products',
+ column_status: 'Status',
+ column_visibility: 'Visibility',
+ column_updated: 'Last updated',
+ bulkAssignParent: 'Assign parent',
+ bulkAssignImage: 'Assign image',
+ groupGeneral: 'General',
+ groupMedia: 'Media',
+ groupSeo: 'SEO',
+ groupVisibility: 'Visibility',
+ groupNavigation: 'Navigation',
+ groupAttributes: 'Attributes',
+ groupAdvanced: 'Advanced',
+ visibilityExplain: 'A hidden category and its products stay out of the storefront navigation and search until you make it visible again.',
+ navBreadcrumb: 'Breadcrumb',
+ navRoot: 'This is a top-level category.',
+ navChildren: 'Subcategories',
+ navNoChildren: 'No subcategories yet.',
+ navAppearsIn: 'Where this appears',
+ navAppearsVisible: 'Visible in catalog navigation and search.',
+ navAppearsHidden: 'Hidden — not shown in catalog navigation while hidden.',
+ itemsCountLabel: 'Assigned products',
+ createdAtLabel: 'Created',
+ healthImage: 'Image',
+ healthSeo: 'SEO',
+ healthDescription: 'Description',
+ healthParent: 'Parent',
+ healthVisibility: 'Visibility',
+ healthProducts: 'Products assigned',
+ totalCategories: 'Total categories',
+ visibleCount: 'Visible',
+ hiddenCount: 'Hidden',
+ emptyCount: 'Empty',
+ rootCount: 'Root categories',
+ subCount: 'Subcategories',
+ missingImagesCount: 'Missing images',
+ missingSeoCount: 'Missing SEO',
+ lastModified: 'Last modified',
+ lastModifiedNever: 'Never',
+ completionLabel: 'Completion',
+ recommendedNext: 'Recommended next step',
+ recommendAddImage: 'A category has no image — add one so it looks trustworthy in listings.',
+ recommendAddSeo: 'A category is missing a search title or description — add one so it can be found.',
+ recommendFillEmpty: 'A category has no products assigned — add products or consider hiding it.',
+ recommendNone: 'Nice work — your category structure is in good shape.',
},
adminProducts: {
emptyTitle: 'No products found',
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 8635e6b..314003e 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -1216,6 +1216,84 @@ export const hy: Translations = {
adminCategories: {
chooseImage: 'Ընտրել պատկեր',
replaceImage: 'Փոխարինել պատկերը',
+ search: 'Փնտրել կատեգորիաներ…',
+ showDeleted: 'Ցույց տալ ջնջվածները',
+ create: 'Ստեղծել կատեգորիա',
+ edit: 'Խմբագրել կատեգորիան',
+ title: 'Անուն',
+ slug: 'URL հասցե',
+ slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
+ parent: 'Ծնող կատեգորիա',
+ noParent: 'Առանց ծնողի (վերին մակարդակ)',
+ icon: 'Պատկերակ',
+ image: 'Պատկեր',
+ imageHint: 'Ցուցադրվում է կատեգորիաների ցանկերում և նավիգացիայում։',
+ description: 'Նկարագրություն',
+ items: 'ապրանք',
+ status: { draft: 'Սևագիր', published: 'Հրապարակված' },
+ restore: 'Վերականգնել',
+ saveDraft: 'Պահպանել սևագիրը',
+ publish: 'Հրապարակել',
+ confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։',
+ confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
+ deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։',
+ emptyTitle: 'Կատեգորիաներ դեռ չկան',
+ emptyDescription: 'Ստեղծեք առաջին կատեգորիան՝ ապրանքները կազմակերպելու համար։',
+ emptyGuide: 'Կատեգորիաները օգնում են հաճախորդներին գտնել ապրանքներ։ Սկսեք մի քանի լայն վերին մակարդակի կատեգորիաներից (օր․՝ Հագուստ, Էլեկտրոնիկա), ապա ավելացրեք ենթակատեգորիաներ՝ կատալոգի աճին զուգահեռ․ խուսափեք 2-3 մակարդակից ավելի խորությունից։',
+ viewTree: 'Ծառ',
+ viewCards: 'Քարտեր',
+ expandAll: 'Ընդարձակել բոլորը',
+ collapseAll: 'Ծալել բոլորը',
+ expandNode: 'Ընդարձակել',
+ collapseNode: 'Ծալել',
+ jumpToParent: 'Անցնել ծնողին',
+ deletedBadge: 'Ջնջված',
+ column_slug: 'Հասցե',
+ column_items: 'Ապրանքներ',
+ column_status: 'Կարգավիճակ',
+ column_visibility: 'Տեսանելիություն',
+ column_updated: 'Վերջին փոփոխություն',
+ bulkAssignParent: 'Նշանակել ծնող',
+ bulkAssignImage: 'Նշանակել պատկեր',
+ groupGeneral: 'Հիմնական',
+ groupMedia: 'Մեդիա',
+ groupSeo: 'SEO',
+ groupVisibility: 'Տեսանելիություն',
+ groupNavigation: 'Նավիգացիա',
+ groupAttributes: 'Հատկանիշներ',
+ groupAdvanced: 'Լրացուցիչ',
+ visibilityExplain: 'Թաքցված կատեգորիան և դրա ապրանքները չեն երևում խանութի նավիգացիայում և որոնման մեջ, մինչև այն նորից տեսանելի դարձնեք։',
+ navBreadcrumb: 'Ուղենիշ',
+ navRoot: 'Սա վերին մակարդակի կատեգորիա է։',
+ navChildren: 'Ենթակատեգորիաներ',
+ navNoChildren: 'Ենթակատեգորիաներ դեռ չկան։',
+ navAppearsIn: 'Որտեղ է երևում',
+ navAppearsVisible: 'Տեսանելի է կատալոգի նավիգացիայում և որոնման մեջ։',
+ navAppearsHidden: 'Թաքցված է․ չի երևում կատալոգի նավիգացիայում, քանի դեռ թաքցված է։',
+ itemsCountLabel: 'Նշանակված ապրանքներ',
+ createdAtLabel: 'Ստեղծված է',
+ healthImage: 'Պատկեր',
+ healthSeo: 'SEO',
+ healthDescription: 'Նկարագրություն',
+ healthParent: 'Ծնող',
+ healthVisibility: 'Տեսանելիություն',
+ healthProducts: 'Նշանակված ապրանքներ',
+ totalCategories: 'Ընդհանուր կատեգորիաներ',
+ visibleCount: 'Տեսանելի',
+ hiddenCount: 'Թաքցված',
+ emptyCount: 'Դատարկ',
+ rootCount: 'Արմատային կատեգորիաներ',
+ subCount: 'Ենթակատեգորիաներ',
+ missingImagesCount: 'Առանց պատկերի',
+ missingSeoCount: 'Առանց SEO-ի',
+ lastModified: 'Վերջին փոփոխություն',
+ lastModifiedNever: 'Երբեք',
+ completionLabel: 'Ավարտվածություն',
+ recommendedNext: 'Հաջորդ առաջարկվող քայլը',
+ recommendAddImage: 'Կատեգորիան պատկեր չունի․ ավելացրեք մեկը՝ վստահելիության համար։',
+ recommendAddSeo: 'Կատեգորիան չունի որոնման վերնագիր կամ նկարագրություն․ ավելացրեք դրանք։',
+ recommendFillEmpty: 'Կատեգորիան ապրանքներ չունի․ ավելացրեք ապրանքներ կամ թաքցրեք այն։',
+ recommendNone: 'Հիանալի է․ կատեգորիաների կառուցվածքը լավ վիճակում է։',
},
adminProducts: {
emptyTitle: 'Ապրանքներ չեն գտնվել',
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 8c1004a..20c150d 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -1216,6 +1216,84 @@ export const ru: Translations = {
adminCategories: {
chooseImage: 'Выбрать изображение',
replaceImage: 'Заменить изображение',
+ search: 'Поиск категорий…',
+ showDeleted: 'Показать удалённые',
+ create: 'Создать категорию',
+ edit: 'Редактировать категорию',
+ title: 'Название',
+ slug: 'URL-адрес',
+ slugTaken: 'Этот адрес уже используется другой категорией.',
+ parent: 'Родительская категория',
+ noParent: 'Без родителя (верхний уровень)',
+ icon: 'Иконка',
+ image: 'Изображение',
+ imageHint: 'Показывается в списках категорий и навигации.',
+ description: 'Описание',
+ items: 'товаров',
+ status: { draft: 'Черновик', published: 'Опубликовано' },
+ restore: 'Восстановить',
+ saveDraft: 'Сохранить черновик',
+ publish: 'Опубликовать',
+ confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.',
+ confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
+ deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.',
+ emptyTitle: 'Категорий пока нет',
+ emptyDescription: 'Создайте первую категорию, чтобы начать организовывать товары.',
+ emptyGuide: 'Категории помогают покупателям находить товары. Начните с нескольких верхнеуровневых категорий (например, Одежда, Электроника), затем добавляйте подкатегории по мере роста каталога — избегайте вложенности глубже 2-3 уровней.',
+ viewTree: 'Дерево',
+ viewCards: 'Карточки',
+ expandAll: 'Развернуть всё',
+ collapseAll: 'Свернуть всё',
+ expandNode: 'Развернуть',
+ collapseNode: 'Свернуть',
+ jumpToParent: 'К родителю',
+ deletedBadge: 'Удалена',
+ column_slug: 'Адрес',
+ column_items: 'Товары',
+ column_status: 'Статус',
+ column_visibility: 'Видимость',
+ column_updated: 'Последнее изменение',
+ bulkAssignParent: 'Назначить родителя',
+ bulkAssignImage: 'Назначить изображение',
+ groupGeneral: 'Основное',
+ groupMedia: 'Медиа',
+ groupSeo: 'SEO',
+ groupVisibility: 'Видимость',
+ groupNavigation: 'Навигация',
+ groupAttributes: 'Атрибуты',
+ groupAdvanced: 'Дополнительно',
+ visibilityExplain: 'Скрытая категория и её товары не отображаются в навигации и поиске магазина, пока вы снова не сделаете её видимой.',
+ navBreadcrumb: 'Хлебные крошки',
+ navRoot: 'Это категория верхнего уровня.',
+ navChildren: 'Подкатегории',
+ navNoChildren: 'Подкатегорий пока нет.',
+ navAppearsIn: 'Где это отображается',
+ navAppearsVisible: 'Видна в навигации каталога и поиске.',
+ navAppearsHidden: 'Скрыта — не отображается в навигации каталога, пока скрыта.',
+ itemsCountLabel: 'Назначенные товары',
+ createdAtLabel: 'Создана',
+ healthImage: 'Изображение',
+ healthSeo: 'SEO',
+ healthDescription: 'Описание',
+ healthParent: 'Родитель',
+ healthVisibility: 'Видимость',
+ healthProducts: 'Назначены товары',
+ totalCategories: 'Всего категорий',
+ visibleCount: 'Видимые',
+ hiddenCount: 'Скрытые',
+ emptyCount: 'Пустые',
+ rootCount: 'Корневые категории',
+ subCount: 'Подкатегории',
+ missingImagesCount: 'Без изображений',
+ missingSeoCount: 'Без SEO',
+ lastModified: 'Последнее изменение',
+ lastModifiedNever: 'Никогда',
+ completionLabel: 'Готовность',
+ recommendedNext: 'Рекомендуемый следующий шаг',
+ recommendAddImage: 'У категории нет изображения — добавьте его для доверия в листингах.',
+ recommendAddSeo: 'У категории нет заголовка или описания для поиска — добавьте их.',
+ recommendFillEmpty: 'В категории нет товаров — добавьте товары или скройте категорию.',
+ recommendNone: 'Отлично — структура категорий в хорошем состоянии.',
},
adminProducts: {
emptyTitle: 'Товары не найдены',
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 554d43b..aeeb62e 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -1228,6 +1228,84 @@ export interface Translations {
adminCategories: {
chooseImage: string;
replaceImage: string;
+ search: string;
+ showDeleted: string;
+ create: string;
+ edit: string;
+ title: string;
+ slug: string;
+ slugTaken: string;
+ parent: string;
+ noParent: string;
+ icon: string;
+ image: string;
+ imageHint: string;
+ description: string;
+ items: string;
+ status: { draft: string; published: string };
+ restore: string;
+ saveDraft: string;
+ publish: string;
+ confirmDelete: string;
+ confirmLeaveUnsaved: string;
+ deleteBlocked: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ emptyGuide: string;
+ viewTree: string;
+ viewCards: string;
+ expandAll: string;
+ collapseAll: string;
+ expandNode: string;
+ collapseNode: string;
+ jumpToParent: string;
+ deletedBadge: string;
+ column_slug: string;
+ column_items: string;
+ column_status: string;
+ column_visibility: string;
+ column_updated: string;
+ bulkAssignParent: string;
+ bulkAssignImage: string;
+ groupGeneral: string;
+ groupMedia: string;
+ groupSeo: string;
+ groupVisibility: string;
+ groupNavigation: string;
+ groupAttributes: string;
+ groupAdvanced: string;
+ visibilityExplain: string;
+ navBreadcrumb: string;
+ navRoot: string;
+ navChildren: string;
+ navNoChildren: string;
+ navAppearsIn: string;
+ navAppearsVisible: string;
+ navAppearsHidden: string;
+ itemsCountLabel: string;
+ createdAtLabel: string;
+ healthImage: string;
+ healthSeo: string;
+ healthDescription: string;
+ healthParent: string;
+ healthVisibility: string;
+ healthProducts: string;
+ totalCategories: string;
+ visibleCount: string;
+ hiddenCount: string;
+ emptyCount: string;
+ rootCount: string;
+ subCount: string;
+ missingImagesCount: string;
+ missingSeoCount: string;
+ lastModified: string;
+ lastModifiedNever: string;
+ completionLabel: string;
+ recommendedNext: string;
+ recommendAddImage: string;
+ recommendAddSeo: string;
+ recommendFillEmpty: string;
+ recommendNone: string;
};
adminProducts: {
emptyTitle: string;