feat(admin): add product management

This commit is contained in:
sdarbinyan
2026-07-10 14:14:43 +04:00
parent 7d6c09a346
commit 49d8226411
13 changed files with 789 additions and 0 deletions

View File

@@ -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>

View File

@@ -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; } }

View File

@@ -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');
}
}

View File

@@ -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>

View File

@@ -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; } }

View File

@@ -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);
}
}

View 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() });
}
}

View 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;
}

View File

@@ -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']);
}
}

View File

@@ -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']); }
}

View File

@@ -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(),
};
}
}

View File

@@ -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>;
}

View File

@@ -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);
}
}
}