diff --git a/src/app/features/admin/products/components/admin-product-form.component.html b/src/app/features/admin/products/components/admin-product-form.component.html index ac70505..d5901ce 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.html +++ b/src/app/features/admin/products/components/admin-product-form.component.html @@ -1,139 +1,254 @@
-
- - - - - - - - - - - - - - - - - - - - - +
+
-

{{ 'adminProducts.media' | translate }}

-
- - - -
- -

{{ 'adminProducts.pricing' | translate }}

-
- - - - - - - - - -
- -

{{ 'adminProducts.inventory' | translate }}

-
- - - - - - - -
- -

{{ 'adminProducts.content' | translate }}

-
- - - - -
- -

{{ 'adminProducts.translations' | translate }}

- @for (locale of locales; track locale) { -
- - - - - - - -
- } - -

{{ 'adminProducts.seo' | translate }}

-
- - - - - - - -
- -

{{ 'adminProducts.marketplace' | translate }}

-
- - - - - - - -
- -

{{ 'adminProducts.variants' | translate }}

-
- -
- -

{{ 'adminProducts.relatedProducts' | translate }}

-
diff --git a/src/app/features/admin/products/components/admin-products-list.component.scss b/src/app/features/admin/products/components/admin-products-list.component.scss index 2c3b6c9..334da62 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.scss +++ b/src/app/features/admin/products/components/admin-products-list.component.scss @@ -7,3 +7,24 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color @media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } } @media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } } .skeleton-rows { display: grid; gap: 8px; } + +.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.view-toggle { display: flex; gap: 2px; } +.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; } +.health-cell { min-width: 140px; } +.admin-products-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); } + +.density-compact td, .density-compact th { padding: 4px 8px; } + +.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } +.product-grid--compact { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } +.product-grid__item { position: relative; display: grid; gap: 6px; } +.product-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; } +.product-grid__image { width: 100%; height: 120px; object-fit: cover; border-radius: 8px; background: var(--surface-muted, #eef2f0); } +.product-grid__image--placeholder { display: flex; align-items: center; justify-content: center; font-size: 0.75rem; color: var(--text-tertiary, #9aa6a2); } +.product-grid__name { margin: 0; font-size: 0.85rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.product-grid__price { margin: 0; font-size: 0.8rem; color: var(--text-secondary, #5f6e6a); } +.product-grid__actions { display: flex; gap: 6px; flex-wrap: wrap; } + +.dialog-body { display: grid; gap: 12px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } diff --git a/src/app/features/admin/products/components/admin-products-list.component.ts b/src/app/features/admin/products/components/admin-products-list.component.ts index fcd7086..f333c2f 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.ts +++ b/src/app/features/admin/products/components/admin-products-list.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; @@ -9,11 +9,30 @@ import { TableComponent } from '../../../../shared/ui/table/table.component'; import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; +import { CardComponent } from '../../../../shared/ui/card/card.component'; +import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; +import { ProductHealthWidgetComponent, ProductHealthItem } from './product-health-widget/product-health-widget.component'; +import { ProductsDashboardComponent } from './products-dashboard/products-dashboard.component'; +import { AdminProductColumn, AdminProductHealth, AdminProductsDashboardStats, AdminProductsDensity, AdminProductsViewMode, ALL_PRODUCT_COLUMNS } from '../facade/admin-products.facade'; @Component({ selector: 'app-admin-products-list', standalone: true, - imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, SkeletonComponent, EmptyStateComponent], + imports: [ + FormsModule, + TranslatePipe, + ButtonComponent, + InputComponent, + BadgeComponent, + TableComponent, + PaginationComponent, + SkeletonComponent, + EmptyStateComponent, + CardComponent, + DialogComponent, + ProductHealthWidgetComponent, + ProductsDashboardComponent, + ], templateUrl: './admin-products-list.component.html', styleUrls: ['./admin-products-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -26,6 +45,11 @@ export class AdminProductsListComponent { @Input() selectedIds: string[] = []; @Input() loading = false; @Input() infiniteScroll = false; + @Input() viewMode: AdminProductsViewMode = 'table'; + @Input() density: AdminProductsDensity = 'comfortable'; + @Input() visibleColumns: AdminProductColumn[] = [...ALL_PRODUCT_COLUMNS]; + @Input() dashboardStats: AdminProductsDashboardStats | null = null; + @Input() health!: (product: AdminProduct) => AdminProductHealth; @Output() filtersChange = new EventEmitter>(); @Output() create = new EventEmitter(); @@ -36,10 +60,23 @@ export class AdminProductsListComponent { @Output() selectAll = new EventEmitter(); @Output() bulkVisibility = new EventEmitter(); @Output() bulkDelete = new EventEmitter(); + @Output() bulkDuplicate = new EventEmitter(); + @Output() bulkAssignCategory = new EventEmitter(); + @Output() bulkAssignTags = new EventEmitter(); + @Output() bulkExport = new EventEmitter(); @Output() archive = new EventEmitter(); @Output() restore = new EventEmitter(); @Output() loadMore = new EventEmitter(); @Output() infiniteScrollToggle = new EventEmitter(); + @Output() viewModeChange = new EventEmitter(); + @Output() densityChange = new EventEmitter(); + @Output() columnToggle = new EventEmitter<{ column: AdminProductColumn; visible: boolean }>(); + + protected readonly allColumns = ALL_PRODUCT_COLUMNS; + protected readonly columnsPanelOpen = signal(false); + protected readonly assignCategoryOpen = signal(false); + protected readonly assignTagsOpen = signal(false); + protected readonly tagsInput = signal(''); isSelected(id: string): boolean { return this.selectedIds.includes(id); @@ -48,4 +85,42 @@ export class AdminProductsListComponent { totalPages(): number { return Math.max(1, Math.ceil(this.total / this.filters.pageSize)); } + + isColumnVisible(column: AdminProductColumn): boolean { + return this.visibleColumns.includes(column); + } + + healthItems(product: AdminProduct): ProductHealthItem[] { + const health = this.health(product); + return [ + { labelKey: 'adminProducts.healthImages', done: health.hasImages }, + { labelKey: 'adminProducts.healthSeo', done: health.hasSeo }, + { labelKey: 'adminProducts.healthPrice', done: health.hasPrice }, + { labelKey: 'adminProducts.healthCategory', done: health.hasCategory }, + { labelKey: 'adminProducts.healthDescription', done: health.hasDescription }, + { labelKey: 'adminProducts.healthInventory', done: health.hasInventory }, + ]; + } + + openAssignCategory(): void { + this.assignCategoryOpen.set(true); + } + + confirmAssignCategory(categoryId: string): void { + this.bulkAssignCategory.emit(categoryId); + this.assignCategoryOpen.set(false); + } + + openAssignTags(): void { + this.tagsInput.set(''); + this.assignTagsOpen.set(true); + } + + confirmAssignTags(): void { + const tags = this.tagsInput().split(',').map(t => t.trim()).filter(Boolean); + if (tags.length > 0) { + this.bulkAssignTags.emit(tags); + } + this.assignTagsOpen.set(false); + } } diff --git a/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.html b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.html new file mode 100644 index 0000000..ccb54f4 --- /dev/null +++ b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.html @@ -0,0 +1,16 @@ +
+
+
+
+ {{ completionPercent() }}% + @if (!compact()) { +
    + @for (item of items(); track item.labelKey) { +
  • + + {{ item.labelKey | translate }} +
  • + } +
+ } +
diff --git a/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.scss b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.scss new file mode 100644 index 0000000..0679b07 --- /dev/null +++ b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.scss @@ -0,0 +1,47 @@ +.product-health { + display: grid; + gap: 6px; + align-items: center; +} + +.product-health__bar { + height: 6px; + border-radius: 999px; + background: var(--surface-muted, #eef2f0); + overflow: hidden; +} + +.product-health__bar-fill { + height: 100%; + background: var(--brand-primary, #1e8a6e); +} + +.product-health__percent { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-secondary, #5f6e6a); +} + +.product-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; + } +} + +.product-health__list-item--done { + color: var(--text-primary, #1e3c38); +} + +.product-health--compact { + grid-template-columns: 1fr auto; +} diff --git a/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.ts b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.ts new file mode 100644 index 0000000..d3df202 --- /dev/null +++ b/src/app/features/admin/products/components/product-health-widget/product-health-widget.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; + +export interface ProductHealthItem { + labelKey: string; + done: boolean; +} + +/** Reusable checklist + completion meter, fed real per-item booleans from AdminProductsFacade.health() - never invents its own data. */ +@Component({ + selector: 'app-product-health-widget', + standalone: true, + imports: [TranslatePipe], + templateUrl: './product-health-widget.component.html', + styleUrl: './product-health-widget.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductHealthWidgetComponent { + readonly items = input.required(); + readonly completionPercent = input.required(); + readonly compact = input(false); +} diff --git a/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.html b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.html new file mode 100644 index 0000000..368d7d8 --- /dev/null +++ b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.html @@ -0,0 +1,37 @@ +
+
+ + + + + + + + +
+ +
+ +

{{ 'adminProducts.recentlyEdited' | translate }}

+ @if (stats().recentlyEdited.length === 0) { +

{{ 'adminProducts.emptyDescription' | translate }}

+ } @else { +
    + @for (product of stats().recentlyEdited; track product.id) { +
  • + +
  • + } +
+ } +
+ + +

{{ 'adminProducts.recommendedNext' | translate }}

+

{{ stats().recommendation.labelKey | translate }}

+ @if (stats().recommendation.productId) { + {{ 'adminProducts.openAction' | translate }} + } +
+
+
diff --git a/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.scss b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.scss new file mode 100644 index 0000000..2ced2a9 --- /dev/null +++ b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.scss @@ -0,0 +1,77 @@ +.products-dashboard { + display: grid; + gap: 16px; + margin-bottom: 8px; +} + +.products-dashboard__metrics { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 1200px) { + .products-dashboard__metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .products-dashboard__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.products-dashboard__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; +} + +@media (max-width: 800px) { + .products-dashboard__row { + grid-template-columns: 1fr; + } +} + +.products-dashboard__recent, +.products-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; + } +} + +.products-dashboard__recent ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 4px; +} + +.products-dashboard__recent button { + all: unset; + cursor: pointer; + color: var(--brand-primary, #1e8a6e); + font-size: 0.85rem; + + &:hover, &:focus-visible { + text-decoration: underline; + } +} + +.products-dashboard__hint { + font-size: 0.85rem; +} diff --git a/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.ts b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.ts new file mode 100644 index 0000000..91be162 --- /dev/null +++ b/src/app/features/admin/products/components/products-dashboard/products-dashboard.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, input } from '@angular/core'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +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 { AdminProductsDashboardStats } from '../../facade/admin-products.facade'; + +@Component({ + selector: 'app-products-dashboard', + standalone: true, + imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent], + templateUrl: './products-dashboard.component.html', + styleUrl: './products-dashboard.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductsDashboardComponent { + readonly stats = input.required(); + + @Output() openProduct = new EventEmitter(); + + goRecommended(): void { + const id = this.stats().recommendation.productId; + if (id) { + this.openProduct.emit(id); + } + } +} diff --git a/src/app/features/admin/products/facade/admin-products.facade.ts b/src/app/features/admin/products/facade/admin-products.facade.ts index 5746f14..bd036fa 100644 --- a/src/app/features/admin/products/facade/admin-products.facade.ts +++ b/src/app/features/admin/products/facade/admin-products.facade.ts @@ -4,12 +4,69 @@ import { AdminProduct, AdminProductCategoryOption, AdminProductEditorMode, Admin import { AdminProductsFormFactory } from '../services/admin-products-form.factory'; import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway'; import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; + +export type AdminProductsViewMode = 'table' | 'grid'; +export type AdminProductsDensity = 'comfortable' | 'compact'; + +export interface AdminProductHealth { + hasImages: boolean; + hasSeo: boolean; + hasPrice: boolean; + hasCategory: boolean; + hasDescription: boolean; + hasInventory: boolean; + completionPercent: number; +} + +export interface AdminProductsDashboardStats { + total: number; + published: number; + drafts: number; + outOfStock: number; + hidden: number; + missingImages: number; + missingSeo: number; + lowQuality: number; + recentlyEdited: AdminProduct[]; + recommendation: { labelKey: string; productId?: string }; +} + +const VIEW_MODE_KEY = 'admin-products:view-mode'; +const DENSITY_KEY = 'admin-products:density'; +const COLUMNS_KEY = 'admin-products:visible-columns'; + +export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const; +export type AdminProductColumn = typeof ALL_PRODUCT_COLUMNS[number]; @Injectable({ providedIn: 'root' }) export class AdminProductsFacade { private readonly gateway = inject(AdminProductsLocalGateway); private readonly formFactory = inject(AdminProductsFormFactory); private readonly projectEditor = inject(ProjectEditorFacade); + private readonly localStorage = inject(LocalStorageService); + + readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminProductsViewMode) || 'table'); + readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminProductsDensity) || 'comfortable'); + readonly visibleColumns = signal(this.localStorage.getJSON(COLUMNS_KEY) ?? [...ALL_PRODUCT_COLUMNS]); + + setViewMode(mode: AdminProductsViewMode): void { + this.viewMode.set(mode); + this.localStorage.setItem(VIEW_MODE_KEY, mode); + } + + setDensity(density: AdminProductsDensity): void { + this.density.set(density); + this.localStorage.setItem(DENSITY_KEY, density); + } + + setColumnVisible(column: AdminProductColumn, 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); + } readonly supportedLocales = computed(() => this.projectEditor.bootstrap()?.localization.supportedLocales ?? ['en']); @@ -25,7 +82,7 @@ export class AdminProductsFacade { visibility: 'all', stock: 'all', includeArchived: false, - sort: 'title', + sort: (this.localStorage.getItem('admin-products:sort') as AdminProductListFilters['sort']) || 'title', page: 1, pageSize: 10, }); @@ -78,11 +135,11 @@ export class AdminProductsFacade { } archiveOne(id: string): void { - this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); } restoreOne(id: string): void { - this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); } loadCategories(): void { @@ -90,6 +147,9 @@ export class AdminProductsFacade { } updateFilters(patch: Partial): void { + if (patch.sort) { + this.localStorage.setItem('admin-products:sort', patch.sort); + } this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 })); this.loadList(); } @@ -108,6 +168,7 @@ export class AdminProductsFacade { updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe()); this.selectedIds.set([]); this.loadList(); + this.loadDashboardStats(); } applyBulkDelete(): void { @@ -115,6 +176,108 @@ export class AdminProductsFacade { ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe()); this.selectedIds.set([]); this.loadList(); + this.loadDashboardStats(); + } + + applyBulkAssignCategory(categoryId: string): void { + const selected = new Set(this.selectedIds()); + const updates = this.products().filter(product => selected.has(product.id)).map(product => ({ ...product, categoryId, updatedAt: new Date().toISOString() })); + updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe()); + this.selectedIds.set([]); + this.loadList(); + this.loadDashboardStats(); + } + + applyBulkAssignTags(tags: string[]): void { + const selected = new Set(this.selectedIds()); + const updates = this.products().filter(product => selected.has(product.id)).map(product => ({ + ...product, + badges: [...new Set([...product.badges, ...tags])], + updatedAt: new Date().toISOString(), + })); + updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe()); + this.selectedIds.set([]); + this.loadList(); + this.loadDashboardStats(); + } + + applyBulkDuplicate(): void { + const ids = [...this.selectedIds()]; + ids.forEach(id => this.gateway.duplicateProduct(id).pipe(take(1)).subscribe()); + this.selectedIds.set([]); + this.loadList(); + this.loadDashboardStats(); + } + + /** Client-side CSV of the currently selected rows - no backend export endpoint exists, so this never claims a server-generated file. */ + exportSelectedAsCsv(): void { + const selected = new Set(this.selectedIds()); + const rows = this.products().filter(product => selected.has(product.id)); + const header = ['id', 'name', 'sku', 'brand', 'price', 'currency', 'quantity', 'stockStatus', 'visible', 'categoryId']; + const lines = rows.map(product => [product.id, product.name, product.sku, product.brand, product.price, product.currency, product.quantity, product.stockStatus, product.visible, product.categoryId] + .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 = 'products-export.csv'; + link.click(); + URL.revokeObjectURL(url); + } + + health(product: AdminProduct): AdminProductHealth { + const hasImages = product.media.images.length > 0 || product.media.gallery.length > 0; + const hasSeo = !!(product.seo.metaTitle.trim() && product.seo.metaDescription.trim()); + const hasPrice = product.price > 0; + const hasCategory = !!product.categoryId; + const hasDescription = product.shortDescription.trim().length > 0; + const hasInventory = product.quantity > 0 || product.availability.trim().length > 0; + const checks = [hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory]; + const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100); + return { hasImages, hasSeo, hasPrice, hasCategory, hasDescription, hasInventory, completionPercent }; + } + + readonly dashboardStats = signal(null); + + /** Dashboard stats must reflect the whole catalog, not just the current page - loads with a page size large enough to cover the mock catalog in one call. */ + loadDashboardStats(): void { + this.gateway.loadProducts({ ...this.filters(), page: 1, pageSize: 100000, includeArchived: true, search: '', categoryId: null, visibility: 'all', stock: 'all' }) + .pipe(take(1)) + .subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) }); + } + + private computeDashboardStats(products: AdminProduct[]): AdminProductsDashboardStats { + const withHealth = products.map(product => ({ product, health: this.health(product) })); + + const missingImages = withHealth.filter(entry => !entry.health.hasImages); + const missingSeo = withHealth.filter(entry => !entry.health.hasSeo); + const lowQuality = withHealth.filter(entry => entry.health.completionPercent < 50); + const recentlyEdited = [...products].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 5); + + let recommendation: AdminProductsDashboardStats['recommendation']; + if (missingImages[0]) { + recommendation = { labelKey: 'adminProducts.recommendAddImages', productId: missingImages[0].product.id }; + } else if (missingSeo[0]) { + recommendation = { labelKey: 'adminProducts.recommendAddSeo', productId: missingSeo[0].product.id }; + } else if (products.some(p => p.stockStatus === 'out_of_stock')) { + recommendation = { labelKey: 'adminProducts.recommendRestock', productId: products.find(p => p.stockStatus === 'out_of_stock')?.id }; + } else { + recommendation = { labelKey: 'adminProducts.recommendNone' }; + } + + return { + total: products.length, + published: products.filter(p => p.visible && !p.archived).length, + drafts: products.filter(p => !p.visible && !p.archived).length, + outOfStock: products.filter(p => p.stockStatus === 'out_of_stock').length, + hidden: products.filter(p => !p.visible).length, + missingImages: missingImages.length, + missingSeo: missingSeo.length, + lowQuality: lowQuality.length, + recentlyEdited, + recommendation, + }; } startCreate(): void { diff --git a/src/app/features/admin/products/pages/admin-product-editor-page.component.ts b/src/app/features/admin/products/pages/admin-product-editor-page.component.ts index 08e2983..643a7fe 100644 --- a/src/app/features/admin/products/pages/admin-product-editor-page.component.ts +++ b/src/app/features/admin/products/pages/admin-product-editor-page.component.ts @@ -9,7 +9,7 @@ import { LanguageService } from '../../../../services/language.service'; selector: 'app-admin-product-editor-page', standalone: true, imports: [AdminProductFormComponent, 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/products/pages/admin-products-list-page.component.ts b/src/app/features/admin/products/pages/admin-products-list-page.component.ts index f10ec67..974ce31 100644 --- a/src/app/features/admin/products/pages/admin-products-list-page.component.ts +++ b/src/app/features/admin/products/pages/admin-products-list-page.component.ts @@ -16,6 +16,11 @@ import { LanguageService } from '../../../../services/language.service'; [selectedIds]="facade.selectedIds()" [loading]="facade.loading()" [infiniteScroll]="facade.infiniteScroll()" + [viewMode]="facade.viewMode()" + [density]="facade.density()" + [visibleColumns]="facade.visibleColumns()" + [dashboardStats]="facade.dashboardStats()" + [health]="healthFn" (filtersChange)="facade.updateFilters($event)" (create)="create()" (edit)="edit($event)" @@ -28,7 +33,14 @@ import { LanguageService } from '../../../../services/language.service'; (selectionChange)="facade.toggleSelection($event.id, $event.checked)" (selectAll)="facade.toggleAll($event)" (bulkVisibility)="facade.applyBulkVisibility($event)" - (bulkDelete)="facade.applyBulkDelete()" />`, + (bulkDelete)="facade.applyBulkDelete()" + (bulkDuplicate)="facade.applyBulkDuplicate()" + (bulkAssignCategory)="facade.applyBulkAssignCategory($event)" + (bulkAssignTags)="facade.applyBulkAssignTags($event)" + (bulkExport)="facade.exportSelectedAsCsv()" + (viewModeChange)="facade.setViewMode($event)" + (densityChange)="facade.setDensity($event)" + (columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`, changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminProductsListPageComponent { @@ -36,9 +48,12 @@ export class AdminProductsListPageComponent { private readonly router = inject(Router); private readonly languageService = inject(LanguageService); + readonly healthFn = (product: Parameters[0]) => this.facade.health(product); + constructor() { this.facade.loadCategories(); this.facade.loadList(); + this.facade.loadDashboardStats(); } create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 4cdf90d..38feb4e 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1225,6 +1225,141 @@ export const en: Translations = { adminProducts: { emptyTitle: 'No products found', emptyDescription: 'Try adjusting your filters, or create a new product.', + emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.', + search: 'Search products…', + category: 'Category', + allCategories: 'All categories', + visibility: 'Visibility', + allVisibility: 'All visibility', + visible: 'Visible', + hidden: 'Hidden', + stockStatus: 'Stock status', + allStock: 'All stock levels', + inStock: 'In stock', + lowStock: 'Low stock', + outOfStock: 'Out of stock', + sortTitle: 'Name', + sortPrice: 'Price', + sortPriority: 'Priority', + sortStock: 'Stock', + sortUpdated: 'Last updated', + showArchived: 'Show archived', + infiniteScroll: 'Infinite scroll', + viewMode: 'View', + viewTable: 'Table', + viewGrid: 'Grid', + density: 'Density', + densityComfortable: 'Comfortable', + densityCompact: 'Compact', + columns: 'Columns', + column_sku: 'SKU', + column_brand: 'Brand', + column_price: 'Price', + column_stock: 'Stock', + column_visibility: 'Visibility', + column_updated: 'Last updated', + create: 'Create product', + edit: 'Edit product', + duplicate: 'Duplicate', + selectedCount: 'selected', + bulkShow: 'Publish', + bulkHide: 'Hide', + bulkAssignCategory: 'Assign category', + bulkAssignTags: 'Assign tags', + bulkDuplicateAction: 'Duplicate', + bulkExportAction: 'Export', + bulkDelete: 'Delete', + tagsPlaceholder: 'e.g. summer, sale', + name: 'Name', + brand: 'Brand', + healthColumn: 'Health', + actions: 'Actions', + archive: 'Archive', + delete: 'Delete', + items: 'products', + loadMore: 'Load more', + noImage: 'No image', + slug: 'URL slug', + barcode: 'Barcode', + priority: 'Priority', + shortDescription: 'Short description', + translations: 'Translations', + primaryImage: 'Primary image', + primaryImageHint: 'The main photo shown in listings and search results.', + gallery: 'Gallery', + galleryHint: 'Additional photos shown on the product page. Drag order with the arrows.', + moveLeft: 'Move earlier', + moveRight: 'Move later', + videos: 'Videos (one URL per line)', + discount: 'Discount %', + currency: 'Currency', + stockQuantity: 'Stock quantity', + stockQuantityHint: 'How many units are available to sell right now.', + availabilityStatus: 'Availability', + availabilityStatusHint: 'What customers see: in stock, running low, or out of stock.', + availabilityNote: 'Availability note', + availabilityNoteHint: 'Optional extra detail shown to customers, e.g. "Ships in 3 days".', + reservedNote: 'Reserved stock isn\'t tracked separately yet — quantity shown here is total available stock.', + relatedProducts: 'Related products', + specifications: 'Specifications', + attributes: 'Attributes', + addRow: 'Add row', + removeRow: 'Remove', + rowKey: 'Name', + rowValue: 'Value', + addVariant: 'Add variant', + variantName: 'Variant name', + variantsHint: 'Each variant is a purchasable option of this product, e.g. a size or color, with its own price and stock.', + seoExplain: 'This is what shows up in search results — a clear title and description help customers find this product.', + searchTitle: 'Search title', + searchTitleHint: 'The headline shown in search results. Keep it short and descriptive.', + searchDescription: 'Search description', + searchDescriptionHint: 'A short summary shown under the title in search results.', + seoMissingTitle: 'Add a search title so this product can be found.', + seoMissingDescription: 'Add a search description to improve how this product appears in results.', + keywords: 'Keywords', + featured: 'Featured', + recommended: 'Recommended', + new: 'New', + bestseller: 'Bestseller', + badges: 'Badges (comma-separated)', + htmlDescription: 'Detailed description', + customer: 'Customer feedback', + reviewsReadonly: 'Reviews', + noReviews: 'No reviews yet.', + questionsReadonly: 'Questions', + noQuestions: 'No questions yet.', + save: 'Save product', + groupGeneral: 'General', + groupMedia: 'Media', + groupPricing: 'Pricing', + groupInventory: 'Inventory', + groupCategories: 'Categories', + groupAttributes: 'Attributes', + groupSeo: 'SEO', + groupVisibility: 'Visibility', + groupAdvanced: 'Advanced', + healthImages: 'Images', + healthSeo: 'SEO', + healthPrice: 'Price', + healthCategory: 'Category', + healthDescription: 'Description', + healthInventory: 'Inventory', + totalProducts: 'Total products', + publishedCount: 'Published', + draftsCount: 'Drafts', + outOfStockCount: 'Out of stock', + hiddenCount: 'Hidden', + missingImagesCount: 'Missing images', + missingSeoCount: 'Missing SEO', + lowQualityCount: 'Low-quality', + recentlyEdited: 'Recently edited', + recommendedNext: 'Recommended next step', + openAction: 'Open', + recommendAddImages: 'A product has no photos — add at least one image so it looks trustworthy.', + recommendAddSeo: 'A product is missing a search title or description — add one so it can be found.', + recommendRestock: 'A product is out of stock — restock it or mark it unavailable.', + recommendNone: 'Nice work — your catalog is in good shape.', }, adminUsers: { emptyTitle: 'No users found', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 5a48b1b..8635e6b 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -1220,6 +1220,141 @@ export const hy: Translations = { adminProducts: { emptyTitle: 'Ապրանքներ չեն գտնվել', emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։', + emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։', + search: 'Փնտրել ապրանքներ…', + category: 'Կատեգորիա', + allCategories: 'Բոլոր կատեգորիաները', + visibility: 'Տեսանելիություն', + allVisibility: 'Ցանկացած տեսանելիություն', + visible: 'Տեսանելի', + hidden: 'Թաքցված', + stockStatus: 'Պահեստի կարգավիճակ', + allStock: 'Ցանկացած մնացորդ', + inStock: 'Առկա է', + lowStock: 'Քիչ է մնացել', + outOfStock: 'Առկա չէ', + sortTitle: 'Անուն', + sortPrice: 'Գին', + sortPriority: 'Առաջնահերթություն', + sortStock: 'Մնացորդ', + sortUpdated: 'Վերջին փոփոխություն', + showArchived: 'Ցույց տալ արխիվայինները', + infiniteScroll: 'Անվերջ ոլորում', + viewMode: 'Տեսք', + viewTable: 'Աղյուսակ', + viewGrid: 'Ցանց', + density: 'Խտություն', + densityComfortable: 'Հարմարավետ', + densityCompact: 'Կոմպակտ', + columns: 'Սյունակներ', + column_sku: 'SKU', + column_brand: 'Ապրանքանիշ', + column_price: 'Գին', + column_stock: 'Մնացորդ', + column_visibility: 'Տեսանելիություն', + column_updated: 'Վերջին փոփոխություն', + create: 'Ստեղծել ապրանք', + edit: 'Խմբագրել ապրանքը', + duplicate: 'Կրկնօրինակել', + selectedCount: 'ընտրված է', + bulkShow: 'Հրապարակել', + bulkHide: 'Թաքցնել', + bulkAssignCategory: 'Նշանակել կատեգորիա', + bulkAssignTags: 'Նշանակել պիտակներ', + bulkDuplicateAction: 'Կրկնօրինակել', + bulkExportAction: 'Արտահանել', + bulkDelete: 'Ջնջել', + tagsPlaceholder: 'օր․՝ ամառ, զեղչ', + name: 'Անուն', + brand: 'Ապրանքանիշ', + healthColumn: 'Որակ', + actions: 'Գործողություններ', + archive: 'Արխիվացնել', + delete: 'Ջնջել', + items: 'ապրանք', + loadMore: 'Բեռնել ավելին', + noImage: 'Առանց պատկերի', + slug: 'URL հասցե', + barcode: 'Շտրիխկոդ', + priority: 'Առաջնահերթություն', + shortDescription: 'Կարճ նկարագրություն', + translations: 'Թարգմանություններ', + primaryImage: 'Հիմնական պատկեր', + primaryImageHint: 'Հիմնական լուսանկարը, որ երևում է ցանկերում և որոնման արդյունքներում։', + gallery: 'Պատկերասրահ', + galleryHint: 'Լրացուցիչ լուսանկարներ ապրանքի էջում։ Կարգը փոխեք սլաքներով։', + moveLeft: 'Տեղափոխել ավելի վաղ', + moveRight: 'Տեղափոխել ավելի ուշ', + videos: 'Տեսանյութեր (մեկ հղում մեկ տողում)', + discount: 'Զեղչ %', + currency: 'Արժույթ', + stockQuantity: 'Պահեստի քանակ', + stockQuantityHint: 'Քանի հատ է հասանելի վաճառքի համար հենց հիմա։', + availabilityStatus: 'Հասանելիություն', + availabilityStatusHint: 'Ինչ է տեսնում հաճախորդը՝ առկա է, քիչ է մնացել, թե առկա չէ։', + availabilityNote: 'Հասանելիության նշում', + availabilityNoteHint: 'Լրացուցիչ մանրամասն հաճախորդի համար, օր․՝ «Առաքում 3 օրում»։', + reservedNote: 'Ամրագրված պաշարը դեռ առանձին չի հաշվառվում․ այստեղ ցուցադրվում է ընդհանուր հասանելի քանակը։', + relatedProducts: 'Առնչվող ապրանքներ', + specifications: 'Բնութագրեր', + attributes: 'Հատկանիշներ', + addRow: 'Ավելացնել տող', + removeRow: 'Հեռացնել', + rowKey: 'Անուն', + rowValue: 'Արժեք', + addVariant: 'Ավելացնել տարբերակ', + variantName: 'Տարբերակի անուն', + variantsHint: 'Յուրաքանչյուր տարբերակ այս ապրանքի առանձին գնվող տարբերակ է (օր․՝ չափս կամ գույն)՝ իր գնով և մնացորդով։', + seoExplain: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են գտնել այս ապրանքը։', + searchTitle: 'Որոնման վերնագիր', + searchTitleHint: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ։', + searchDescription: 'Որոնման նկարագրություն', + searchDescriptionHint: 'Կարճ նկարագրություն՝ վերնագրի տակ, որոնման արդյունքներում։', + seoMissingTitle: 'Ավելացրեք որոնման վերնագիր, որպեսզի ապրանքը գտնվի։', + seoMissingDescription: 'Ավելացրեք որոնման նկարագրություն՝ ապրանքի ցուցադրումը բարելավելու համար։', + keywords: 'Հիմնաբառեր', + featured: 'Ընտրված', + recommended: 'Երաշխավորված', + new: 'Նոր', + bestseller: 'Բեսթսելլեր', + badges: 'Կրծքանշաններ (ստորակետերով)', + htmlDescription: 'Մանրամասն նկարագրություն', + customer: 'Հաճախորդների արձագանք', + reviewsReadonly: 'Կարծիքներ', + noReviews: 'Կարծիքներ դեռ չկան։', + questionsReadonly: 'Հարցեր', + noQuestions: 'Հարցեր դեռ չկան։', + save: 'Պահպանել ապրանքը', + groupGeneral: 'Հիմնական', + groupMedia: 'Մեդիա', + groupPricing: 'Գին', + groupInventory: 'Պահեստ', + groupCategories: 'Կատեգորիաներ', + groupAttributes: 'Հատկանիշներ', + groupSeo: 'SEO', + groupVisibility: 'Տեսանելիություն', + groupAdvanced: 'Լրացուցիչ', + healthImages: 'Պատկերներ', + healthSeo: 'SEO', + healthPrice: 'Գին', + healthCategory: 'Կատեգորիա', + healthDescription: 'Նկարագրություն', + healthInventory: 'Պահեստ', + totalProducts: 'Ընդհանուր ապրանքներ', + publishedCount: 'Հրապարակված', + draftsCount: 'Սևագրեր', + outOfStockCount: 'Առկա չէ', + hiddenCount: 'Թաքցված', + missingImagesCount: 'Առանց պատկերի', + missingSeoCount: 'Առանց SEO-ի', + lowQualityCount: 'Ցածր որակի', + recentlyEdited: 'Վերջերս խմբագրված', + recommendedNext: 'Հաջորդ առաջարկվող քայլը', + openAction: 'Բացել', + recommendAddImages: 'Ապրանքը լուսանկար չունի․ ավելացրեք առնվազն մեկ պատկեր։', + recommendAddSeo: 'Ապրանքը չունի որոնման վերնագիր կամ նկարագրություն․ ավելացրեք դրանք։', + recommendRestock: 'Ապրանքը սպառված է․ համալրեք պաշարը կամ թաքցրեք այն։', + recommendNone: 'Հիանալի է․ կատալոգը լավ վիճակում է։', }, adminUsers: { emptyTitle: 'Օգտատերեր չեն գտնվել', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 0a8127c..8c1004a 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -1220,6 +1220,141 @@ export const ru: Translations = { adminProducts: { emptyTitle: 'Товары не найдены', emptyDescription: 'Измените фильтры или создайте новый товар.', + emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.', + search: 'Поиск товаров…', + category: 'Категория', + allCategories: 'Все категории', + visibility: 'Видимость', + allVisibility: 'Любая видимость', + visible: 'Видимый', + hidden: 'Скрытый', + stockStatus: 'Статус наличия', + allStock: 'Любой остаток', + inStock: 'В наличии', + lowStock: 'Мало на складе', + outOfStock: 'Нет в наличии', + sortTitle: 'Имя', + sortPrice: 'Цена', + sortPriority: 'Приоритет', + sortStock: 'Остаток', + sortUpdated: 'Последнее изменение', + showArchived: 'Показать архивные', + infiniteScroll: 'Бесконечная прокрутка', + viewMode: 'Вид', + viewTable: 'Таблица', + viewGrid: 'Сетка', + density: 'Плотность', + densityComfortable: 'Комфортная', + densityCompact: 'Компактная', + columns: 'Столбцы', + column_sku: 'Артикул', + column_brand: 'Бренд', + column_price: 'Цена', + column_stock: 'Остаток', + column_visibility: 'Видимость', + column_updated: 'Последнее изменение', + create: 'Создать товар', + edit: 'Редактировать товар', + duplicate: 'Дублировать', + selectedCount: 'выбрано', + bulkShow: 'Опубликовать', + bulkHide: 'Скрыть', + bulkAssignCategory: 'Назначить категорию', + bulkAssignTags: 'Назначить теги', + bulkDuplicateAction: 'Дублировать', + bulkExportAction: 'Экспорт', + bulkDelete: 'Удалить', + tagsPlaceholder: 'например, лето, скидка', + name: 'Название', + brand: 'Бренд', + healthColumn: 'Качество', + actions: 'Действия', + archive: 'В архив', + delete: 'Удалить', + items: 'товаров', + loadMore: 'Загрузить ещё', + noImage: 'Нет изображения', + slug: 'URL-адрес', + barcode: 'Штрихкод', + priority: 'Приоритет', + shortDescription: 'Краткое описание', + translations: 'Переводы', + primaryImage: 'Главное изображение', + primaryImageHint: 'Основное фото, показываемое в списках и результатах поиска.', + gallery: 'Галерея', + galleryHint: 'Дополнительные фото на странице товара. Порядок меняется стрелками.', + moveLeft: 'Переместить раньше', + moveRight: 'Переместить позже', + videos: 'Видео (по одной ссылке на строку)', + discount: 'Скидка %', + currency: 'Валюта', + stockQuantity: 'Количество на складе', + stockQuantityHint: 'Сколько единиц доступно для продажи прямо сейчас.', + availabilityStatus: 'Доступность', + availabilityStatusHint: 'Что видит покупатель: в наличии, заканчивается или нет в наличии.', + availabilityNote: 'Примечание о доступности', + availabilityNoteHint: 'Дополнительная информация для покупателя, например «Доставка за 3 дня».', + reservedNote: 'Резерв пока не учитывается отдельно — здесь показано общее доступное количество.', + relatedProducts: 'Похожие товары', + specifications: 'Характеристики', + attributes: 'Атрибуты', + addRow: 'Добавить строку', + removeRow: 'Удалить', + rowKey: 'Название', + rowValue: 'Значение', + addVariant: 'Добавить вариант', + variantName: 'Название варианта', + variantsHint: 'Каждый вариант — это отдельный покупаемый вариант товара (например, размер или цвет) со своей ценой и остатком.', + seoExplain: 'Это то, что видно в результатах поиска — понятные заголовок и описание помогают найти товар.', + searchTitle: 'Заголовок для поиска', + searchTitleHint: 'Заголовок, показываемый в результатах поиска. Делайте его коротким.', + searchDescription: 'Описание для поиска', + searchDescriptionHint: 'Краткое описание под заголовком в результатах поиска.', + seoMissingTitle: 'Добавьте заголовок для поиска, чтобы товар можно было найти.', + seoMissingDescription: 'Добавьте описание для поиска, чтобы улучшить отображение товара.', + keywords: 'Ключевые слова', + featured: 'Рекомендуемый', + recommended: 'В подборке', + new: 'Новинка', + bestseller: 'Хит продаж', + badges: 'Значки (через запятую)', + htmlDescription: 'Подробное описание', + customer: 'Отзывы покупателей', + reviewsReadonly: 'Отзывы', + noReviews: 'Пока нет отзывов.', + questionsReadonly: 'Вопросы', + noQuestions: 'Пока нет вопросов.', + save: 'Сохранить товар', + groupGeneral: 'Основное', + groupMedia: 'Медиа', + groupPricing: 'Цена', + groupInventory: 'Склад', + groupCategories: 'Категории', + groupAttributes: 'Атрибуты', + groupSeo: 'SEO', + groupVisibility: 'Видимость', + groupAdvanced: 'Дополнительно', + healthImages: 'Изображения', + healthSeo: 'SEO', + healthPrice: 'Цена', + healthCategory: 'Категория', + healthDescription: 'Описание', + healthInventory: 'Склад', + totalProducts: 'Всего товаров', + publishedCount: 'Опубликовано', + draftsCount: 'Черновики', + outOfStockCount: 'Нет в наличии', + hiddenCount: 'Скрыто', + missingImagesCount: 'Без изображений', + missingSeoCount: 'Без SEO', + lowQualityCount: 'Низкое качество', + recentlyEdited: 'Недавно изменённые', + recommendedNext: 'Рекомендуемый следующий шаг', + openAction: 'Открыть', + recommendAddImages: 'У товара нет фото — добавьте хотя бы одно изображение.', + recommendAddSeo: 'У товара нет заголовка или описания для поиска — добавьте их.', + recommendRestock: 'Товар закончился на складе — пополните запас или скройте его.', + recommendNone: 'Отлично — каталог в хорошем состоянии.', }, adminUsers: { emptyTitle: 'Пользователи не найдены', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 382cc64..554d43b 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1232,6 +1232,141 @@ export interface Translations { adminProducts: { emptyTitle: string; emptyDescription: string; + emptyGuide: string; + search: string; + category: string; + allCategories: string; + visibility: string; + allVisibility: string; + visible: string; + hidden: string; + stockStatus: string; + allStock: string; + inStock: string; + lowStock: string; + outOfStock: string; + sortTitle: string; + sortPrice: string; + sortPriority: string; + sortStock: string; + sortUpdated: string; + showArchived: string; + infiniteScroll: string; + viewMode: string; + viewTable: string; + viewGrid: string; + density: string; + densityComfortable: string; + densityCompact: string; + columns: string; + column_sku: string; + column_brand: string; + column_price: string; + column_stock: string; + column_visibility: string; + column_updated: string; + create: string; + edit: string; + duplicate: string; + selectedCount: string; + bulkShow: string; + bulkHide: string; + bulkAssignCategory: string; + bulkAssignTags: string; + bulkDuplicateAction: string; + bulkExportAction: string; + bulkDelete: string; + tagsPlaceholder: string; + name: string; + brand: string; + healthColumn: string; + actions: string; + archive: string; + delete: string; + items: string; + loadMore: string; + noImage: string; + slug: string; + barcode: string; + priority: string; + shortDescription: string; + translations: string; + primaryImage: string; + primaryImageHint: string; + gallery: string; + galleryHint: string; + moveLeft: string; + moveRight: string; + videos: string; + discount: string; + currency: string; + stockQuantity: string; + stockQuantityHint: string; + availabilityStatus: string; + availabilityStatusHint: string; + availabilityNote: string; + availabilityNoteHint: string; + reservedNote: string; + relatedProducts: string; + specifications: string; + attributes: string; + addRow: string; + removeRow: string; + rowKey: string; + rowValue: string; + addVariant: string; + variantName: string; + variantsHint: string; + seoExplain: string; + searchTitle: string; + searchTitleHint: string; + searchDescription: string; + searchDescriptionHint: string; + seoMissingTitle: string; + seoMissingDescription: string; + keywords: string; + featured: string; + recommended: string; + new: string; + bestseller: string; + badges: string; + htmlDescription: string; + customer: string; + reviewsReadonly: string; + noReviews: string; + questionsReadonly: string; + noQuestions: string; + save: string; + groupGeneral: string; + groupMedia: string; + groupPricing: string; + groupInventory: string; + groupCategories: string; + groupAttributes: string; + groupSeo: string; + groupVisibility: string; + groupAdvanced: string; + healthImages: string; + healthSeo: string; + healthPrice: string; + healthCategory: string; + healthDescription: string; + healthInventory: string; + totalProducts: string; + publishedCount: string; + draftsCount: string; + outOfStockCount: string; + hiddenCount: string; + missingImagesCount: string; + missingSeoCount: string; + lowQualityCount: string; + recentlyEdited: string; + recommendedNext: string; + openAction: string; + recommendAddImages: string; + recommendAddSeo: string; + recommendRestock: string; + recommendNone: string; }; adminUsers: { emptyTitle: string;