diff --git a/docs/ADMIN.md b/docs/ADMIN.md index a20e13a..4d37854 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -197,6 +197,17 @@ src/app/features/admin/categories/ seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope (`docs/SPRINT-PLAN.md`). +## Sprint 21 - Product Management completion + +- **Categories now real**: `AdminProductsLocalGateway` seeds its category dropdown from `AdminCategoriesLocalGateway.loadCategories()` (Sprint 20) instead of raw `BackofficeDataService.loadCategories()` - product `categoryId` now points at real admin-managed categories. +- **Archive/restore**: `AdminProduct.archived` (soft, distinct from `visible`). List has an "include archived" filter + per-row Archive/Restore action; archived products excluded by default (mirrors categories' `deletedAt`/restore pattern). +- **Barcode**: added alongside `sku`. +- **Variants**: lightweight `AdminProductVariant[]` (`name`/`price`/`quantity`), edited as `name|price|quantity` lines (same textarea-parse convention as `specifications`/`attributes`). Not a full options-matrix variant system - scoped to what the model/backend contract actually needs today. +- **Related products**: `relatedProductIds: string[]`, checkbox picker in the editor sourced from `AdminProductFormComponent`'s `allProducts` input - which is `AdminProductsFacade.products()`, i.e. whatever page is currently loaded in the facade (usually primed by navigating from the list). Not a full catalog search; fine for the current mock-data scale, worth revisiting if `AdminProductsLocalGateway` is ever swapped for a real API with more than a page of products. +- **Gallery**: `media.gallery` now built via the shared `MediaPickerComponent` (add/remove thumbnails) instead of a raw URL textarea; `media.images`/`media.videos` unchanged (still textarea, out of this ticket's scope). +- **Preview**: simple read-only line in the editor showing computed discounted price. +- **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged). + ## Known gaps / backend needs - **Dashboard metrics endpoint.** Categories/Products counts are computed diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 1798caf..25ff982 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -85,9 +85,9 @@ Backend must also support content moderation/validation on publish (disallow dan ## 6. Products -**Current behavior:** `features/admin/products/` is fully built (list + editor pages) against `AdminProductsLocalGateway` (swappable via an injection token, same pattern as everywhere else) — i.e. it's ready for a real API gateway, but one has never been implemented. +**Current behavior (Sprint 21):** `features/admin/products/` is fully built (list + editor pages) against `AdminProductsLocalGateway` (swappable via an injection token, same pattern as everywhere else) — i.e. it's ready for a real API gateway, but one has never been implemented. Sprint 21 added `barcode`, `archived` (soft archive/restore), `variants` (`{name, price, quantity}[]`), `relatedProductIds`, and wired the category dropdown to `AdminCategoriesGateway` (see item 5) instead of its own seed. -**Needed:** product CRUD endpoints matching the existing storefront product contract (`itemID`, `name`, `price`, `currency`, `categoryID`, `visible`, `discount`, `images`, `badges`, `media`, `specificationGroups`, `variantOptions`, `relatedCollections` — see the Product Engagement / Product Experience 2.0 fields folded from prior sprint reports). +**Needed:** product CRUD endpoints matching the current `AdminProduct` shape (`src/app/features/admin/products/models/admin-product.model.ts`) — `itemID`, `name`, `price`, `currency`, `categoryID`, `visible`, `archived`, `discount`, `images`, `badges`, `media`, `specifications`/`attributes`, `variants`, `relatedProductIds`, plus a bulk endpoint matching `PATCH /items/bulk`-style semantics for `applyBulkVisibility`/`applyBulkDelete`. **Frontend files:** implement `AdminProductsApiGateway` alongside the existing `AdminProductsLocalGateway` and rebind the injection token — `features/admin/products/pages/*` and the facade do not change. diff --git a/docs/SPRINT-PLAN.md b/docs/SPRINT-PLAN.md index 6f05c4d..a58105d 100644 --- a/docs/SPRINT-PLAN.md +++ b/docs/SPRINT-PLAN.md @@ -24,12 +24,13 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin - Commit: `feat(admin): complete category management` - Note: `admin/products`' category dropdown still uses its own `AdminProductsGateway.loadCategories()`, not this new gateway — unification deferred to Sprint 21 (documented in BACKEND.md). -## Sprint 21 — Product Management completion -- [ ] Audit gaps vs list: duplicate/archive already exist? verify; add missing (archive state, restore draft, related products, variant editor, price/currency/discount editor, inventory/SKU/barcode fields already present—confirm) -- [ ] Wire products to real AdminCategoriesGateway (replace ad-hoc category options) -- [ ] Gallery via MediaPickerComponent, translation editor, preview, infinite-scroll option on list -- [ ] Update `docs/ADMIN.md`, `docs/BACKEND.md` +## Sprint 21 — Product Management completion ✅ done +- [x] Audit + add missing: `archived` (soft, list filter + archive/restore action), `barcode`, `variants` (lightweight name|price|quantity), `relatedProductIds` (checkbox picker) +- [x] Wired products to real `AdminCategoriesGateway` (`AdminProductsLocalGateway` now seeds from `AdminCategoriesLocalGateway`, replacing its own `BackofficeDataService` category seed) +- [x] Gallery via `MediaPickerComponent` (add/remove thumbnails); translation editor already existed (Sprint pre-19); preview (computed discounted price); infinite-scroll toggle on list (`facade.loadMore()` appends vs pagination) +- [x] Updated `docs/ADMIN.md` (new Sprint 21 section), `docs/BACKEND.md` (item 6 rewritten with real field list) - Commit: `feat(admin): complete product management` +- Known trade-off: related-products picker sources from whatever page is currently loaded in `AdminProductsFacade.products()`, not a full catalog search — documented in ADMIN.md, fine at current mock scale. ## Sprint 22 — Media System hardening - [ ] Folder support, tags, search in Media Manager 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 b4798eb..8a107ba 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 @@ -9,6 +9,9 @@ + + + @@ -17,12 +20,21 @@ +

