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 new file mode 100644 index 0000000..c943ca3 --- /dev/null +++ b/src/app/features/admin/products/components/admin-product-form.component.html @@ -0,0 +1,81 @@ +
+
+ + + + + + + +
+ +

{{ 'adminProducts.media' | translate }}

+
+ + + +
+ +

{{ 'adminProducts.pricing' | translate }}

+
+ + + +
+ +

{{ 'adminProducts.inventory' | translate }}

+
+ + + +
+ +

{{ 'adminProducts.content' | translate }}

+
+ + + + +
+ +

{{ 'adminProducts.translations' | translate }}

+ @for (locale of ['en','ru','hy']; track locale) { +
+ + + +
+ } + +

{{ 'adminProducts.seo' | translate }}

+
+ + + +
+ +

{{ 'adminProducts.marketplace' | translate }}

+
+ + + + + +
+ +

{{ 'adminProducts.customer' | translate }}

+
+
+

{{ 'adminProducts.reviewsReadonly' | translate }}

+ @if (product.reviews.length === 0) {

{{ 'adminProducts.noReviews' | translate }}

} + @for (review of product.reviews; track review.id) {

{{ review.author }} ยท {{ review.rating }}/5
{{ review.text }}

} +
+
+

{{ 'adminProducts.questionsReadonly' | translate }}

