feat(admin): add product management
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
<section class="form-card">
|
||||||
|
<div class="grid two">
|
||||||
|
<label><span>{{ 'adminProducts.name' | translate }}</span><input type="text" [ngModel]="product.name" (ngModelChange)="updateField('name', $event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.slug' | translate }}</span><input type="text" [ngModel]="product.slug" (ngModelChange)="updateField('slug', $event)" /></label>
|
||||||
|
<label><span>{{ 'backoffice.sku' | translate }}</span><input type="text" [ngModel]="product.sku" (ngModelChange)="updateField('sku', $event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.brand' | translate }}</span><input type="text" [ngModel]="product.brand" (ngModelChange)="updateField('brand', $event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.category' | translate }}</span><select [ngModel]="product.categoryId" (ngModelChange)="updateField('categoryId', $event)">@for (category of categories; track category.id) {<option [ngValue]="category.id">{{ category.title }}</option>}</select></label>
|
||||||
|
<label><span>{{ 'adminProducts.priority' | translate }}</span><input type="number" [ngModel]="product.priority" (ngModelChange)="updateField('priority', +$event)" /></label>
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.visible" (change)="updateField('visible', $any($event.target).checked)" /><span>{{ 'adminProducts.visible' | translate }}</span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.media' | translate }}</h3>
|
||||||
|
<div class="grid one">
|
||||||
|
<label><span>{{ 'adminProducts.images' | translate }}</span><textarea rows="3" [ngModel]="product.media.images.join('\n')" (ngModelChange)="updateList('images', $event)"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.gallery' | translate }}</span><textarea rows="3" [ngModel]="product.media.gallery.join('\n')" (ngModelChange)="updateList('gallery', $event)"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.videos' | translate }}</span><textarea rows="3" [ngModel]="product.media.videos.join('\n')" (ngModelChange)="updateList('videos', $event)"></textarea></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.pricing' | translate }}</h3>
|
||||||
|
<div class="grid three">
|
||||||
|
<label><span>{{ 'backoffice.price' | translate }}</span><input type="number" [ngModel]="product.price" (ngModelChange)="updateField('price', +$event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.discount' | translate }}</span><input type="number" [ngModel]="product.discount" (ngModelChange)="updateField('discount', +$event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.currency' | translate }}</span><input type="text" [ngModel]="product.currency" (ngModelChange)="updateField('currency', $event)" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.inventory' | translate }}</h3>
|
||||||
|
<div class="grid three">
|
||||||
|
<label><span>{{ 'adminProducts.quantity' | translate }}</span><input type="number" [ngModel]="product.quantity" (ngModelChange)="updateField('quantity', +$event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.stockStatus' | translate }}</span><select [ngModel]="product.stockStatus" (ngModelChange)="updateField('stockStatus', $event)"><option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option><option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option><option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option></select></label>
|
||||||
|
<label><span>{{ 'adminProducts.availability' | translate }}</span><input type="text" [ngModel]="product.availability" (ngModelChange)="updateField('availability', $event)" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.content' | translate }}</h3>
|
||||||
|
<div class="grid one">
|
||||||
|
<label><span>{{ 'adminProducts.shortDescription' | translate }}</span><textarea rows="3" [ngModel]="product.shortDescription" (ngModelChange)="updateField('shortDescription', $event)"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.htmlDescription' | translate }}</span><textarea rows="6" [ngModel]="product.htmlDescription" (ngModelChange)="updateField('htmlDescription', $event)"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.specifications' | translate }}</span><textarea rows="5" [ngModel]="joinKeyValue(product.specifications)" (ngModelChange)="updateList('specifications', $event)"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.attributes' | translate }}</span><textarea rows="5" [ngModel]="joinKeyValue(product.attributes)" (ngModelChange)="updateList('attributes', $event)"></textarea></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.translations' | translate }}</h3>
|
||||||
|
@for (locale of ['en','ru','hy']; track locale) {
|
||||||
|
<div class="grid two sub-block">
|
||||||
|
<label><span>{{ 'adminProducts.name' | translate }} {{ locale }}</span><input type="text" [ngModel]="product.translations[locale]?.name || ''" (ngModelChange)="updateTranslation(locale, 'name', $event)" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.shortDescription' | translate }} {{ locale }}</span><input type="text" [ngModel]="product.translations[locale]?.shortDescription || ''" (ngModelChange)="updateTranslation(locale, 'shortDescription', $event)" /></label>
|
||||||
|
<label class="full"><span>{{ 'adminProducts.htmlDescription' | translate }} {{ locale }}</span><textarea rows="4" [ngModel]="product.translations[locale]?.htmlDescription || ''" (ngModelChange)="updateTranslation(locale, 'htmlDescription', $event)"></textarea></label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.seo' | translate }}</h3>
|
||||||
|
<div class="grid one">
|
||||||
|
<label><span>{{ 'adminProducts.metaTitle' | translate }}</span><input type="text" [ngModel]="product.seo.metaTitle" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaTitle: $event } })" /></label>
|
||||||
|
<label><span>{{ 'adminProducts.metaDescription' | translate }}</span><textarea rows="3" [ngModel]="product.seo.metaDescription" (ngModelChange)="productChange.emit({ seo: { ...product.seo, metaDescription: $event } })"></textarea></label>
|
||||||
|
<label><span>{{ 'adminProducts.keywords' | translate }}</span><input type="text" [ngModel]="product.seo.keywords" (ngModelChange)="productChange.emit({ seo: { ...product.seo, keywords: $event } })" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.marketplace' | translate }}</h3>
|
||||||
|
<div class="grid three">
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.featured" (change)="updateField('featured', $any($event.target).checked)" /><span>{{ 'adminProducts.featured' | translate }}</span></label>
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.recommended" (change)="updateField('recommended', $any($event.target).checked)" /><span>{{ 'adminProducts.recommended' | translate }}</span></label>
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.isNew" (change)="updateField('isNew', $any($event.target).checked)" /><span>{{ 'adminProducts.new' | translate }}</span></label>
|
||||||
|
<label class="check"><input type="checkbox" [checked]="product.bestseller" (change)="updateField('bestseller', $any($event.target).checked)" /><span>{{ 'adminProducts.bestseller' | translate }}</span></label>
|
||||||
|
<label class="full"><span>{{ 'adminProducts.badges' | translate }}</span><input type="text" [ngModel]="product.badges.join(', ')" (ngModelChange)="updateList('badges', $event)" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ 'adminProducts.customer' | translate }}</h3>
|
||||||
|
<div class="readonly-grid">
|
||||||
|
<article>
|
||||||
|
<h4>{{ 'adminProducts.reviewsReadonly' | translate }}</h4>
|
||||||
|
@if (product.reviews.length === 0) { <p>{{ 'adminProducts.noReviews' | translate }}</p> }
|
||||||
|
@for (review of product.reviews; track review.id) { <p><strong>{{ review.author }}</strong> · {{ review.rating }}/5<br />{{ review.text }}</p> }
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h4>{{ 'adminProducts.questionsReadonly' | translate }}</h4>
|
||||||
|
@if (product.questions.length === 0) { <p>{{ 'adminProducts.noQuestions' | translate }}</p> }
|
||||||
|
@for (question of product.questions; track question.id) { <p><strong>{{ question.question }}</strong><br />{{ question.answer || '-' }}</p> }
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions"><button type="button" (click)="save.emit()">{{ 'adminProducts.save' | translate }}</button></div>
|
||||||
|
</section>
|
||||||
@@ -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; } }
|
||||||
@@ -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<Partial<AdminProduct>>();
|
||||||
|
@Output() save = new EventEmitter<void>();
|
||||||
|
|
||||||
|
updateField<K extends keyof AdminProduct>(key: K, value: AdminProduct[K]): void {
|
||||||
|
this.productChange.emit({ [key]: value } as Partial<AdminProduct>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AdminProduct>);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinKeyValue(items: Array<{ key: string; value: string }>): string {
|
||||||
|
return items.map(item => `${item.key}|${item.value}`).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<section class="admin-products-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filters">
|
||||||
|
<input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
|
||||||
|
<select [ngModel]="filters.categoryId" (ngModelChange)="filtersChange.emit({ categoryId: $event || null })">
|
||||||
|
<option [ngValue]="null">{{ 'adminProducts.allCategories' | translate }}</option>
|
||||||
|
@for (category of categories; track category.id) {
|
||||||
|
<option [ngValue]="category.id">{{ category.title }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
|
||||||
|
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
|
||||||
|
<option value="visible">{{ 'adminProducts.visible' | translate }}</option>
|
||||||
|
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
|
||||||
|
</select>
|
||||||
|
<select [ngModel]="filters.stock" (ngModelChange)="filtersChange.emit({ stock: $event })">
|
||||||
|
<option value="all">{{ 'adminProducts.allStock' | translate }}</option>
|
||||||
|
<option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option>
|
||||||
|
<option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option>
|
||||||
|
<option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option>
|
||||||
|
</select>
|
||||||
|
<select [ngModel]="filters.sort" (ngModelChange)="filtersChange.emit({ sort: $event })">
|
||||||
|
<option value="title">{{ 'adminProducts.sortTitle' | translate }}</option>
|
||||||
|
<option value="price">{{ 'adminProducts.sortPrice' | translate }}</option>
|
||||||
|
<option value="priority">{{ 'adminProducts.sortPriority' | translate }}</option>
|
||||||
|
<option value="stock">{{ 'adminProducts.sortStock' | translate }}</option>
|
||||||
|
<option value="updated">{{ 'adminProducts.sortUpdated' | translate }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" (click)="create.emit()">{{ 'adminProducts.create' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedIds.length > 0) {
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<button type="button" class="secondary" (click)="bulkVisibility.emit(true)">{{ 'adminProducts.bulkShow' | translate }}</button>
|
||||||
|
<button type="button" class="secondary" (click)="bulkVisibility.emit(false)">{{ 'adminProducts.bulkHide' | translate }}</button>
|
||||||
|
<button type="button" class="secondary danger" (click)="bulkDelete.emit()">{{ 'adminProducts.bulkDelete' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" (change)="selectAll.emit($any($event.target).checked)" /></th>
|
||||||
|
<th>{{ 'adminProducts.name' | translate }}</th>
|
||||||
|
<th>{{ 'backoffice.sku' | translate }}</th>
|
||||||
|
<th>{{ 'adminProducts.brand' | translate }}</th>
|
||||||
|
<th>{{ 'backoffice.price' | translate }}</th>
|
||||||
|
<th>{{ 'backoffice.status' | translate }}</th>
|
||||||
|
<th>{{ 'adminProducts.visibility' | translate }}</th>
|
||||||
|
<th>{{ 'adminProducts.actions' | translate }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (product of products; track product.id) {
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" [checked]="isSelected(product.id)" (change)="selectionChange.emit({ id: product.id, checked: $any($event.target).checked })" /></td>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td>{{ product.sku }}</td>
|
||||||
|
<td>{{ product.brand }}</td>
|
||||||
|
<td>{{ product.price }} {{ product.currency }}</td>
|
||||||
|
<td>{{ product.stockStatus }}</td>
|
||||||
|
<td>{{ product.visible ? ('adminProducts.visible' | translate) : ('adminProducts.hidden' | translate) }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="button" class="secondary" (click)="edit.emit(product.id)">{{ 'adminProducts.edit' | translate }}</button>
|
||||||
|
<button type="button" class="secondary" (click)="duplicate.emit(product.id)">{{ 'adminProducts.duplicate' | translate }}</button>
|
||||||
|
<button type="button" class="secondary danger" (click)="delete.emit(product.id)">{{ 'adminProducts.delete' | translate }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<span>{{ total }} {{ 'adminProducts.items' | translate }}</span>
|
||||||
|
<div class="pager-actions">
|
||||||
|
<button type="button" class="secondary" [disabled]="filters.page <= 1" (click)="filtersChange.emit({ page: filters.page - 1 })">{{ 'catalog.previousPage' | translate }}</button>
|
||||||
|
<button type="button" class="secondary" [disabled]="filters.page * filters.pageSize >= total" (click)="filtersChange.emit({ page: filters.page + 1 })">{{ 'catalog.nextPage' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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; } }
|
||||||
@@ -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<Partial<AdminProductListFilters>>();
|
||||||
|
@Output() create = new EventEmitter<void>();
|
||||||
|
@Output() edit = new EventEmitter<string>();
|
||||||
|
@Output() duplicate = new EventEmitter<string>();
|
||||||
|
@Output() delete = new EventEmitter<string>();
|
||||||
|
@Output() selectionChange = new EventEmitter<{ id: string; checked: boolean }>();
|
||||||
|
@Output() selectAll = new EventEmitter<boolean>();
|
||||||
|
@Output() bulkVisibility = new EventEmitter<boolean>();
|
||||||
|
@Output() bulkDelete = new EventEmitter<void>();
|
||||||
|
|
||||||
|
isSelected(id: string): boolean {
|
||||||
|
return this.selectedIds.includes(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/app/features/admin/products/facade/admin-products.facade.ts
Normal file
112
src/app/features/admin/products/facade/admin-products.facade.ts
Normal file
@@ -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<AdminProductListFilters>({
|
||||||
|
search: '',
|
||||||
|
categoryId: null,
|
||||||
|
visibility: 'all',
|
||||||
|
stock: 'all',
|
||||||
|
sort: 'title',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
readonly products = signal<AdminProduct[]>([]);
|
||||||
|
readonly total = signal(0);
|
||||||
|
readonly categories = signal<AdminProductCategoryOption[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly selectedIds = signal<string[]>([]);
|
||||||
|
readonly draft = signal<AdminProduct | null>(null);
|
||||||
|
readonly editorMode = signal<AdminProductEditorMode>('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<AdminProductListFilters>): 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<AdminProduct>): 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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/features/admin/products/models/admin-product.model.ts
Normal file
101
src/app/features/admin/products/models/admin-product.model.ts
Normal file
@@ -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<string, AdminProductTranslation>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [mode]="facade.editorMode()" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `<app-admin-products-list
|
||||||
|
[products]="facade.products()"
|
||||||
|
[categories]="facade.categories()"
|
||||||
|
[filters]="facade.filters()"
|
||||||
|
[total]="facade.total()"
|
||||||
|
[selectedIds]="facade.selectedIds()"
|
||||||
|
[loading]="facade.loading()"
|
||||||
|
(filtersChange)="facade.updateFilters($event)"
|
||||||
|
(create)="create()"
|
||||||
|
(edit)="edit($event)"
|
||||||
|
(duplicate)="duplicate($event)"
|
||||||
|
(delete)="facade.deleteOne($event)"
|
||||||
|
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||||
|
(selectAll)="facade.toggleAll($event)"
|
||||||
|
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||||
|
(bulkDelete)="facade.applyBulkDelete()" />`,
|
||||||
|
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']); }
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminProductsListResult>;
|
||||||
|
loadProduct(id: string): Observable<AdminProduct | null>;
|
||||||
|
loadCategories(): Observable<AdminProductCategoryOption[]>;
|
||||||
|
createProduct(product: AdminProduct): Observable<AdminProduct>;
|
||||||
|
updateProduct(product: AdminProduct): Observable<AdminProduct>;
|
||||||
|
deleteProduct(id: string): Observable<void>;
|
||||||
|
duplicateProduct(id: string): Observable<AdminProduct | null>;
|
||||||
|
}
|
||||||
@@ -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<AdminProductsListResult> {
|
||||||
|
return new Observable<AdminProductsListResult>(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<AdminProduct | null> {
|
||||||
|
return new Observable<AdminProduct | null>(subscriber => {
|
||||||
|
this.ensureData().then(() => {
|
||||||
|
subscriber.next(this.productsCache?.find(product => product.id === id) ?? null);
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCategories(): Observable<AdminProductCategoryOption[]> {
|
||||||
|
return new Observable<AdminProductCategoryOption[]>(subscriber => {
|
||||||
|
this.ensureData().then(() => {
|
||||||
|
subscriber.next(this.categoriesCache ?? []);
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createProduct(product: AdminProduct): Observable<AdminProduct> {
|
||||||
|
this.productsCache = [product, ...(this.productsCache ?? [])];
|
||||||
|
return of(product).pipe(delay(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProduct(product: AdminProduct): Observable<AdminProduct> {
|
||||||
|
this.productsCache = (this.productsCache ?? []).map(item => item.id === product.id ? product : item);
|
||||||
|
return of(product).pipe(delay(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProduct(id: string): Observable<void> {
|
||||||
|
this.productsCache = (this.productsCache ?? []).filter(product => product.id !== id);
|
||||||
|
return of(void 0).pipe(delay(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateProduct(id: string): Observable<AdminProduct | null> {
|
||||||
|
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<void> {
|
||||||
|
if (this.productsCache && this.categoriesCache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await new Promise<ProductCardConfig[]>(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value)));
|
||||||
|
const categories = await new Promise<CategoryCardConfig[]>(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: `<p>${product.subtitle ?? product.title}</p>`,
|
||||||
|
specifications: [],
|
||||||
|
attributes: [],
|
||||||
|
translations: {
|
||||||
|
en: { name: product.title, shortDescription: product.subtitle ?? '', htmlDescription: `<p>${product.subtitle ?? product.title}</p>` },
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user