{{ 'adminProducts.media' | translate }}

- +
@@ -93,6 +105,23 @@ +

{{ 'adminProducts.variants' | translate }}

+
+ +
+ +

{{ 'adminProducts.relatedProducts' | translate }}

+ + +

{{ 'adminProducts.preview' | translate }}

+

{{ product.name || ('adminProducts.name' | translate) }} — {{ finalPrice() }} {{ product.currency }} + @if (product.discount > 0) { {{ product.price }} {{ product.currency }} } +

+

{{ 'adminProducts.customer' | translate }}

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 index b46432e..cab8ce4 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.scss +++ b/src/app/features/admin/products/components/admin-product-form.component.scss @@ -11,5 +11,12 @@ 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; } +.gallery-grid { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.gallery-item { position: relative; width: 64px; height: 64px; } +.gallery-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; border: 1px solid var(--border-color, #d3dad9); } +.gallery-item button { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; border: none; background: #d9433c; color: #fff; cursor: pointer; } +.related-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.price-preview { font-weight: 600; } .actions { display: flex; justify-content: flex-end; } +@media (max-width: 700px) { .related-grid { grid-template-columns: 1fr; } } @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 index fea5c77..91e1bdb 100644 --- a/src/app/features/admin/products/components/admin-product-form.component.ts +++ b/src/app/features/admin/products/components/admin-product-form.component.ts @@ -1,15 +1,17 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { AdminProduct, AdminProductCategoryOption } from '../models/admin-product.model'; +import { AdminProduct, AdminProductCategoryOption, AdminProductVariant } from '../models/admin-product.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'; @Component({ selector: 'app-admin-product-form', standalone: true, - imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent], + imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, FormFieldComponent, MediaPickerComponent], templateUrl: './admin-product-form.component.html', styleUrls: ['./admin-product-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -17,8 +19,11 @@ import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field. export class AdminProductFormComponent { @Input({ required: true }) product!: AdminProduct; @Input() categories: AdminProductCategoryOption[] = []; + @Input() allProducts: AdminProduct[] = []; @Input() mode: 'create' | 'edit' | 'duplicate' = 'create'; + protected mediaPickerOpen = false; + @Output() productChange = new EventEmitter>(); @Output() save = new EventEmitter(); @@ -59,4 +64,42 @@ export class AdminProductFormComponent { joinKeyValue(items: Array<{ key: string; value: string }>): string { return items.map(item => `${item.key}|${item.value}`).join('\n'); } + + updateVariants(value: string): void { + const variants: AdminProductVariant[] = value.split('\n').map(line => line.trim()).filter(Boolean).map(line => { + const [name, price, quantity] = line.split('|'); + return { name: name?.trim() ?? '', price: Number(price?.trim() ?? 0) || 0, quantity: Number(quantity?.trim() ?? 0) || 0 }; + }); + this.productChange.emit({ variants }); + } + + joinVariants(variants: AdminProductVariant[]): string { + return variants.map(variant => `${variant.name}|${variant.price}|${variant.quantity}`).join('\n'); + } + + toggleRelated(id: string, checked: boolean): void { + const current = this.product.relatedProductIds; + this.productChange.emit({ relatedProductIds: checked ? [...new Set([...current, id])] : current.filter(item => item !== id) }); + } + + otherProducts(): AdminProduct[] { + return this.allProducts.filter(item => item.id !== this.product.id); + } + + openMediaPicker(): void { + this.mediaPickerOpen = true; + } + + addGalleryImage(asset: MediaAsset): void { + this.productChange.emit({ media: { ...this.product.media, gallery: [...this.product.media.gallery, asset.url] } }); + this.mediaPickerOpen = false; + } + + removeGalleryImage(index: number): void { + this.productChange.emit({ media: { ...this.product.media, gallery: this.product.media.gallery.filter((_, i) => i !== index) } }); + } + + finalPrice(): number { + return Math.round(this.product.price * (1 - this.product.discount / 100)); + } } 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 index 1719d28..14af360 100644 --- a/src/app/features/admin/products/components/admin-products-list.component.html +++ b/src/app/features/admin/products/components/admin-products-list.component.html @@ -26,6 +26,8 @@ + +
{{ 'adminProducts.create' | translate }} @@ -68,6 +70,11 @@ {{ 'adminProducts.edit' | translate }} {{ 'adminProducts.duplicate' | translate }} + @if (product.archived) { + {{ 'adminCategories.restore' | translate }} + } @else { + {{ 'adminProducts.archive' | translate }} + } {{ 'adminProducts.delete' | translate }} @@ -76,7 +83,13 @@
- {{ total }} {{ 'adminProducts.items' | translate }} - + {{ products.length }} / {{ total }} {{ 'adminProducts.items' | translate }} + @if (infiniteScroll) { + @if (products.length < total) { + {{ 'adminProducts.loadMore' | translate }} + } + } @else { + + }
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 38a9c61..29e425f 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 @@ -23,6 +23,7 @@ export class AdminProductsListComponent { @Input() total = 0; @Input() selectedIds: string[] = []; @Input() loading = false; + @Input() infiniteScroll = false; @Output() filtersChange = new EventEmitter>(); @Output() create = new EventEmitter(); @@ -33,6 +34,10 @@ export class AdminProductsListComponent { @Output() selectAll = new EventEmitter(); @Output() bulkVisibility = new EventEmitter(); @Output() bulkDelete = new EventEmitter(); + @Output() archive = new EventEmitter(); + @Output() restore = new EventEmitter(); + @Output() loadMore = new EventEmitter(); + @Output() infiniteScrollToggle = 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 index cf2c585..848c689 100644 --- a/src/app/features/admin/products/facade/admin-products.facade.ts +++ b/src/app/features/admin/products/facade/admin-products.facade.ts @@ -14,12 +14,14 @@ export class AdminProductsFacade { categoryId: null, visibility: 'all', stock: 'all', + includeArchived: false, sort: 'title', page: 1, pageSize: 10, }); readonly products = signal([]); readonly total = signal(0); + readonly infiniteScroll = signal(false); readonly categories = signal([]); readonly loading = signal(false); readonly selectedIds = signal([]); @@ -44,6 +46,35 @@ export class AdminProductsFacade { }); } + setInfiniteScroll(enabled: boolean): void { + this.infiniteScroll.set(enabled); + this.updateFilters({ page: 1 }); + } + + loadMore(): void { + if (!this.infiniteScroll()) return; + const nextPage = this.filters().page + 1; + this.loading.set(true); + const nextFilters = { ...this.filters(), page: nextPage }; + this.gateway.loadProducts(nextFilters).pipe(take(1)).subscribe({ + next: result => { + this.filters.set(nextFilters); + this.products.update(current => [...current, ...result.items]); + this.total.set(result.total); + this.loading.set(false); + }, + error: () => this.loading.set(false) + }); + } + + archiveOne(id: string): void { + this.gateway.archiveProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + } + + restoreOne(id: string): void { + this.gateway.restoreProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); + } + loadCategories(): void { this.gateway.loadCategories().pipe(take(1)).subscribe({ next: categories => this.categories.set(categories) }); } diff --git a/src/app/features/admin/products/models/admin-product.model.ts b/src/app/features/admin/products/models/admin-product.model.ts index 8c2bfd4..ffd9965 100644 --- a/src/app/features/admin/products/models/admin-product.model.ts +++ b/src/app/features/admin/products/models/admin-product.model.ts @@ -13,6 +13,12 @@ export interface AdminProductSpecification { value: string; } +export interface AdminProductVariant { + name: string; + price: number; + quantity: number; +} + export interface AdminProductAttribute { key: string; value: string; @@ -50,9 +56,11 @@ export interface AdminProduct { name: string; slug: string; sku: string; + barcode: string; brand: string; categoryId: string; visible: boolean; + archived: boolean; priority: number; media: AdminProductMedia; price: number; @@ -65,6 +73,8 @@ export interface AdminProduct { htmlDescription: string; specifications: AdminProductSpecification[]; attributes: AdminProductAttribute[]; + variants: AdminProductVariant[]; + relatedProductIds: string[]; translations: Record; seo: AdminProductSeo; featured: boolean; @@ -83,6 +93,7 @@ export interface AdminProductListFilters { categoryId: string | null; visibility: 'all' | 'visible' | 'hidden'; stock: 'all' | AdminProductStockStatus; + includeArchived: boolean; sort: AdminProductSort; page: number; pageSize: number; 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 e7572ab..46bd843 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 785d9bc..f10ec67 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 @@ -15,11 +15,16 @@ import { LanguageService } from '../../../../services/language.service'; [total]="facade.total()" [selectedIds]="facade.selectedIds()" [loading]="facade.loading()" + [infiniteScroll]="facade.infiniteScroll()" (filtersChange)="facade.updateFilters($event)" (create)="create()" (edit)="edit($event)" (duplicate)="duplicate($event)" (delete)="facade.deleteOne($event)" + (archive)="facade.archiveOne($event)" + (restore)="facade.restoreOne($event)" + (loadMore)="facade.loadMore()" + (infiniteScrollToggle)="facade.setInfiniteScroll($event)" (selectionChange)="facade.toggleSelection($event.id, $event.checked)" (selectAll)="facade.toggleAll($event)" (bulkVisibility)="facade.applyBulkVisibility($event)" 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 index a7735e0..c71f1c0 100644 --- a/src/app/features/admin/products/services/admin-products-form.factory.ts +++ b/src/app/features/admin/products/services/admin-products-form.factory.ts @@ -9,9 +9,11 @@ export class AdminProductsFormFactory { name: '', slug: '', sku: '', + barcode: '', brand: '', categoryId: '', visible: true, + archived: false, priority: 0, media: { images: [], gallery: [], videos: [] }, price: 0, @@ -24,6 +26,8 @@ export class AdminProductsFormFactory { htmlDescription: '', specifications: [], attributes: [], + variants: [], + relatedProductIds: [], translations: { en: {}, ru: {}, hy: {} }, seo: { metaTitle: '', metaDescription: '', keywords: '' }, featured: false, 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 index f5a6f84..1ec104a 100644 --- a/src/app/features/admin/products/services/admin-products-gateway.interface.ts +++ b/src/app/features/admin/products/services/admin-products-gateway.interface.ts @@ -9,4 +9,6 @@ export interface AdminProductsGateway { updateProduct(product: AdminProduct): Observable; deleteProduct(id: string): Observable; duplicateProduct(id: string): Observable; + archiveProduct(id: string): Observable; + restoreProduct(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 index e7d3eaa..531f5a7 100644 --- a/src/app/features/admin/products/services/admin-products-local.gateway.ts +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -2,22 +2,27 @@ 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 { ProductCardConfig } from '../../../../shared/models/ui'; import { AdminProduct, AdminProductCategoryOption, AdminProductListFilters, AdminProductsListResult } from '../models/admin-product.model'; import { AdminProductsGateway } from './admin-products-gateway.interface'; +import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway'; @Injectable({ providedIn: 'root' }) export class AdminProductsLocalGateway implements AdminProductsGateway { private productsCache: AdminProduct[] | null = null; private categoriesCache: AdminProductCategoryOption[] | null = null; - constructor(private readonly backofficeData: BackofficeDataService) {} + constructor( + private readonly backofficeData: BackofficeDataService, + private readonly categoriesGateway: AdminCategoriesLocalGateway + ) {} loadProducts(filters: AdminProductListFilters): Observable { return new Observable(subscriber => { this.ensureData().then(() => { const all = this.productsCache ?? []; const filtered = all + .filter(product => filters.includeArchived || !product.archived) .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)) @@ -76,6 +81,7 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { const duplicated: AdminProduct = { ...source, + archived: false, id: `${source.id}-copy-${Date.now()}`, sku: `${source.sku}-COPY`, slug: `${source.slug}-copy-${Date.now()}`, @@ -87,15 +93,33 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { return of(duplicated).pipe(delay(50)); } + archiveProduct(id: string): Observable { + this.productsCache = (this.productsCache ?? []).map(item => item.id === id ? { ...item, archived: true, updatedAt: new Date().toISOString() } : item); + return of(void 0).pipe(delay(50)); + } + + restoreProduct(id: string): Observable { + const restored = (this.productsCache ?? []).find(item => item.id === id); + if (!restored) { + return of(null); + } + const updated = { ...restored, archived: false, updatedAt: new Date().toISOString() }; + this.productsCache = (this.productsCache ?? []).map(item => item.id === id ? updated : item); + return of(updated).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))); + const categories = await new Promise(resolve => + this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false }) + .subscribe(value => resolve(value.map(category => ({ id: category.id, title: category.title })))) + ); 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 })); + this.categoriesCache = categories; } private toAdminProduct(product: ProductCardConfig, categoryId: string): AdminProduct { @@ -104,9 +128,11 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { name: product.title, slug: product.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), sku: product.sku, + barcode: '', brand: product.subtitle ?? 'Default Brand', categoryId, visible: true, + archived: false, priority: 0, media: { images: [product.imageUrl], @@ -125,6 +151,8 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { htmlDescription: `

${product.subtitle ?? product.title}

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

${product.subtitle ?? product.title}

` }, ru: {},