+ @if (product.questions.length === 0) {

{{ 'adminProducts.noQuestions' | translate }}

} + @for (question of product.questions; track question.id) {

{{ question.question }}
{{ question.answer || '-' }}

} +
+
+ +
+
diff --git a/src/app/features/admin/products/components/admin-product-form.component.scss b/src/app/features/admin/products/components/admin-product-form.component.scss new file mode 100644 index 0000000..de3daf6 --- /dev/null +++ b/src/app/features/admin/products/components/admin-product-form.component.scss @@ -0,0 +1,16 @@ +.form-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; } +.grid { display: grid; gap: 12px; } +.grid.one { grid-template-columns: 1fr; } +.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +label { display: grid; gap: 6px; font-weight: 600; } +label.full { grid-column: 1 / -1; } +label.check { display: flex; align-items: center; gap: 8px; } +input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; font: inherit; } +input[type='checkbox'] { width: auto; } +.sub-block { border-top: 1px dashed #d9e2e1; padding-top: 12px; } +.readonly-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.readonly-grid article { border: 1px solid #ececec; border-radius: 12px; padding: 12px; } +.actions { display: flex; justify-content: flex-end; } +button { min-height: 42px; border-radius: 10px; border: 1px solid #497671; background: #497671; color: #fff; padding: 0 14px; font-weight: 700; cursor: pointer; } +@media (max-width: 900px) { .grid.two, .grid.three, .readonly-grid { grid-template-columns: 1fr; } } diff --git a/src/app/features/admin/products/components/admin-product-form.component.ts b/src/app/features/admin/products/components/admin-product-form.component.ts new file mode 100644 index 0000000..1f5f09a --- /dev/null +++ b/src/app/features/admin/products/components/admin-product-form.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AdminProduct, AdminProductCategoryOption } from '../models/admin-product.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-admin-product-form', + standalone: true, + imports: [FormsModule, TranslatePipe], + templateUrl: './admin-product-form.component.html', + styleUrls: ['./admin-product-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminProductFormComponent { + @Input({ required: true }) product!: AdminProduct; + @Input() categories: AdminProductCategoryOption[] = []; + @Input() mode: 'create' | 'edit' | 'duplicate' = 'create'; + + @Output() productChange = new EventEmitter>(); + @Output() save = new EventEmitter(); + + updateField(key: K, value: AdminProduct[K]): void { + this.productChange.emit({ [key]: value } as Partial); + } + + updateTranslation(locale: string, field: 'name' | 'shortDescription' | 'htmlDescription' | 'seoTitle' | 'seoDescription', value: string): void { + this.productChange.emit({ + translations: { + ...this.product.translations, + [locale]: { + ...(this.product.translations[locale] ?? {}), + [field]: value, + } + } + }); + } + + updateList(type: 'specifications' | 'attributes' | 'badges' | 'images' | 'gallery' | 'videos', value: string): void { + if (type === 'badges') { + this.productChange.emit({ badges: value.split(',').map(item => item.trim()).filter(Boolean) }); + return; + } + + if (type === 'images' || type === 'gallery' || type === 'videos') { + this.productChange.emit({ media: { ...this.product.media, [type]: value.split('\n').map(item => item.trim()).filter(Boolean) } }); + return; + } + + const entries = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { + const [key, raw] = line.split('|'); + return { key: key?.trim() ?? '', value: raw?.trim() ?? '' }; + }); + this.productChange.emit({ [type]: entries } as Partial); + } + + joinKeyValue(items: Array<{ key: string; value: string }>): string { + return items.map(item => `${item.key}|${item.value}`).join('\n'); + } +} diff --git a/src/app/features/admin/products/components/admin-products-list.component.html b/src/app/features/admin/products/components/admin-products-list.component.html new file mode 100644 index 0000000..b2b0fb6 --- /dev/null +++ b/src/app/features/admin/products/components/admin-products-list.component.html @@ -0,0 +1,83 @@ +
+
+
+ + + + + +
+ +
+ + @if (selectedIds.length > 0) { +
+ + + +
+ } + +
+ + + + + + + + + + + + + + + @for (product of products; track product.id) { + + + + + + + + + + + } + +
{{ 'adminProducts.name' | translate }}{{ 'backoffice.sku' | translate }}{{ 'adminProducts.brand' | translate }}{{ 'backoffice.price' | translate }}{{ 'backoffice.status' | translate }}{{ 'adminProducts.visibility' | translate }}{{ 'adminProducts.actions' | translate }}
{{ product.name }}{{ product.sku }}{{ product.brand }}{{ product.price }} {{ product.currency }}{{ product.stockStatus }}{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }} + + + +
+
+ +
+ {{ total }} {{ 'adminProducts.items' | 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 new file mode 100644 index 0000000..0dd74fe --- /dev/null +++ b/src/app/features/admin/products/components/admin-products-list.component.scss @@ -0,0 +1,13 @@ +.admin-products-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; } +.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; } +.filters { display: grid; grid-template-columns: repeat(5, minmax(140px, 1fr)); gap: 10px; flex: 1; } +input, select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } +button { min-height: 40px; border-radius: 10px; border: 1px solid #497671; background: #497671; color: #fff; padding: 0 12px; font-weight: 700; cursor: pointer; } +button.secondary { background: #fff; color: #1e3c38; border-color: var(--border-color, #d3dad9); } +button.danger { border-color: #b91c1c; color: #b91c1c; } +.bulk-actions, .pager, .pager-actions, .actions { display: flex; gap: 10px; align-items: center; } +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 10px; border-bottom: 1px solid #ececec; text-align: left; vertical-align: top; } +@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; } } 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 new file mode 100644 index 0000000..c4f6c6f --- /dev/null +++ b/src/app/features/admin/products/components/admin-products-list.component.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters } from '../models/admin-product.model'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-admin-products-list', + standalone: true, + imports: [FormsModule, TranslatePipe], + templateUrl: './admin-products-list.component.html', + styleUrls: ['./admin-products-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminProductsListComponent { + @Input() products: AdminProduct[] = []; + @Input() categories: AdminProductCategoryOption[] = []; + @Input() filters!: AdminProductListFilters; + @Input() total = 0; + @Input() selectedIds: string[] = []; + @Input() loading = false; + + @Output() filtersChange = new EventEmitter>(); + @Output() create = new EventEmitter(); + @Output() edit = new EventEmitter(); + @Output() duplicate = new EventEmitter(); + @Output() delete = new EventEmitter(); + @Output() selectionChange = new EventEmitter<{ id: string; checked: boolean }>(); + @Output() selectAll = new EventEmitter(); + @Output() bulkVisibility = new EventEmitter(); + @Output() bulkDelete = new EventEmitter(); + + isSelected(id: string): boolean { + return this.selectedIds.includes(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 new file mode 100644 index 0000000..cf2c585 --- /dev/null +++ b/src/app/features/admin/products/facade/admin-products.facade.ts @@ -0,0 +1,112 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { AdminProduct, AdminProductCategoryOption, AdminProductEditorMode, AdminProductListFilters } from '../models/admin-product.model'; +import { AdminProductsFormFactory } from '../services/admin-products-form.factory'; +import { AdminProductsLocalGateway } from '../services/admin-products-local.gateway'; + +@Injectable({ providedIn: 'root' }) +export class AdminProductsFacade { + private readonly gateway = inject(AdminProductsLocalGateway); + private readonly formFactory = inject(AdminProductsFormFactory); + + readonly filters = signal({ + search: '', + categoryId: null, + visibility: 'all', + stock: 'all', + sort: 'title', + page: 1, + pageSize: 10, + }); + readonly products = signal([]); + readonly total = signal(0); + readonly categories = signal([]); + readonly loading = signal(false); + readonly selectedIds = signal([]); + readonly draft = signal(null); + readonly editorMode = signal('create'); + + readonly hasSelection = computed(() => this.selectedIds().length > 0); + + loadList(): void { + this.loading.set(true); + this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({ + next: result => { + this.products.set(result.items); + this.total.set(result.total); + this.loading.set(false); + }, + error: () => { + this.products.set([]); + this.total.set(0); + this.loading.set(false); + } + }); + } + + loadCategories(): void { + this.gateway.loadCategories().pipe(take(1)).subscribe({ next: categories => this.categories.set(categories) }); + } + + updateFilters(patch: Partial): void { + this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 })); + this.loadList(); + } + + 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.products().map(product => product.id) : []); + } + + applyBulkVisibility(visible: boolean): void { + const selected = new Set(this.selectedIds()); + const updates = this.products().filter(product => selected.has(product.id)).map(product => ({ ...product, visible, updatedAt: new Date().toISOString() })); + updates.forEach(product => this.gateway.updateProduct(product).pipe(take(1)).subscribe()); + this.selectedIds.set([]); + this.loadList(); + } + + applyBulkDelete(): void { + const ids = [...this.selectedIds()]; + ids.forEach(id => this.gateway.deleteProduct(id).pipe(take(1)).subscribe()); + this.selectedIds.set([]); + this.loadList(); + } + + startCreate(): void { + this.editorMode.set('create'); + this.draft.set(this.formFactory.createEmpty()); + } + + loadForEdit(id: string, mode: AdminProductEditorMode = 'edit'): void { + this.editorMode.set(mode); + if (mode === 'duplicate') { + this.gateway.duplicateProduct(id).pipe(take(1)).subscribe({ next: product => this.draft.set(product) }); + return; + } + + this.gateway.loadProduct(id).pipe(take(1)).subscribe({ next: product => this.draft.set(product ? { ...product } : null) }); + } + + updateDraft(patch: Partial): void { + this.draft.update(current => current ? ({ ...current, ...patch, updatedAt: new Date().toISOString() }) : current); + } + + saveDraft(): void { + const draft = this.draft(); + if (!draft) return; + + const request = this.editorMode() === 'create' + ? this.gateway.createProduct(draft) + : this.gateway.updateProduct(draft); + + request.pipe(take(1)).subscribe({ next: () => this.loadList() }); + } + + deleteOne(id: string): void { + this.gateway.deleteProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + } +} diff --git a/src/app/features/admin/products/models/admin-product.model.ts b/src/app/features/admin/products/models/admin-product.model.ts new file mode 100644 index 0000000..8c2bfd4 --- /dev/null +++ b/src/app/features/admin/products/models/admin-product.model.ts @@ -0,0 +1,101 @@ +export type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock'; +export type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated'; +export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate'; + +export interface AdminProductMedia { + images: string[]; + gallery: string[]; + videos: string[]; +} + +export interface AdminProductSpecification { + key: string; + value: string; +} + +export interface AdminProductAttribute { + key: string; + value: string; +} + +export interface AdminProductTranslation { + name?: string; + shortDescription?: string; + htmlDescription?: string; + seoTitle?: string; + seoDescription?: string; +} + +export interface AdminProductSeo { + metaTitle: string; + metaDescription: string; + keywords: string; +} + +export interface AdminProductReview { + id: string; + author: string; + rating: number; + text: string; +} + +export interface AdminProductQuestion { + id: string; + question: string; + answer?: string; +} + +export interface AdminProduct { + id: string; + name: string; + slug: string; + sku: string; + brand: string; + categoryId: string; + visible: boolean; + priority: number; + media: AdminProductMedia; + price: number; + discount: number; + currency: string; + quantity: number; + stockStatus: AdminProductStockStatus; + availability: string; + shortDescription: string; + htmlDescription: string; + specifications: AdminProductSpecification[]; + attributes: AdminProductAttribute[]; + translations: Record; + seo: AdminProductSeo; + featured: boolean; + recommended: boolean; + isNew: boolean; + bestseller: boolean; + badges: string[]; + reviews: AdminProductReview[]; + questions: AdminProductQuestion[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminProductListFilters { + search: string; + categoryId: string | null; + visibility: 'all' | 'visible' | 'hidden'; + stock: 'all' | AdminProductStockStatus; + sort: AdminProductSort; + page: number; + pageSize: number; +} + +export interface AdminProductsListResult { + items: AdminProduct[]; + total: number; + page: number; + pageSize: number; +} + +export interface AdminProductCategoryOption { + id: string; + title: string; +} 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 new file mode 100644 index 0000000..eccc007 --- /dev/null +++ b/src/app/features/admin/products/pages/admin-product-editor-page.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AdminProductsFacade } from '../facade/admin-products.facade'; +import { AdminProductFormComponent } from '../components/admin-product-form.component'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-admin-product-editor-page', + standalone: true, + imports: [AdminProductFormComponent, TranslatePipe], + 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 +}) +export class AdminProductEditorPageComponent { + readonly facade = inject(AdminProductsFacade); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly title = computed(() => this.facade.editorMode() === 'create' ? 'adminProducts.create' : this.facade.editorMode() === 'duplicate' ? 'adminProducts.duplicate' : 'adminProducts.edit'); + + constructor() { + this.facade.loadCategories(); + const id = this.route.snapshot.paramMap.get('id'); + const mode = this.route.snapshot.routeConfig?.path?.includes('duplicate') ? 'duplicate' : this.route.snapshot.routeConfig?.path?.includes('edit') ? 'edit' : 'create'; + if (mode === 'create') { + this.facade.startCreate(); + } else if (id) { + this.facade.loadForEdit(id, mode); + } + } + + save(): void { + this.facade.saveDraft(); + void this.router.navigate(['ru/backoffice/products']); + } +} 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 new file mode 100644 index 0000000..81c1a06 --- /dev/null +++ b/src/app/features/admin/products/pages/admin-products-list-page.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { AdminProductsFacade } from '../facade/admin-products.facade'; +import { AdminProductsListComponent } from '../components/admin-products-list.component'; + +@Component({ + selector: 'app-admin-products-list-page', + standalone: true, + imports: [AdminProductsListComponent], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminProductsListPageComponent { + readonly facade = inject(AdminProductsFacade); + private readonly router = inject(Router); + + constructor() { + this.facade.loadCategories(); + this.facade.loadList(); + } + + create(): void { this.facade.startCreate(); void this.router.navigate(['ru/backoffice/products/create']); } + edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate(['ru/backoffice/products', id, 'edit']); } + duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate(['ru/backoffice/products', id, 'duplicate']); } +} diff --git a/src/app/features/admin/products/services/admin-products-form.factory.ts b/src/app/features/admin/products/services/admin-products-form.factory.ts new file mode 100644 index 0000000..a7735e0 --- /dev/null +++ b/src/app/features/admin/products/services/admin-products-form.factory.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { AdminProduct } from '../models/admin-product.model'; + +@Injectable({ providedIn: 'root' }) +export class AdminProductsFormFactory { + createEmpty(): AdminProduct { + return { + id: `product-${Date.now()}`, + name: '', + slug: '', + sku: '', + brand: '', + categoryId: '', + visible: true, + priority: 0, + media: { images: [], gallery: [], videos: [] }, + price: 0, + discount: 0, + currency: 'RUB', + quantity: 0, + stockStatus: 'in_stock', + availability: 'in_stock', + shortDescription: '', + htmlDescription: '', + specifications: [], + attributes: [], + translations: { en: {}, ru: {}, hy: {} }, + seo: { metaTitle: '', metaDescription: '', keywords: '' }, + featured: false, + recommended: false, + isNew: false, + bestseller: false, + badges: [], + reviews: [], + questions: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } +} diff --git a/src/app/features/admin/products/services/admin-products-gateway.interface.ts b/src/app/features/admin/products/services/admin-products-gateway.interface.ts new file mode 100644 index 0000000..f5a6f84 --- /dev/null +++ b/src/app/features/admin/products/services/admin-products-gateway.interface.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; +import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model'; + +export interface AdminProductsGateway { + loadProducts(filters: AdminProductListFilters): Observable; + loadProduct(id: string): Observable; + loadCategories(): Observable; + createProduct(product: AdminProduct): Observable; + updateProduct(product: AdminProduct): Observable; + deleteProduct(id: string): Observable; + duplicateProduct(id: string): Observable; +} diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.ts b/src/app/features/admin/products/services/admin-products-local.gateway.ts new file mode 100644 index 0000000..e7d3eaa --- /dev/null +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service'; +import { CategoryCardConfig, ProductCardConfig } from '../../../../shared/models/ui'; +import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model'; +import { AdminProductsGateway } from './admin-products-gateway.interface'; + +@Injectable({ providedIn: 'root' }) +export class AdminProductsLocalGateway implements AdminProductsGateway { + private productsCache: AdminProduct[] | null = null; + private categoriesCache: AdminProductCategoryOption[] | null = null; + + constructor(private readonly backofficeData: BackofficeDataService) {} + + loadProducts(filters: AdminProductListFilters): Observable { + return new Observable(subscriber => { + this.ensureData().then(() => { + const all = this.productsCache ?? []; + const filtered = all + .filter(product => !filters.search || `${product.name} ${product.sku} ${product.brand}`.toLowerCase().includes(filters.search.toLowerCase())) + .filter(product => !filters.categoryId || product.categoryId === filters.categoryId) + .filter(product => filters.visibility === 'all' || (filters.visibility === 'visible' ? product.visible : !product.visible)) + .filter(product => filters.stock === 'all' || product.stockStatus === filters.stock) + .sort((left, right) => this.compare(left, right, filters.sort)); + const start = (filters.page - 1) * filters.pageSize; + subscriber.next({ + items: filtered.slice(start, start + filters.pageSize), + total: filtered.length, + page: filters.page, + pageSize: filters.pageSize, + }); + subscriber.complete(); + }); + }).pipe(delay(50)); + } + + loadProduct(id: string): Observable { + return new Observable(subscriber => { + this.ensureData().then(() => { + subscriber.next(this.productsCache?.find(product => product.id === id) ?? null); + subscriber.complete(); + }); + }); + } + + loadCategories(): Observable { + return new Observable(subscriber => { + this.ensureData().then(() => { + subscriber.next(this.categoriesCache ?? []); + subscriber.complete(); + }); + }); + } + + createProduct(product: AdminProduct): Observable { + this.productsCache = [product, ...(this.productsCache ?? [])]; + return of(product).pipe(delay(50)); + } + + updateProduct(product: AdminProduct): Observable { + this.productsCache = (this.productsCache ?? []).map(item => item.id === product.id ? product : item); + return of(product).pipe(delay(50)); + } + + deleteProduct(id: string): Observable { + this.productsCache = (this.productsCache ?? []).filter(product => product.id !== id); + return of(void 0).pipe(delay(50)); + } + + duplicateProduct(id: string): Observable { + const source = (this.productsCache ?? []).find(product => product.id === id); + if (!source) { + return of(null); + } + + const duplicated: AdminProduct = { + ...source, + id: `${source.id}-copy-${Date.now()}`, + sku: `${source.sku}-COPY`, + slug: `${source.slug}-copy-${Date.now()}`, + name: `${source.name} Copy`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + this.productsCache = [duplicated, ...(this.productsCache ?? [])]; + return of(duplicated).pipe(delay(50)); + } + + private async ensureData(): Promise { + if (this.productsCache && this.categoriesCache) { + return; + } + + const products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); + const categories = await new Promise(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value))); + this.productsCache = products.map((product, index) => this.toAdminProduct(product, categories[index % Math.max(1, categories.length)]?.id ?? 'cat-001')); + this.categoriesCache = categories.map(category => ({ id: category.id, title: category.title })); + } + + private toAdminProduct(product: ProductCardConfig, categoryId: string): AdminProduct { + return { + id: product.id, + name: product.title, + slug: product.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), + sku: product.sku, + brand: product.subtitle ?? 'Default Brand', + categoryId, + visible: true, + priority: 0, + media: { + images: [product.imageUrl], + gallery: [product.imageUrl], + videos: [], + }, + price: product.price.amount, + discount: product.price.originalAmount && product.price.originalAmount > product.price.amount + ? Math.round((1 - product.price.amount / product.price.originalAmount) * 100) + : 0, + currency: product.price.currency, + quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25, + stockStatus: product.stockStatus ?? 'in_stock', + availability: product.stockStatus ?? 'in_stock', + shortDescription: product.subtitle ?? '', + htmlDescription: `

${product.subtitle ?? product.title}

`, + specifications: [], + attributes: [], + translations: { + en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `

${product.subtitle ?? product.title}

` }, + ru: {}, + hy: {}, + }, + seo: { + metaTitle: product.title, + metaDescription: product.subtitle ?? product.title, + keywords: (product.tags ?? []).join(', '), + }, + featured: (product.tags ?? []).includes('featured'), + recommended: (product.tags ?? []).includes('recommended'), + isNew: (product.badges ?? []).includes('new'), + bestseller: (product.tags ?? []).includes('bestseller'), + badges: product.badges ?? [], + reviews: [], + questions: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + + private compare(left: AdminProduct, right: AdminProduct, sort: AdminProductListFilters['sort']): number { + switch (sort) { + case 'price': return left.price - right.price; + case 'priority': return left.priority - right.priority; + case 'stock': return left.quantity - right.quantity; + case 'updated': return right.updatedAt.localeCompare(left.updatedAt); + case 'title': + default: + return left.name.localeCompare(right.name); + } + } +}